@createiq/htmldiff 1.2.0-beta.0 → 1.2.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -19
- package/dist/HtmlDiff.cjs +381 -407
- package/dist/HtmlDiff.cjs.map +1 -1
- package/dist/HtmlDiff.d.cts +20 -1
- package/dist/HtmlDiff.d.mts +20 -1
- package/dist/HtmlDiff.mjs +381 -407
- package/dist/HtmlDiff.mjs.map +1 -1
- package/package.json +1 -1
- package/src/HtmlDiff.ts +49 -29
- package/src/ThreeWayDiff.ts +173 -127
- package/src/ThreeWayTable.ts +408 -484
- package/test/HtmlDiff.threeWay.spec.ts +107 -109
- package/test/HtmlDiff.threeWay.tables.spec.ts +88 -194
package/dist/HtmlDiff.mjs
CHANGED
|
@@ -1252,119 +1252,119 @@ function findTopLevelCells(html, start, end) {
|
|
|
1252
1252
|
}
|
|
1253
1253
|
//#endregion
|
|
1254
1254
|
//#region src/ThreeWayDiff.ts
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1255
|
+
/**
|
|
1256
|
+
* Builds the attributed segment stream for a three-way diff.
|
|
1257
|
+
*
|
|
1258
|
+
* @param dCp analysis of diff(genesis → cp-latest)
|
|
1259
|
+
* @param dMe analysis of diff(genesis → me-current)
|
|
1260
|
+
*
|
|
1261
|
+
* Both analyses must share the same `oldDiffWords` (the genesis tokens)
|
|
1262
|
+
* — the caller guarantees this by passing the same genesis input and
|
|
1263
|
+
* the same `useProjections` decision to both `HtmlDiff.analyze` calls.
|
|
1264
|
+
*/
|
|
1265
|
+
function buildSegments(dCp, dMe) {
|
|
1266
|
+
const genesisLen = dCp.oldDiffWords.length;
|
|
1267
|
+
const cpFate = buildFateFromGenesis(dCp.operations, genesisLen);
|
|
1268
|
+
const meFate = buildFateFromGenesis(dMe.operations, genesisLen);
|
|
1269
|
+
const cpInsAt = collectInsertionsKeyedByEnd(dCp);
|
|
1270
|
+
const meInsAt = collectInsertionsKeyedByEnd(dMe);
|
|
1271
|
+
const diffToOriginal = dCp.oldContentToOriginal ?? Array.from({ length: genesisLen }, (_, i) => i);
|
|
1272
|
+
const genesisOriginalLen = dCp.oldOriginalWords.length;
|
|
1263
1273
|
const segments = [];
|
|
1264
1274
|
let originalCursor = 0;
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
author: "cp"
|
|
1270
|
-
}, cpDel);
|
|
1271
|
-
const attr = combine(fromV1[i], toV3[i]);
|
|
1275
|
+
emitBoundary(0, cpInsAt, meInsAt, dCp.newDiffWords, dMe.newDiffWords, segments);
|
|
1276
|
+
for (let i = 0; i < genesisLen; i++) {
|
|
1277
|
+
const cpDel = cpFate[i] === "deleted";
|
|
1278
|
+
const meDel = meFate[i] === "deleted";
|
|
1272
1279
|
const origIdx = diffToOriginal[i];
|
|
1273
|
-
const slice =
|
|
1280
|
+
const slice = dCp.oldOriginalWords.slice(originalCursor, origIdx + 1);
|
|
1274
1281
|
originalCursor = origIdx + 1;
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
kind: "
|
|
1282
|
+
if (!cpDel && !meDel) appendSegment(segments, { kind: "equal" }, slice);
|
|
1283
|
+
else if (cpDel && meDel) {
|
|
1284
|
+
if (slice.length > 1) appendSegment(segments, { kind: "equal" }, slice.slice(0, slice.length - 1));
|
|
1285
|
+
} else if (cpDel) appendSegment(segments, {
|
|
1286
|
+
kind: "del",
|
|
1287
|
+
author: "cp"
|
|
1288
|
+
}, slice);
|
|
1289
|
+
else appendSegment(segments, {
|
|
1290
|
+
kind: "del",
|
|
1284
1291
|
author: "me"
|
|
1285
|
-
},
|
|
1292
|
+
}, slice);
|
|
1293
|
+
emitBoundary(i + 1, cpInsAt, meInsAt, dCp.newDiffWords, dMe.newDiffWords, segments);
|
|
1286
1294
|
}
|
|
1287
|
-
|
|
1288
|
-
if (tailCpDel?.length) appendSegment(segments, {
|
|
1289
|
-
kind: "del",
|
|
1290
|
-
author: "cp"
|
|
1291
|
-
}, tailCpDel);
|
|
1292
|
-
const tailMeIns = meInsertionsAt.get(v2DiffLen);
|
|
1293
|
-
if (tailMeIns?.length) appendSegment(segments, {
|
|
1294
|
-
kind: "ins",
|
|
1295
|
-
author: "me"
|
|
1296
|
-
}, tailMeIns);
|
|
1297
|
-
if (originalCursor < v2OriginalLen) appendSegment(segments, { kind: "equal" }, d1.newOriginalWords.slice(originalCursor));
|
|
1295
|
+
if (originalCursor < genesisOriginalLen) appendSegment(segments, { kind: "equal" }, dCp.oldOriginalWords.slice(originalCursor));
|
|
1298
1296
|
return segments;
|
|
1299
1297
|
}
|
|
1300
|
-
function buildOriginMap(ops, v2Len) {
|
|
1301
|
-
const out = new Array(v2Len).fill("preserved-from-v1");
|
|
1302
|
-
for (const op of ops) {
|
|
1303
|
-
const origin = op.action === 2 ? "inserted-by-cp" : op.action === 4 ? "replaced-into-by-cp" : null;
|
|
1304
|
-
if (origin === null) continue;
|
|
1305
|
-
for (let i = op.startInNew; i < op.endInNew; i++) if (i >= 0 && i < v2Len) out[i] = origin;
|
|
1306
|
-
}
|
|
1307
|
-
return out;
|
|
1308
|
-
}
|
|
1309
|
-
function buildFateMap(ops, v2Len) {
|
|
1310
|
-
const out = new Array(v2Len).fill("preserved-to-v3");
|
|
1311
|
-
for (const op of ops) {
|
|
1312
|
-
const fate = op.action === 1 ? "deleted-by-me" : op.action === 4 ? "replaced-out-by-me" : null;
|
|
1313
|
-
if (fate === null) continue;
|
|
1314
|
-
for (let i = op.startInOld; i < op.endInOld; i++) if (i >= 0 && i < v2Len) out[i] = fate;
|
|
1315
|
-
}
|
|
1316
|
-
return out;
|
|
1317
|
-
}
|
|
1318
|
-
function isDeletion(attr) {
|
|
1319
|
-
return attr.kind === "del" || attr.kind === "reject";
|
|
1320
|
-
}
|
|
1321
|
-
function combine(origin, fate) {
|
|
1322
|
-
const cpInserted = origin === "inserted-by-cp" || origin === "replaced-into-by-cp";
|
|
1323
|
-
const meDeleted = fate === "deleted-by-me" || fate === "replaced-out-by-me";
|
|
1324
|
-
if (!cpInserted && !meDeleted) return { kind: "equal" };
|
|
1325
|
-
if (cpInserted && !meDeleted) return {
|
|
1326
|
-
kind: "ins",
|
|
1327
|
-
author: "cp"
|
|
1328
|
-
};
|
|
1329
|
-
if (!cpInserted && meDeleted) return {
|
|
1330
|
-
kind: "del",
|
|
1331
|
-
author: "me"
|
|
1332
|
-
};
|
|
1333
|
-
return {
|
|
1334
|
-
kind: "reject",
|
|
1335
|
-
by: "me",
|
|
1336
|
-
rejected: "cp"
|
|
1337
|
-
};
|
|
1338
|
-
}
|
|
1339
1298
|
/**
|
|
1340
|
-
*
|
|
1341
|
-
*
|
|
1342
|
-
*
|
|
1299
|
+
* Per genesis-diff-index, what did this side do to that token? Both
|
|
1300
|
+
* Delete and Replace ops remove the token from the side's output, so
|
|
1301
|
+
* both contribute `'deleted'`. Equal ops contribute `'kept'`. Insert
|
|
1302
|
+
* ops have an empty old range, so they don't touch the genesis fate
|
|
1303
|
+
* map.
|
|
1343
1304
|
*/
|
|
1344
|
-
function
|
|
1345
|
-
const out =
|
|
1346
|
-
for (const op of
|
|
1305
|
+
function buildFateFromGenesis(ops, genesisLen) {
|
|
1306
|
+
const out = new Array(genesisLen).fill("kept");
|
|
1307
|
+
for (const op of ops) {
|
|
1347
1308
|
if (op.action !== 1 && op.action !== 4) continue;
|
|
1348
|
-
|
|
1349
|
-
if (words.length === 0) continue;
|
|
1350
|
-
const existing = out.get(op.startInNew) ?? [];
|
|
1351
|
-
existing.push(...words);
|
|
1352
|
-
out.set(op.startInNew, existing);
|
|
1309
|
+
for (let i = op.startInOld; i < op.endInOld; i++) if (i >= 0 && i < genesisLen) out[i] = "deleted";
|
|
1353
1310
|
}
|
|
1354
1311
|
return out;
|
|
1355
1312
|
}
|
|
1356
|
-
|
|
1313
|
+
/**
|
|
1314
|
+
* Per genesis boundary `b`, collect tokens this side inserted at that
|
|
1315
|
+
* boundary. Keyed by `endInOld` so a Replace at genesis[k..k+1] has its
|
|
1316
|
+
* insertion at boundary k+1 (after the deleted token) rather than k
|
|
1317
|
+
* (before) — that produces the del-then-ins visual order.
|
|
1318
|
+
*
|
|
1319
|
+
* For pure Insert ops the old range is empty (endInOld == startInOld),
|
|
1320
|
+
* so the key is the same as the semantic between-tokens position.
|
|
1321
|
+
*/
|
|
1322
|
+
function collectInsertionsKeyedByEnd(d) {
|
|
1357
1323
|
const out = /* @__PURE__ */ new Map();
|
|
1358
1324
|
for (const op of d.operations) {
|
|
1359
1325
|
if (op.action !== 2 && op.action !== 4) continue;
|
|
1360
1326
|
const words = d.newDiffWords.slice(op.startInNew, op.endInNew);
|
|
1361
1327
|
if (words.length === 0) continue;
|
|
1362
|
-
const
|
|
1328
|
+
const key = op.endInOld;
|
|
1329
|
+
const existing = out.get(key) ?? [];
|
|
1363
1330
|
existing.push(...words);
|
|
1364
|
-
out.set(
|
|
1331
|
+
out.set(key, existing);
|
|
1365
1332
|
}
|
|
1366
1333
|
return out;
|
|
1367
1334
|
}
|
|
1335
|
+
/**
|
|
1336
|
+
* Emit any insertions at boundary `b`. When both authors inserted at
|
|
1337
|
+
* the same boundary AND the inserted token sequences are textually
|
|
1338
|
+
* identical, the insertion is treated as agreed and emitted unmarked.
|
|
1339
|
+
* Otherwise each side's insertion is emitted with author attribution.
|
|
1340
|
+
*
|
|
1341
|
+
* The CP-then-Me ordering for disagreement is arbitrary but consistent;
|
|
1342
|
+
* callers don't depend on it.
|
|
1343
|
+
*/
|
|
1344
|
+
function emitBoundary(b, cpInsAt, meInsAt, _cpDiffWords, _meDiffWords, segments) {
|
|
1345
|
+
const cpIns = cpInsAt.get(b);
|
|
1346
|
+
const meIns = meInsAt.get(b);
|
|
1347
|
+
const hasCp = !!cpIns && cpIns.length > 0;
|
|
1348
|
+
const hasMe = !!meIns && meIns.length > 0;
|
|
1349
|
+
if (!hasCp && !hasMe) return;
|
|
1350
|
+
if (hasCp && hasMe && tokenArraysEqual(cpIns, meIns)) {
|
|
1351
|
+
appendSegment(segments, { kind: "equal" }, cpIns);
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
if (hasCp) appendSegment(segments, {
|
|
1355
|
+
kind: "ins",
|
|
1356
|
+
author: "cp"
|
|
1357
|
+
}, cpIns);
|
|
1358
|
+
if (hasMe) appendSegment(segments, {
|
|
1359
|
+
kind: "ins",
|
|
1360
|
+
author: "me"
|
|
1361
|
+
}, meIns);
|
|
1362
|
+
}
|
|
1363
|
+
function tokenArraysEqual(a, b) {
|
|
1364
|
+
if (a.length !== b.length) return false;
|
|
1365
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
1366
|
+
return true;
|
|
1367
|
+
}
|
|
1368
1368
|
function appendSegment(segments, attr, words) {
|
|
1369
1369
|
if (words.length === 0) return;
|
|
1370
1370
|
const last = segments[segments.length - 1];
|
|
@@ -1381,7 +1381,6 @@ function sameAttribution(a, b) {
|
|
|
1381
1381
|
if (a.kind === "equal" && b.kind === "equal") return true;
|
|
1382
1382
|
if (a.kind === "ins" && b.kind === "ins") return a.author === b.author;
|
|
1383
1383
|
if (a.kind === "del" && b.kind === "del") return a.author === b.author;
|
|
1384
|
-
if (a.kind === "reject" && b.kind === "reject") return true;
|
|
1385
1384
|
return false;
|
|
1386
1385
|
}
|
|
1387
1386
|
/**
|
|
@@ -1391,375 +1390,338 @@ function sameAttribution(a, b) {
|
|
|
1391
1390
|
* pre-wrap) stay consistent. A change here propagates to every author
|
|
1392
1391
|
* marker in the output.
|
|
1393
1392
|
*/
|
|
1394
|
-
function authorAttribution(author
|
|
1395
|
-
const dataAttrs = { author };
|
|
1396
|
-
if (rejects !== void 0) dataAttrs.rejects = rejects;
|
|
1393
|
+
function authorAttribution(author) {
|
|
1397
1394
|
return {
|
|
1398
|
-
extraClasses:
|
|
1399
|
-
dataAttrs
|
|
1395
|
+
extraClasses: author,
|
|
1396
|
+
dataAttrs: { author }
|
|
1400
1397
|
};
|
|
1401
1398
|
}
|
|
1402
1399
|
/**
|
|
1403
1400
|
* Resolve a segment's attribution into the wrapper-tag, base CSS class,
|
|
1404
1401
|
* and `WrapMetadata` consumed by `Utils.wrapText` / `insertTag`. The
|
|
1405
1402
|
* caller is `HtmlDiff.executeThreeWay`'s emission loop.
|
|
1403
|
+
*
|
|
1404
|
+
* `equal` segments don't go through this — they're emitted unmarked.
|
|
1406
1405
|
*/
|
|
1407
1406
|
function segmentEmissionShape(attr) {
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
};
|
|
1414
|
-
case "del": return {
|
|
1415
|
-
tag: "del",
|
|
1416
|
-
baseClass: "diffdel",
|
|
1417
|
-
metadata: authorAttribution(attr.author)
|
|
1418
|
-
};
|
|
1419
|
-
case "reject": return {
|
|
1420
|
-
tag: "del",
|
|
1421
|
-
baseClass: "diffdel",
|
|
1422
|
-
metadata: authorAttribution(attr.by, attr.rejected)
|
|
1423
|
-
};
|
|
1424
|
-
}
|
|
1407
|
+
return {
|
|
1408
|
+
tag: attr.kind,
|
|
1409
|
+
baseClass: attr.kind === "ins" ? "diffins" : "diffdel",
|
|
1410
|
+
metadata: authorAttribution(attr.author)
|
|
1411
|
+
};
|
|
1425
1412
|
}
|
|
1426
1413
|
//#endregion
|
|
1427
1414
|
//#region src/ThreeWayTable.ts
|
|
1428
|
-
function preprocessTablesThreeWay(
|
|
1429
|
-
const
|
|
1430
|
-
const
|
|
1431
|
-
const
|
|
1432
|
-
if (
|
|
1433
|
-
for (const t of
|
|
1434
|
-
for (const t of
|
|
1435
|
-
for (const t of
|
|
1436
|
-
const placeholderPrefix = makePlaceholderPrefix(
|
|
1437
|
-
if (positionallyAligned(
|
|
1438
|
-
return
|
|
1439
|
-
}
|
|
1440
|
-
function preprocessAlignedByPosition(
|
|
1415
|
+
function preprocessTablesThreeWay(genesis, cpLatest, meCurrent, cellDiff) {
|
|
1416
|
+
const gTables = findTopLevelTables(genesis);
|
|
1417
|
+
const cTables = findTopLevelTables(cpLatest);
|
|
1418
|
+
const mTables = findTopLevelTables(meCurrent);
|
|
1419
|
+
if (gTables.length === 0 && cTables.length === 0 && mTables.length === 0) return null;
|
|
1420
|
+
for (const t of gTables) if (exceedsSizeLimit(t)) return null;
|
|
1421
|
+
for (const t of cTables) if (exceedsSizeLimit(t)) return null;
|
|
1422
|
+
for (const t of mTables) if (exceedsSizeLimit(t)) return null;
|
|
1423
|
+
const placeholderPrefix = makePlaceholderPrefix(genesis, cpLatest, meCurrent);
|
|
1424
|
+
if (positionallyAligned(genesis, cpLatest, meCurrent, gTables, cTables, mTables)) return preprocessAlignedByPosition(genesis, cpLatest, meCurrent, gTables, cTables, mTables, cellDiff, placeholderPrefix);
|
|
1425
|
+
return preprocessByContent(genesis, cpLatest, meCurrent, gTables, cTables, mTables, cellDiff, placeholderPrefix);
|
|
1426
|
+
}
|
|
1427
|
+
function preprocessAlignedByPosition(genesis, cpLatest, meCurrent, gTables, cTables, mTables, cellDiff, placeholderPrefix) {
|
|
1441
1428
|
const pairs = [];
|
|
1442
|
-
for (let i = 0; i <
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
diffed: diffTableThreeWay(
|
|
1429
|
+
for (let i = 0; i < gTables.length; i++) pairs.push({
|
|
1430
|
+
g: gTables[i],
|
|
1431
|
+
c: cTables[i],
|
|
1432
|
+
m: mTables[i],
|
|
1433
|
+
diffed: diffTableThreeWay(genesis, cpLatest, meCurrent, gTables[i], cTables[i], mTables[i], cellDiff)
|
|
1447
1434
|
});
|
|
1448
|
-
let
|
|
1449
|
-
let
|
|
1450
|
-
let
|
|
1435
|
+
let modifiedGenesis = genesis;
|
|
1436
|
+
let modifiedCp = cpLatest;
|
|
1437
|
+
let modifiedMe = meCurrent;
|
|
1451
1438
|
const placeholderToDiff = /* @__PURE__ */ new Map();
|
|
1452
1439
|
for (let i = pairs.length - 1; i >= 0; i--) {
|
|
1453
1440
|
const placeholder = `${placeholderPrefix}${i}-->`;
|
|
1454
1441
|
placeholderToDiff.set(placeholder, pairs[i].diffed);
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1442
|
+
modifiedGenesis = spliceString(modifiedGenesis, pairs[i].g.tableStart, pairs[i].g.tableEnd, placeholder);
|
|
1443
|
+
modifiedCp = spliceString(modifiedCp, pairs[i].c.tableStart, pairs[i].c.tableEnd, placeholder);
|
|
1444
|
+
modifiedMe = spliceString(modifiedMe, pairs[i].m.tableStart, pairs[i].m.tableEnd, placeholder);
|
|
1458
1445
|
}
|
|
1459
1446
|
return {
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1447
|
+
modifiedGenesis,
|
|
1448
|
+
modifiedCp,
|
|
1449
|
+
modifiedMe,
|
|
1463
1450
|
placeholderToDiff
|
|
1464
1451
|
};
|
|
1465
1452
|
}
|
|
1466
1453
|
/**
|
|
1467
|
-
* Multi-table
|
|
1468
|
-
*
|
|
1469
|
-
* each
|
|
1470
|
-
* table
|
|
1471
|
-
* - paired-everywhere placeholders → equal in both diffs → unwrapped
|
|
1472
|
-
* - V2-only (CP-inserted + Me-rejected) → inserted by CP, deleted by
|
|
1473
|
-
* Me → reject wrapper around the table
|
|
1474
|
-
* - V2+V3 (CP-inserted, Me-kept) → ins-cp wrapper
|
|
1475
|
-
* - V1+V2 (Me-deleted) → del-me wrapper
|
|
1476
|
-
* - V1-only (CP-deleted before V2) → del-cp wrapper
|
|
1477
|
-
* - V3-only (Me-inserted) → ins-me wrapper
|
|
1454
|
+
* Multi-table handler. Tables are paired against `genesis` (the spine)
|
|
1455
|
+
* via content-LCS on each of cp and me. Placeholders are assigned so
|
|
1456
|
+
* each appears only in the inputs that actually contain the underlying
|
|
1457
|
+
* table. The word-level merger then attributes them naturally:
|
|
1478
1458
|
*
|
|
1479
|
-
*
|
|
1480
|
-
*
|
|
1481
|
-
*
|
|
1459
|
+
* - paired in genesis+cp+me → equal in both diffs → emit recursive 3-way diff
|
|
1460
|
+
* - in cp+me, not in genesis → both-agree insertion → emit plain
|
|
1461
|
+
* - in cp only → cp insertion → ins-cp wrapper (Me didn't take it)
|
|
1462
|
+
* - in me only → me insertion → ins-me wrapper
|
|
1463
|
+
* - in genesis+cp, not me → me deletion → del-me wrapper
|
|
1464
|
+
* - in genesis+me, not cp → cp deletion → del-cp wrapper
|
|
1465
|
+
* - in genesis only → both deleted, settled → silent (placeholder content empty)
|
|
1482
1466
|
*/
|
|
1483
|
-
function
|
|
1484
|
-
const
|
|
1485
|
-
const
|
|
1486
|
-
const
|
|
1487
|
-
const
|
|
1488
|
-
const
|
|
1489
|
-
const
|
|
1490
|
-
const
|
|
1491
|
-
for (const a of
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
}
|
|
1495
|
-
const
|
|
1496
|
-
const
|
|
1497
|
-
for (const a of
|
|
1498
|
-
|
|
1499
|
-
|
|
1467
|
+
function preprocessByContent(genesis, cpLatest, meCurrent, gTables, cTables, mTables, cellDiff, placeholderPrefix) {
|
|
1468
|
+
const gKeys = gTables.map((t) => tableKey(genesis, t));
|
|
1469
|
+
const cKeys = cTables.map((t) => tableKey(cpLatest, t));
|
|
1470
|
+
const mKeys = mTables.map((t) => tableKey(meCurrent, t));
|
|
1471
|
+
const alignCp = lcsAlign(gKeys, cKeys);
|
|
1472
|
+
const alignMe = lcsAlign(gKeys, mKeys);
|
|
1473
|
+
const gToCp = new Array(gTables.length).fill(-1);
|
|
1474
|
+
const cpToG = new Array(cTables.length).fill(-1);
|
|
1475
|
+
for (const a of alignCp) if (a.oldIdx !== null && a.newIdx !== null) {
|
|
1476
|
+
gToCp[a.oldIdx] = a.newIdx;
|
|
1477
|
+
cpToG[a.newIdx] = a.oldIdx;
|
|
1478
|
+
}
|
|
1479
|
+
const gToMe = new Array(gTables.length).fill(-1);
|
|
1480
|
+
const meToG = new Array(mTables.length).fill(-1);
|
|
1481
|
+
for (const a of alignMe) if (a.oldIdx !== null && a.newIdx !== null) {
|
|
1482
|
+
gToMe[a.oldIdx] = a.newIdx;
|
|
1483
|
+
meToG[a.newIdx] = a.oldIdx;
|
|
1500
1484
|
}
|
|
1501
1485
|
let nextId = 0;
|
|
1502
1486
|
const placeholderToDiff = /* @__PURE__ */ new Map();
|
|
1503
1487
|
const placeholders = {
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1488
|
+
g: new Array(gTables.length).fill(null),
|
|
1489
|
+
c: new Array(cTables.length).fill(null),
|
|
1490
|
+
m: new Array(mTables.length).fill(null)
|
|
1507
1491
|
};
|
|
1508
1492
|
const allocate = () => `${placeholderPrefix}${nextId++}-->`;
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
const
|
|
1512
|
-
|
|
1493
|
+
const wrapWhole = (tag, author, tableHtml) => Utils_default.wrapText(tableHtml, tag, `diff${tag}`, authorAttribution(author));
|
|
1494
|
+
for (let gIdx = 0; gIdx < gTables.length; gIdx++) {
|
|
1495
|
+
const cIdx = gToCp[gIdx];
|
|
1496
|
+
const mIdx = gToMe[gIdx];
|
|
1497
|
+
if (cIdx === -1 || mIdx === -1) continue;
|
|
1513
1498
|
const placeholder = allocate();
|
|
1514
|
-
placeholderToDiff.set(placeholder, diffTableThreeWay(
|
|
1515
|
-
placeholders.
|
|
1516
|
-
placeholders.
|
|
1517
|
-
placeholders.
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
if (v3Idx === -1) continue;
|
|
1499
|
+
placeholderToDiff.set(placeholder, diffTableThreeWay(genesis, cpLatest, meCurrent, gTables[gIdx], cTables[cIdx], mTables[mIdx], cellDiff));
|
|
1500
|
+
placeholders.g[gIdx] = placeholder;
|
|
1501
|
+
placeholders.c[cIdx] = placeholder;
|
|
1502
|
+
placeholders.m[mIdx] = placeholder;
|
|
1503
|
+
}
|
|
1504
|
+
for (let gIdx = 0; gIdx < gTables.length; gIdx++) {
|
|
1505
|
+
if (placeholders.g[gIdx] !== null) continue;
|
|
1506
|
+
const cIdx = gToCp[gIdx];
|
|
1507
|
+
if (cIdx === -1) continue;
|
|
1524
1508
|
const placeholder = allocate();
|
|
1525
|
-
placeholderToDiff.set(placeholder, wrapWhole("
|
|
1526
|
-
placeholders.
|
|
1527
|
-
placeholders.
|
|
1528
|
-
}
|
|
1529
|
-
for (let
|
|
1530
|
-
if (placeholders.
|
|
1531
|
-
const
|
|
1532
|
-
if (
|
|
1509
|
+
placeholderToDiff.set(placeholder, wrapWhole("del", "me", genesis.slice(gTables[gIdx].tableStart, gTables[gIdx].tableEnd)));
|
|
1510
|
+
placeholders.g[gIdx] = placeholder;
|
|
1511
|
+
placeholders.c[cIdx] = placeholder;
|
|
1512
|
+
}
|
|
1513
|
+
for (let gIdx = 0; gIdx < gTables.length; gIdx++) {
|
|
1514
|
+
if (placeholders.g[gIdx] !== null) continue;
|
|
1515
|
+
const mIdx = gToMe[gIdx];
|
|
1516
|
+
if (mIdx === -1) continue;
|
|
1533
1517
|
const placeholder = allocate();
|
|
1534
|
-
placeholderToDiff.set(placeholder, wrapWhole("del", "
|
|
1535
|
-
placeholders.
|
|
1536
|
-
placeholders.
|
|
1518
|
+
placeholderToDiff.set(placeholder, wrapWhole("del", "cp", genesis.slice(gTables[gIdx].tableStart, gTables[gIdx].tableEnd)));
|
|
1519
|
+
placeholders.g[gIdx] = placeholder;
|
|
1520
|
+
placeholders.m[mIdx] = placeholder;
|
|
1537
1521
|
}
|
|
1538
|
-
for (let
|
|
1539
|
-
if (placeholders.
|
|
1522
|
+
for (let gIdx = 0; gIdx < gTables.length; gIdx++) {
|
|
1523
|
+
if (placeholders.g[gIdx] !== null) continue;
|
|
1524
|
+
const placeholder = allocate();
|
|
1525
|
+
placeholderToDiff.set(placeholder, "");
|
|
1526
|
+
placeholders.g[gIdx] = placeholder;
|
|
1527
|
+
}
|
|
1528
|
+
for (let cIdx = 0; cIdx < cTables.length; cIdx++) {
|
|
1529
|
+
if (placeholders.c[cIdx] !== null) continue;
|
|
1530
|
+
const cText = cKeys[cIdx];
|
|
1531
|
+
let mIdx = -1;
|
|
1532
|
+
for (let candidate = 0; candidate < mTables.length; candidate++) {
|
|
1533
|
+
if (placeholders.m[candidate] !== null) continue;
|
|
1534
|
+
if (meToG[candidate] !== -1) continue;
|
|
1535
|
+
if (mKeys[candidate] === cText) {
|
|
1536
|
+
mIdx = candidate;
|
|
1537
|
+
break;
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
if (mIdx === -1) continue;
|
|
1540
1541
|
const placeholder = allocate();
|
|
1541
|
-
placeholderToDiff.set(placeholder,
|
|
1542
|
-
placeholders.
|
|
1542
|
+
placeholderToDiff.set(placeholder, cpLatest.slice(cTables[cIdx].tableStart, cTables[cIdx].tableEnd));
|
|
1543
|
+
placeholders.c[cIdx] = placeholder;
|
|
1544
|
+
placeholders.m[mIdx] = placeholder;
|
|
1543
1545
|
}
|
|
1544
|
-
for (let
|
|
1545
|
-
if (placeholders.
|
|
1546
|
+
for (let cIdx = 0; cIdx < cTables.length; cIdx++) {
|
|
1547
|
+
if (placeholders.c[cIdx] !== null) continue;
|
|
1546
1548
|
const placeholder = allocate();
|
|
1547
|
-
placeholderToDiff.set(placeholder, wrapWhole("
|
|
1548
|
-
placeholders.
|
|
1549
|
+
placeholderToDiff.set(placeholder, wrapWhole("ins", "cp", cpLatest.slice(cTables[cIdx].tableStart, cTables[cIdx].tableEnd)));
|
|
1550
|
+
placeholders.c[cIdx] = placeholder;
|
|
1549
1551
|
}
|
|
1550
|
-
for (let
|
|
1551
|
-
if (placeholders.
|
|
1552
|
+
for (let mIdx = 0; mIdx < mTables.length; mIdx++) {
|
|
1553
|
+
if (placeholders.m[mIdx] !== null) continue;
|
|
1552
1554
|
const placeholder = allocate();
|
|
1553
|
-
placeholderToDiff.set(placeholder, wrapWhole("ins", "me",
|
|
1554
|
-
placeholders.
|
|
1555
|
+
placeholderToDiff.set(placeholder, wrapWhole("ins", "me", meCurrent.slice(mTables[mIdx].tableStart, mTables[mIdx].tableEnd)));
|
|
1556
|
+
placeholders.m[mIdx] = placeholder;
|
|
1555
1557
|
}
|
|
1556
|
-
let
|
|
1557
|
-
for (let i =
|
|
1558
|
-
const p = placeholders.
|
|
1558
|
+
let modifiedGenesis = genesis;
|
|
1559
|
+
for (let i = gTables.length - 1; i >= 0; i--) {
|
|
1560
|
+
const p = placeholders.g[i];
|
|
1559
1561
|
if (p === null) continue;
|
|
1560
|
-
|
|
1562
|
+
modifiedGenesis = spliceString(modifiedGenesis, gTables[i].tableStart, gTables[i].tableEnd, p);
|
|
1561
1563
|
}
|
|
1562
|
-
let
|
|
1563
|
-
for (let i =
|
|
1564
|
-
const p = placeholders.
|
|
1564
|
+
let modifiedCp = cpLatest;
|
|
1565
|
+
for (let i = cTables.length - 1; i >= 0; i--) {
|
|
1566
|
+
const p = placeholders.c[i];
|
|
1565
1567
|
if (p === null) continue;
|
|
1566
|
-
|
|
1568
|
+
modifiedCp = spliceString(modifiedCp, cTables[i].tableStart, cTables[i].tableEnd, p);
|
|
1567
1569
|
}
|
|
1568
|
-
let
|
|
1569
|
-
for (let i =
|
|
1570
|
-
const p = placeholders.
|
|
1570
|
+
let modifiedMe = meCurrent;
|
|
1571
|
+
for (let i = mTables.length - 1; i >= 0; i--) {
|
|
1572
|
+
const p = placeholders.m[i];
|
|
1571
1573
|
if (p === null) continue;
|
|
1572
|
-
|
|
1574
|
+
modifiedMe = spliceString(modifiedMe, mTables[i].tableStart, mTables[i].tableEnd, p);
|
|
1573
1575
|
}
|
|
1574
1576
|
return {
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1577
|
+
modifiedGenesis,
|
|
1578
|
+
modifiedCp,
|
|
1579
|
+
modifiedMe,
|
|
1578
1580
|
placeholderToDiff
|
|
1579
1581
|
};
|
|
1580
1582
|
}
|
|
1581
|
-
/**
|
|
1582
|
-
* Threshold at which positional pairing is considered sound. Below this
|
|
1583
|
-
* similarity, two positionally-aligned tables are probably different
|
|
1584
|
-
* tables (e.g. CP swapped them around) and content-LCS pairing should
|
|
1585
|
-
* be used instead. 0.5 is a deliberately loose bar — paired-but-content-
|
|
1586
|
-
* edited tables (the common case) sit well above it; genuinely different
|
|
1587
|
-
* tables sit well below.
|
|
1588
|
-
*/
|
|
1589
1583
|
const POSITIONAL_PAIR_SIMILARITY_THRESHOLD = .5;
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
if (t1s.length !== t2s.length || t2s.length !== t3s.length) return false;
|
|
1599
|
-
for (let i = 0; i < t1s.length; i++) {
|
|
1600
|
-
const k1 = tableKey(v1, t1s[i]);
|
|
1601
|
-
const k2 = tableKey(v2, t2s[i]);
|
|
1602
|
-
const k3 = tableKey(v3, t3s[i]);
|
|
1603
|
-
if (textSimilarity(k1, k2) < POSITIONAL_PAIR_SIMILARITY_THRESHOLD) return false;
|
|
1604
|
-
if (textSimilarity(k2, k3) < POSITIONAL_PAIR_SIMILARITY_THRESHOLD) return false;
|
|
1584
|
+
function positionallyAligned(genesis, cpLatest, meCurrent, gTables, cTables, mTables) {
|
|
1585
|
+
if (gTables.length !== cTables.length || cTables.length !== mTables.length) return false;
|
|
1586
|
+
for (let i = 0; i < gTables.length; i++) {
|
|
1587
|
+
const kG = tableKey(genesis, gTables[i]);
|
|
1588
|
+
const kC = tableKey(cpLatest, cTables[i]);
|
|
1589
|
+
const kM = tableKey(meCurrent, mTables[i]);
|
|
1590
|
+
if (textSimilarity(kG, kC) < POSITIONAL_PAIR_SIMILARITY_THRESHOLD) return false;
|
|
1591
|
+
if (textSimilarity(kG, kM) < POSITIONAL_PAIR_SIMILARITY_THRESHOLD) return false;
|
|
1605
1592
|
}
|
|
1606
1593
|
return true;
|
|
1607
1594
|
}
|
|
1608
1595
|
function tableKey(html, table) {
|
|
1609
1596
|
return html.slice(table.tableStart, table.tableEnd).replace(/\s+/g, " ").trim();
|
|
1610
1597
|
}
|
|
1611
|
-
function diffTableThreeWay(
|
|
1612
|
-
if (sameDimensions(
|
|
1613
|
-
return diffTableStructural(
|
|
1598
|
+
function diffTableThreeWay(genesis, cpLatest, meCurrent, tG, tC, tM, cellDiff) {
|
|
1599
|
+
if (sameDimensions(tG, tC) && sameDimensions(tC, tM)) return diffTablePositional(genesis, cpLatest, meCurrent, tG, tC, tM, cellDiff);
|
|
1600
|
+
return diffTableStructural(genesis, cpLatest, meCurrent, tG, tC, tM, cellDiff);
|
|
1614
1601
|
}
|
|
1615
|
-
function diffTablePositional(
|
|
1602
|
+
function diffTablePositional(genesis, cpLatest, meCurrent, tG, tC, tM, cellDiff) {
|
|
1616
1603
|
const out = [];
|
|
1617
|
-
let cursor =
|
|
1618
|
-
for (let r = 0; r <
|
|
1619
|
-
const
|
|
1620
|
-
const
|
|
1621
|
-
const
|
|
1622
|
-
for (let c = 0; c <
|
|
1623
|
-
const
|
|
1624
|
-
const
|
|
1625
|
-
const
|
|
1626
|
-
out.push(
|
|
1627
|
-
out.push(cellDiff(
|
|
1628
|
-
cursor =
|
|
1604
|
+
let cursor = tG.tableStart;
|
|
1605
|
+
for (let r = 0; r < tG.rows.length; r++) {
|
|
1606
|
+
const rG = tG.rows[r];
|
|
1607
|
+
const rC = tC.rows[r];
|
|
1608
|
+
const rM = tM.rows[r];
|
|
1609
|
+
for (let c = 0; c < rG.cells.length; c++) {
|
|
1610
|
+
const cG = rG.cells[c];
|
|
1611
|
+
const cC = rC.cells[c];
|
|
1612
|
+
const cM = rM.cells[c];
|
|
1613
|
+
out.push(genesis.slice(cursor, cG.contentStart));
|
|
1614
|
+
out.push(cellDiff(genesis.slice(cG.contentStart, cG.contentEnd), cpLatest.slice(cC.contentStart, cC.contentEnd), meCurrent.slice(cM.contentStart, cM.contentEnd)));
|
|
1615
|
+
cursor = cG.contentEnd;
|
|
1629
1616
|
}
|
|
1630
1617
|
}
|
|
1631
|
-
out.push(
|
|
1618
|
+
out.push(genesis.slice(cursor, tG.tableEnd));
|
|
1632
1619
|
return out.join("");
|
|
1633
1620
|
}
|
|
1634
1621
|
/**
|
|
1635
|
-
*
|
|
1636
|
-
*
|
|
1637
|
-
* 1. Run row-LCS for each pair (V1↔V2, V2↔V3) over rowKeys
|
|
1638
|
-
* 2. Build per-V2-row origin (from align1) and fate (from align2)
|
|
1639
|
-
* 3. Walk V2's row order, interleaving:
|
|
1640
|
-
* - CP-deleted V1 rows (in align1 but not preserved into V2)
|
|
1641
|
-
* - Me-inserted V3 rows (in align2 but not from V2)
|
|
1642
|
-
* 4. For each V2 row, combine origin+fate to decide:
|
|
1643
|
-
* - equal: recurse cellDiff if cell counts match, else fall back
|
|
1644
|
-
* - ins-cp: emit V2 row as fully-CP-inserted
|
|
1645
|
-
* - del-me: emit V2 row as fully-Me-deleted
|
|
1646
|
-
* - reject: emit V2 row as Me-rejects-CP
|
|
1622
|
+
* Row-level genesis-spine merge for tables with diverging row/cell
|
|
1623
|
+
* counts.
|
|
1647
1624
|
*
|
|
1648
|
-
*
|
|
1649
|
-
*
|
|
1650
|
-
*
|
|
1651
|
-
*
|
|
1652
|
-
*
|
|
1653
|
-
*
|
|
1625
|
+
* 1. Align cp rows to genesis rows (alignCp), me rows to genesis rows
|
|
1626
|
+
* (alignMe), each via row-LCS over rowKeys.
|
|
1627
|
+
* 2. Per genesis row: cpFate (kept / deleted), meFate (kept / deleted).
|
|
1628
|
+
* Both kept → recurse cell diff (with structural-change cell handling
|
|
1629
|
+
* falling back to me-attribution Replace per the documented
|
|
1630
|
+
* limitation). One kept, other deleted → emit author-attributed full
|
|
1631
|
+
* row. Both deleted → silent.
|
|
1632
|
+
* 3. Off-spine rows: cp-only inserted rows + me-only inserted rows.
|
|
1633
|
+
* Check for content agreement at the same boundary; agreed
|
|
1634
|
+
* insertions emit plain.
|
|
1654
1635
|
*/
|
|
1655
|
-
function diffTableStructural(
|
|
1656
|
-
const
|
|
1657
|
-
const
|
|
1658
|
-
const
|
|
1659
|
-
const
|
|
1660
|
-
const
|
|
1661
|
-
const
|
|
1662
|
-
for (
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
const v2Fate = new Array(t2.rows.length);
|
|
1668
|
-
for (let i = 0; i < v2Fate.length; i++) v2Fate[i] = { kind: "me-deleted" };
|
|
1669
|
-
for (const a of align2) if (a.oldIdx !== null && a.newIdx !== null) v2Fate[a.oldIdx] = {
|
|
1670
|
-
kind: "preserved",
|
|
1671
|
-
v3Idx: a.newIdx
|
|
1672
|
-
};
|
|
1673
|
-
const cpDelRowsAt = collectCpDelRowsAtBoundary(align1, t2.rows.length);
|
|
1674
|
-
const meInsRowsAt = collectMeInsRowsAtBoundary(align2, t2.rows.length);
|
|
1636
|
+
function diffTableStructural(genesis, cpLatest, meCurrent, tG, tC, tM, cellDiff) {
|
|
1637
|
+
const gKeys = tG.rows.map((r) => rowKey(genesis, r));
|
|
1638
|
+
const cKeys = tC.rows.map((r) => rowKey(cpLatest, r));
|
|
1639
|
+
const mKeys = tM.rows.map((r) => rowKey(meCurrent, r));
|
|
1640
|
+
const alignCp = lcsAlign(gKeys, cKeys);
|
|
1641
|
+
const alignMe = lcsAlign(gKeys, mKeys);
|
|
1642
|
+
const gToCp = new Array(tG.rows.length).fill(-1);
|
|
1643
|
+
for (const a of alignCp) if (a.oldIdx !== null && a.newIdx !== null) gToCp[a.oldIdx] = a.newIdx;
|
|
1644
|
+
const gToMe = new Array(tG.rows.length).fill(-1);
|
|
1645
|
+
for (const a of alignMe) if (a.oldIdx !== null && a.newIdx !== null) gToMe[a.oldIdx] = a.newIdx;
|
|
1646
|
+
const cpInsAt = collectInsertedRowsAtBoundary(alignCp, tG.rows.length);
|
|
1647
|
+
const meInsAt = collectInsertedRowsAtBoundary(alignMe, tG.rows.length);
|
|
1675
1648
|
const out = [];
|
|
1676
|
-
out.push(tableHeaderSlice(
|
|
1677
|
-
const
|
|
1678
|
-
const
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1649
|
+
out.push(tableHeaderSlice(genesis, tG));
|
|
1650
|
+
const emitBoundaryInsertions = (b) => {
|
|
1651
|
+
const cIdxs = cpInsAt.get(b) ?? [];
|
|
1652
|
+
const mIdxs = meInsAt.get(b) ?? [];
|
|
1653
|
+
if (cIdxs.length === 0 && mIdxs.length === 0) return;
|
|
1654
|
+
const remainingMe = new Set(mIdxs);
|
|
1655
|
+
for (const cIdx of cIdxs) {
|
|
1656
|
+
const cText = cKeys[cIdx];
|
|
1657
|
+
let agreedMeIdx;
|
|
1658
|
+
for (const mIdx of remainingMe) if (mKeys[mIdx] === cText) {
|
|
1659
|
+
agreedMeIdx = mIdx;
|
|
1660
|
+
break;
|
|
1661
|
+
}
|
|
1662
|
+
if (agreedMeIdx !== void 0) {
|
|
1663
|
+
remainingMe.delete(agreedMeIdx);
|
|
1664
|
+
out.push(cpLatest.slice(tC.rows[cIdx].rowStart, tC.rows[cIdx].rowEnd));
|
|
1665
|
+
} else out.push(emitFullRowAttributed(cpLatest, tC.rows[cIdx], "ins", "cp"));
|
|
1666
|
+
}
|
|
1667
|
+
for (const mIdx of remainingMe) out.push(emitFullRowAttributed(meCurrent, tM.rows[mIdx], "ins", "me"));
|
|
1682
1668
|
};
|
|
1683
|
-
for (let
|
|
1684
|
-
|
|
1685
|
-
const
|
|
1686
|
-
const
|
|
1687
|
-
const
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
if (origin.kind === "cp-inserted" && fate.kind === "me-deleted") return emitFullRowAttributed(v2, v2Row, "del", "me", "cp");
|
|
1696
|
-
if (origin.kind === "cp-inserted") return emitFullRowAttributed(v2, v2Row, "ins", "cp");
|
|
1697
|
-
if (fate.kind === "me-deleted") return emitFullRowAttributed(v2, v2Row, "del", "me");
|
|
1698
|
-
const v1Row = t1.rows[origin.v1Idx];
|
|
1699
|
-
const v3Row = t3.rows[fate.v3Idx];
|
|
1700
|
-
if (v1Row.cells.length === v2Row.cells.length && v2Row.cells.length === v3Row.cells.length) return diffRowPositional(v1, v2, v3, v1Row, v2Row, v3Row, cellDiff);
|
|
1701
|
-
const out = [];
|
|
1702
|
-
out.push(emitFullRowAttributed(v2, v2Row, "del", "me"));
|
|
1703
|
-
out.push(emitFullRowAttributed(v3, v3Row, "ins", "me"));
|
|
1669
|
+
for (let g = 0; g < tG.rows.length; g++) {
|
|
1670
|
+
emitBoundaryInsertions(g);
|
|
1671
|
+
const cIdx = gToCp[g];
|
|
1672
|
+
const mIdx = gToMe[g];
|
|
1673
|
+
const cpDel = cIdx === -1;
|
|
1674
|
+
const meDel = mIdx === -1;
|
|
1675
|
+
if (!cpDel && !meDel) out.push(emitPreservedRow(genesis, cpLatest, meCurrent, tG.rows[g], tC.rows[cIdx], tM.rows[mIdx], cellDiff));
|
|
1676
|
+
else if (cpDel && meDel) {} else if (cpDel) out.push(emitFullRowAttributed(meCurrent, tM.rows[mIdx], "del", "cp"));
|
|
1677
|
+
else out.push(emitFullRowAttributed(cpLatest, tC.rows[cIdx], "del", "me"));
|
|
1678
|
+
}
|
|
1679
|
+
emitBoundaryInsertions(tG.rows.length);
|
|
1680
|
+
out.push(tableFooterSlice(genesis, tG));
|
|
1704
1681
|
return out.join("");
|
|
1705
1682
|
}
|
|
1706
|
-
function
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
function collectCpDelRowsAtBoundary(align, v2RowCount) {
|
|
1721
|
-
const out = /* @__PURE__ */ new Map();
|
|
1722
|
-
let nextV2Boundary = v2RowCount;
|
|
1723
|
-
const pending = [];
|
|
1724
|
-
for (let i = align.length - 1; i >= 0; i--) {
|
|
1725
|
-
const a = align[i];
|
|
1726
|
-
if (a.newIdx !== null) {
|
|
1727
|
-
if (pending.length > 0) {
|
|
1728
|
-
const existing = out.get(nextV2Boundary) ?? [];
|
|
1729
|
-
existing.unshift(...pending.toReversed());
|
|
1730
|
-
out.set(nextV2Boundary, existing);
|
|
1731
|
-
pending.length = 0;
|
|
1732
|
-
}
|
|
1733
|
-
nextV2Boundary = a.newIdx;
|
|
1734
|
-
} else if (a.oldIdx !== null) pending.push(a.oldIdx);
|
|
1735
|
-
}
|
|
1736
|
-
if (pending.length > 0) {
|
|
1737
|
-
const existing = out.get(nextV2Boundary) ?? [];
|
|
1738
|
-
existing.unshift(...pending.reverse());
|
|
1739
|
-
out.set(nextV2Boundary, existing);
|
|
1683
|
+
function emitPreservedRow(genesis, cpLatest, meCurrent, rG, rC, rM, cellDiff) {
|
|
1684
|
+
if (rG.cells.length === rC.cells.length && rC.cells.length === rM.cells.length) {
|
|
1685
|
+
const out = [];
|
|
1686
|
+
let cursor = rG.rowStart;
|
|
1687
|
+
for (let c = 0; c < rG.cells.length; c++) {
|
|
1688
|
+
const cG = rG.cells[c];
|
|
1689
|
+
const cC = rC.cells[c];
|
|
1690
|
+
const cM = rM.cells[c];
|
|
1691
|
+
out.push(genesis.slice(cursor, cG.contentStart));
|
|
1692
|
+
out.push(cellDiff(genesis.slice(cG.contentStart, cG.contentEnd), cpLatest.slice(cC.contentStart, cC.contentEnd), meCurrent.slice(cM.contentStart, cM.contentEnd)));
|
|
1693
|
+
cursor = cG.contentEnd;
|
|
1694
|
+
}
|
|
1695
|
+
out.push(genesis.slice(cursor, rG.rowEnd));
|
|
1696
|
+
return out.join("");
|
|
1740
1697
|
}
|
|
1741
|
-
return
|
|
1698
|
+
return emitFullRowAttributed(genesis, rG, "del", "me") + emitFullRowAttributed(meCurrent, rM, "ins", "me");
|
|
1742
1699
|
}
|
|
1743
|
-
|
|
1700
|
+
/**
|
|
1701
|
+
* Returns map "genesis-row-boundary → list of new-side row indices
|
|
1702
|
+
* inserted at that boundary". Mirrors the word-level boundary collection
|
|
1703
|
+
* but at the row scale.
|
|
1704
|
+
*/
|
|
1705
|
+
function collectInsertedRowsAtBoundary(align, genesisRowCount) {
|
|
1744
1706
|
const out = /* @__PURE__ */ new Map();
|
|
1745
|
-
let
|
|
1707
|
+
let nextGenesisBoundary = genesisRowCount;
|
|
1746
1708
|
const pending = [];
|
|
1747
1709
|
for (let i = align.length - 1; i >= 0; i--) {
|
|
1748
1710
|
const a = align[i];
|
|
1749
1711
|
if (a.oldIdx !== null) {
|
|
1750
1712
|
if (pending.length > 0) {
|
|
1751
|
-
const existing = out.get(
|
|
1713
|
+
const existing = out.get(nextGenesisBoundary) ?? [];
|
|
1752
1714
|
existing.unshift(...pending.toReversed());
|
|
1753
|
-
out.set(
|
|
1715
|
+
out.set(nextGenesisBoundary, existing);
|
|
1754
1716
|
pending.length = 0;
|
|
1755
1717
|
}
|
|
1756
|
-
|
|
1718
|
+
nextGenesisBoundary = a.oldIdx;
|
|
1757
1719
|
} else if (a.newIdx !== null) pending.push(a.newIdx);
|
|
1758
1720
|
}
|
|
1759
1721
|
if (pending.length > 0) {
|
|
1760
|
-
const existing = out.get(
|
|
1761
|
-
existing.unshift(...pending.
|
|
1762
|
-
out.set(
|
|
1722
|
+
const existing = out.get(nextGenesisBoundary) ?? [];
|
|
1723
|
+
existing.unshift(...pending.toReversed());
|
|
1724
|
+
out.set(nextGenesisBoundary, existing);
|
|
1763
1725
|
}
|
|
1764
1726
|
return out;
|
|
1765
1727
|
}
|
|
@@ -1774,42 +1736,35 @@ function tableFooterSlice(html, table) {
|
|
|
1774
1736
|
return html.slice(lastRow.rowEnd, table.tableEnd);
|
|
1775
1737
|
}
|
|
1776
1738
|
/**
|
|
1777
|
-
* Emit a row
|
|
1778
|
-
*
|
|
1779
|
-
*
|
|
1780
|
-
*
|
|
1781
|
-
* author classes/attrs.
|
|
1739
|
+
* Emit a row fully attributed to one author. Wraps `<tr>` and each
|
|
1740
|
+
* `<td>` with the author's diffins/diffdel class and `data-author`
|
|
1741
|
+
* attribute; wraps cell content with an inner `<ins>`/`<del>` matching
|
|
1742
|
+
* the word-level emission shape.
|
|
1782
1743
|
*/
|
|
1783
|
-
function emitFullRowAttributed(html, row, kind, author
|
|
1744
|
+
function emitFullRowAttributed(html, row, kind, author) {
|
|
1784
1745
|
const trOpening = parseOpeningTagAt(html, row.rowStart);
|
|
1785
|
-
if (!trOpening) return html.slice(
|
|
1786
|
-
const out = [injectAuthorAttribution(html.slice(row.rowStart, trOpening.end), kind, author
|
|
1746
|
+
if (!trOpening) return html.slice(row.rowStart, row.rowEnd);
|
|
1747
|
+
const out = [injectAuthorAttribution(html.slice(row.rowStart, trOpening.end), kind, author)];
|
|
1787
1748
|
let cursor = trOpening.end;
|
|
1788
1749
|
for (const cell of row.cells) {
|
|
1789
1750
|
out.push(html.slice(cursor, cell.cellStart));
|
|
1790
|
-
out.push(emitFullCellAttributed(html, cell, kind, author
|
|
1751
|
+
out.push(emitFullCellAttributed(html, cell, kind, author));
|
|
1791
1752
|
cursor = cell.cellEnd;
|
|
1792
1753
|
}
|
|
1793
1754
|
out.push(html.slice(cursor, row.rowEnd));
|
|
1794
1755
|
return out.join("");
|
|
1795
1756
|
}
|
|
1796
|
-
function emitFullCellAttributed(html, cell, kind, author
|
|
1757
|
+
function emitFullCellAttributed(html, cell, kind, author) {
|
|
1797
1758
|
const tdOpening = parseOpeningTagAt(html, cell.cellStart);
|
|
1798
1759
|
if (!tdOpening) return html.slice(cell.cellStart, cell.cellEnd);
|
|
1799
|
-
const tdWithAttrs = injectAuthorAttribution(html.slice(cell.cellStart, tdOpening.end), kind, author
|
|
1760
|
+
const tdWithAttrs = injectAuthorAttribution(html.slice(cell.cellStart, tdOpening.end), kind, author);
|
|
1800
1761
|
const innerContent = html.slice(cell.contentStart, cell.contentEnd);
|
|
1801
|
-
const innerWrapped = innerContent.trim().length === 0 ? innerContent : Utils_default.wrapText(innerContent, kind, `diff${kind}`, authorAttribution(author
|
|
1762
|
+
const innerWrapped = innerContent.trim().length === 0 ? innerContent : Utils_default.wrapText(innerContent, kind, `diff${kind}`, authorAttribution(author));
|
|
1802
1763
|
const closing = html.slice(cell.contentEnd, cell.cellEnd);
|
|
1803
1764
|
return tdWithAttrs + innerWrapped + closing;
|
|
1804
1765
|
}
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
* an `<tr>` or `<td>` already in the source HTML). Uses the same
|
|
1808
|
-
* attribution shape as `authorAttribution` + `Utils.wrapText` so the
|
|
1809
|
-
* inject-into-existing and wrap-around-text paths agree.
|
|
1810
|
-
*/
|
|
1811
|
-
function injectAuthorAttribution(openingTag, kind, author, rejectsAuthor) {
|
|
1812
|
-
const meta = authorAttribution(author, rejectsAuthor);
|
|
1766
|
+
function injectAuthorAttribution(openingTag, kind, author) {
|
|
1767
|
+
const meta = authorAttribution(author);
|
|
1813
1768
|
return injectDataAttrs(injectClass(openingTag, `diff${kind} ${meta.extraClasses}`), meta.dataAttrs ?? {});
|
|
1814
1769
|
}
|
|
1815
1770
|
function injectDataAttrs(openingTag, dataAttrs) {
|
|
@@ -2241,25 +2196,44 @@ var HtmlDiff = class HtmlDiff {
|
|
|
2241
2196
|
* pairs would project on their own. Pass an explicit boolean to
|
|
2242
2197
|
* override.
|
|
2243
2198
|
*/
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2199
|
+
/**
|
|
2200
|
+
* Three-way HTML diff against a shared genesis. Produces attributed
|
|
2201
|
+
* HTML that distinguishes CP's accumulated changes (genesis → cpLatest)
|
|
2202
|
+
* from Me's accumulated changes (genesis → meCurrent). Use this for
|
|
2203
|
+
* blackline UX where the negotiation has gone through multiple turns
|
|
2204
|
+
* and the reader wants to see "who proposed what" across the whole
|
|
2205
|
+
* history, not just the most recent round.
|
|
2206
|
+
*
|
|
2207
|
+
* When both parties happen to have made the same change (e.g. CP
|
|
2208
|
+
* proposed a wording change in turn N, Me adopted it in turn N+1),
|
|
2209
|
+
* the change reads as "settled" and is emitted unmarked — only
|
|
2210
|
+
* disagreements and pending proposals carry author attribution.
|
|
2211
|
+
*
|
|
2212
|
+
* @param genesis the shared common ancestor (per-user — the FE
|
|
2213
|
+
* picks between V1.0 and /preview/initialAnswers
|
|
2214
|
+
* based on `prefillReceiverAnswers`)
|
|
2215
|
+
* @param cpLatest the counterparty's current published version
|
|
2216
|
+
* @param meCurrent Me's current draft (the document on screen)
|
|
2217
|
+
*/
|
|
2218
|
+
static executeThreeWay(genesis, cpLatest, meCurrent, options = {}) {
|
|
2219
|
+
return HtmlDiff.executeThreeWayWithDepth(genesis, cpLatest, meCurrent, options, 0);
|
|
2220
|
+
}
|
|
2221
|
+
static executeThreeWayWithDepth(genesis, cpLatest, meCurrent, options, depth) {
|
|
2222
|
+
const tablePreprocess = depth < HtmlDiff.MaxThreeWayDepth ? preprocessTablesThreeWay(genesis, cpLatest, meCurrent, (g, c, m) => HtmlDiff.executeThreeWayWithDepth(g, c, m, options, depth + 1)) : null;
|
|
2223
|
+
const inGenesis = tablePreprocess?.modifiedGenesis ?? genesis;
|
|
2224
|
+
const inCp = tablePreprocess?.modifiedCp ?? cpLatest;
|
|
2225
|
+
const inMe = tablePreprocess?.modifiedMe ?? meCurrent;
|
|
2252
2226
|
const analyzeOpts = {
|
|
2253
|
-
useProjections: options.useProjections ?? (HtmlDiff.evaluateProjectionApplicability(
|
|
2227
|
+
useProjections: options.useProjections ?? (HtmlDiff.evaluateProjectionApplicability(inGenesis, inCp) && HtmlDiff.evaluateProjectionApplicability(inGenesis, inMe)),
|
|
2254
2228
|
blockExpressions: options.blockExpressions,
|
|
2255
2229
|
repeatingWordsAccuracy: options.repeatingWordsAccuracy,
|
|
2256
2230
|
orphanMatchThreshold: options.orphanMatchThreshold,
|
|
2257
2231
|
ignoreWhitespaceDifferences: options.ignoreWhitespaceDifferences
|
|
2258
2232
|
};
|
|
2259
|
-
const
|
|
2260
|
-
const
|
|
2261
|
-
if (
|
|
2262
|
-
const segments = buildSegments(
|
|
2233
|
+
const dCp = HtmlDiff.analyze(inGenesis, inCp, analyzeOpts);
|
|
2234
|
+
const dMe = HtmlDiff.analyze(inGenesis, inMe, analyzeOpts);
|
|
2235
|
+
if (dCp.oldDiffWords.length !== dMe.oldDiffWords.length) throw new Error(`HtmlDiff.executeThreeWay: genesis tokenisation diverged across pair-wise analyses (${dCp.oldDiffWords.length} vs ${dMe.oldDiffWords.length}). This indicates the symmetric-projection coordination has a bug.`);
|
|
2236
|
+
const segments = buildSegments(dCp, dMe);
|
|
2263
2237
|
const merged = HtmlDiff.emitSegments(segments);
|
|
2264
2238
|
return tablePreprocess ? restoreTablePlaceholders(merged, tablePreprocess.placeholderToDiff) : merged;
|
|
2265
2239
|
}
|