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;