stackchain-dashboard/frontend/checklist-conflict.js
timmy 4589c46f16
All checks were successful
CI / lint (pull_request) Successful in 1m59s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 56s
CI / release-candidate (pull_request) Has been skipped
feat: manage mobile checklist steps inline (Closes #919)
2026-08-15 22:38:52 +00:00

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;