142 lines
6.1 KiB
JavaScript
142 lines
6.1 KiB
JavaScript
function mergeChecklistConflict({ baseBody, localBody, remoteBody, operations = [] }) {
|
|
const taskPattern = /^(\s*[-*+]\s+\[)([ xX])(\]\s+)(.*)$/;
|
|
|
|
function tasks(body) {
|
|
const entries = [];
|
|
String(body || '').split('\n').forEach((line, lineIndex) => {
|
|
const match = line.match(taskPattern);
|
|
if (!match) return;
|
|
const label = match[4].trim();
|
|
const key = label.replace(/\s+/g, ' ').toLocaleLowerCase();
|
|
if (!key) return;
|
|
entries.push({ key, label, checked: match[2].toLowerCase() === 'x', lineIndex, match });
|
|
});
|
|
return entries;
|
|
}
|
|
|
|
function grouped(entries) {
|
|
const result = new Map();
|
|
entries.forEach(entry => result.set(entry.key, [...(result.get(entry.key) || []), entry]));
|
|
return result;
|
|
}
|
|
|
|
function replayOperations(body, requested, reportChanges) {
|
|
let lines = String(body || '').split('\n');
|
|
const visibleTasks = value => tasks(value).filter(entry => /^[-*+]/.test(entry.match[1]));
|
|
const logical = visibleTasks(baseBody).map(entry => ({ key:entry.key, label:entry.label }));
|
|
const replayed = [];
|
|
for (const operation of requested) {
|
|
const index = Number(operation?.index);
|
|
const target = logical[index];
|
|
if (!target) return { body:null, changes:replayed, conflict:{ label:'Checklist step', reason:'missing' } };
|
|
let entries = visibleTasks(lines.join('\n'));
|
|
const matches = entries.filter(entry => entry.key === target.key);
|
|
if (matches.length !== 1) {
|
|
return { body:null, changes:replayed, conflict:{
|
|
label:target.label, reason:matches.length ? 'ambiguous' : 'missing',
|
|
} };
|
|
}
|
|
const match = matches[0];
|
|
if (operation.action === 'rename') {
|
|
const label = String(operation.label || '').trim().replace(/\s+/g, ' ');
|
|
const key = label.toLocaleLowerCase();
|
|
if (!label || entries.some(entry => entry.key === key && entry.lineIndex !== match.lineIndex)) {
|
|
return { body:null, changes:replayed, conflict:{ label:target.label, reason:'ambiguous' } };
|
|
}
|
|
lines[match.lineIndex] = match.match[1] + match.match[2] + match.match[3] + label;
|
|
if (reportChanges) replayed.push({ label:target.label, renamed:label });
|
|
target.label = label;
|
|
target.key = key;
|
|
} else if (operation.action === 'remove') {
|
|
lines.splice(match.lineIndex, 1);
|
|
logical.splice(index, 1);
|
|
if (reportChanges) replayed.push({ label:target.label, removed:true });
|
|
} else if (operation.action === 'move-earlier' || operation.action === 'move-later') {
|
|
const neighborIndex = operation.action === 'move-earlier' ? index - 1 : index + 1;
|
|
const neighbor = logical[neighborIndex];
|
|
const neighborMatches = neighbor ? entries.filter(entry => entry.key === neighbor.key) : [];
|
|
if (neighborMatches.length !== 1 || Math.abs(neighborMatches[0].lineIndex - match.lineIndex) !== 1) {
|
|
return { body:null, changes:replayed, conflict:{ label:target.label, reason:'order-changed' } };
|
|
}
|
|
const neighborLine = neighborMatches[0].lineIndex;
|
|
[lines[match.lineIndex], lines[neighborLine]] = [lines[neighborLine], lines[match.lineIndex]];
|
|
[logical[index], logical[neighborIndex]] = [logical[neighborIndex], logical[index]];
|
|
if (reportChanges) replayed.push({
|
|
label:target.label, moved:operation.action === 'move-earlier' ? 'earlier' : 'later',
|
|
});
|
|
}
|
|
}
|
|
return { body:lines.join('\n'), changes:replayed, conflict:null };
|
|
}
|
|
|
|
if (Array.isArray(operations) && operations.length) {
|
|
const baseReplay = replayOperations(baseBody, operations, false);
|
|
const remoteReplay = replayOperations(remoteBody, operations, true);
|
|
const conflict = baseReplay.conflict || remoteReplay.conflict;
|
|
if (conflict) return { body:null, changes:remoteReplay.changes || [], conflicts:[conflict] };
|
|
const residual = mergeChecklistConflict({
|
|
baseBody:baseReplay.body, localBody, remoteBody:remoteReplay.body,
|
|
});
|
|
return {
|
|
body:residual.body,
|
|
changes:[...remoteReplay.changes, ...residual.changes],
|
|
conflicts:residual.conflicts,
|
|
};
|
|
}
|
|
|
|
const base = grouped(tasks(baseBody));
|
|
const local = grouped(tasks(localBody));
|
|
const remoteEntries = tasks(remoteBody);
|
|
const remote = grouped(remoteEntries);
|
|
const changes = [];
|
|
const conflicts = [];
|
|
|
|
for (const [key, baseMatches] of base) {
|
|
const localMatches = local.get(key) || [];
|
|
if (baseMatches.length !== 1 || localMatches.length !== 1) continue;
|
|
if (baseMatches[0].checked === localMatches[0].checked) continue;
|
|
const remoteMatches = remote.get(key) || [];
|
|
if (remoteMatches.length !== 1) {
|
|
conflicts.push({
|
|
label: baseMatches[0].label,
|
|
reason: remoteMatches.length ? 'ambiguous' : 'missing',
|
|
});
|
|
continue;
|
|
}
|
|
changes.push({ label: remoteMatches[0].label, checked: localMatches[0].checked });
|
|
}
|
|
|
|
for (const [key, localMatches] of local) {
|
|
if (base.has(key)) continue;
|
|
if (localMatches.length !== 1) {
|
|
conflicts.push({ label:localMatches[0].label, reason:'ambiguous' });
|
|
continue;
|
|
}
|
|
const remoteMatches = remote.get(key) || [];
|
|
if (remoteMatches.length > 1) {
|
|
conflicts.push({ label: localMatches[0].label, reason: 'ambiguous' });
|
|
continue;
|
|
}
|
|
if (remoteMatches.length === 0) {
|
|
changes.push({ label: localMatches[0].label, checked:localMatches[0].checked, added:true });
|
|
}
|
|
}
|
|
|
|
if (conflicts.length) return { body: null, changes, conflicts };
|
|
const desired = new Map(changes.map(change => [
|
|
change.label.replace(/\s+/g, ' ').toLocaleLowerCase(), change.checked,
|
|
]));
|
|
const lines = String(remoteBody || '').split('\n');
|
|
remoteEntries.forEach(entry => {
|
|
if (!desired.has(entry.key)) return;
|
|
const marker = desired.get(entry.key) ? 'x' : ' ';
|
|
lines[entry.lineIndex] = entry.match[1] + marker + entry.match[3] + entry.match[4];
|
|
});
|
|
changes.filter(change => change.added).forEach(change => {
|
|
lines.push('- [' + (change.checked ? 'x' : ' ') + '] ' + change.label);
|
|
});
|
|
return { body: lines.join('\n'), changes, conflicts: [] };
|
|
}
|
|
|
|
if (typeof module !== 'undefined') module.exports = mergeChecklistConflict;
|