diff --git a/offline-collaboration-conflict-resolver/README.md b/offline-collaboration-conflict-resolver/README.md new file mode 100644 index 0000000..b1fd647 --- /dev/null +++ b/offline-collaboration-conflict-resolver/README.md @@ -0,0 +1,46 @@ +# Offline Collaboration Conflict Resolver + +This module implements a focused sync layer for the SCIBASE real-time collaborative research editor bounty. It handles the tricky part that appears when researchers edit scientific manuscripts offline and later reconnect: +deterministic operation replay, section-lock conflict detection, suggestion/comment merge safety, audit reports, and restore-ready snapshots. + +It is intentionally dependency-free so reviewers can run it with stock Node.js. + +## What It Covers + +- Client-side offline operation queues with actor and block metadata. +- Rebase of offline edits on top of server operations. +- Section lock checks so protected manuscript areas are not overwritten. +- Safe inline comment and suggestion merging. +- Idempotent operation replay so retried offline operations do not apply twice. +- Stale version and missing suggestion conflict reporting. +- Restore-ready snapshots with content hashes. +- Reviewer-facing audit reports with stable audit hashes and duplicate replay counts. + +## Demo + +```bash +npm run demo +``` + +The demo prints a sync report where an abstract edit is applied, a locked methods edit is blocked for manual review, and a review suggestion is safely accepted. + +Demo artifacts: `docs/demo.gif` and `docs/demo.svg`. + +## Verification + +```bash +npm run check +npm test +npm run demo +``` + +## Files + +- `src/conflict-resolver.js` - core queue, rebase, snapshot, and report logic. +- `test/conflict-resolver.test.js` - focused tests for rebase, locks, suggestions, duplicate replay, and snapshots. +- `scripts/demo.js` - CLI demo with sample scientific manuscript blocks. +- `docs/issue-12-requirement-map.md` - mapping from issue requirements to implementation evidence. + +## AI-Assisted Disclosure + +This contribution was produced with AI assistance and manually verified before submission. diff --git a/offline-collaboration-conflict-resolver/docs/demo.gif b/offline-collaboration-conflict-resolver/docs/demo.gif new file mode 100644 index 0000000..c06090f Binary files /dev/null and b/offline-collaboration-conflict-resolver/docs/demo.gif differ diff --git a/offline-collaboration-conflict-resolver/docs/demo.svg b/offline-collaboration-conflict-resolver/docs/demo.svg new file mode 100644 index 0000000..4166a64 --- /dev/null +++ b/offline-collaboration-conflict-resolver/docs/demo.svg @@ -0,0 +1,32 @@ + + Offline collaboration conflict resolver demo + A simple diagram showing offline edits rebased onto server operations with an audit report. + + + Offline queue + off-1 abstract edit + off-2 locked method edit + off-3 suggestion resolve + + Server revision + remote abstract update + methods section locked + open review suggestion + + + Rebase result + 2 applied + 1 manual review + restore snapshot saved + + + Conflict audit report + status: manual-review-required + blocker: section-lock on methods, owned by reviewer-2 + audit hash and restore snapshot make sync decisions reviewable + + + + + + diff --git a/offline-collaboration-conflict-resolver/docs/issue-12-requirement-map.md b/offline-collaboration-conflict-resolver/docs/issue-12-requirement-map.md new file mode 100644 index 0000000..80cc9e1 --- /dev/null +++ b/offline-collaboration-conflict-resolver/docs/issue-12-requirement-map.md @@ -0,0 +1,19 @@ +# Issue #12 Requirement Map + +This module is a focused offline collaboration slice for the real-time collaborative research editor. It complements the broader editor modules by handling the state that accumulates when a researcher edits a manuscript while disconnected. + +| Issue #12 requirement | Implementation evidence | +| --- | --- | +| Multi-user editing with live collaboration semantics | `queueOfflineOperation` records actor-scoped client edits, and `rebaseOfflineQueue` replays them on top of remote server operations. | +| Inline comments, suggestions, and change tracking | Offline comments, suggestions, and retried operation ids are merged idempotently; suggestion resolution is audited and blocked if the suggestion was already removed. | +| Locking / unlock modes for controlled sections | `detectConflict` blocks offline edits when a section has an active lock owned by another collaborator. | +| Continuous autosave with local caching | `createSnapshot` creates restore-ready snapshots with content hashes before or after sync. | +| Fine-grained version tracking | Each block carries a version, and stale offline edits are audited when the server version has advanced. | +| Restore previous versions or compare changes | The post-rebase snapshot contains a `restorePayload`, stable `contentHash`, and audit hash for comparison. | +| Integrated review workflow | Conflict reports identify manual-review blockers before risky edits overwrite locked sections or resolved suggestions. | + +## Reviewer Notes + +- The implementation is dependency-free and can be reviewed with stock Node.js. +- It is intentionally not a UI mock. The value is deterministic sync behavior that a real editor UI or API can call. +- The demo includes one applied stale edit, one locked-section conflict, and one accepted offline suggestion resolution. diff --git a/offline-collaboration-conflict-resolver/package.json b/offline-collaboration-conflict-resolver/package.json new file mode 100644 index 0000000..02a487d --- /dev/null +++ b/offline-collaboration-conflict-resolver/package.json @@ -0,0 +1,13 @@ +{ + "name": "offline-collaboration-conflict-resolver", + "version": "1.0.0", + "description": "Offline edit rebase and conflict audit module for the SCIBASE real-time collaborative research editor bounty.", + "main": "src/conflict-resolver.js", + "type": "commonjs", + "scripts": { + "check": "node --check src/conflict-resolver.js && node --check scripts/demo.js && node --check test/conflict-resolver.test.js", + "demo": "node scripts/demo.js", + "test": "node test/conflict-resolver.test.js" + }, + "license": "Apache-2.0" +} diff --git a/offline-collaboration-conflict-resolver/scripts/demo.js b/offline-collaboration-conflict-resolver/scripts/demo.js new file mode 100644 index 0000000..669747d --- /dev/null +++ b/offline-collaboration-conflict-resolver/scripts/demo.js @@ -0,0 +1,89 @@ +"use strict"; + +const { + queueOfflineOperation, + rebaseOfflineQueue, + buildConflictReport +} = require("../src/conflict-resolver"); + +const baseDocument = { + id: "manuscript-alpha", + baseRevision: "rev-17", + locks: [ + { sectionId: "methods", ownerId: "reviewer-2", status: "active" } + ], + blocks: [ + { + id: "abstract", + sectionId: "frontmatter", + type: "markdown", + content: "We introduce a catalyst screening workflow.", + citations: ["doi:10.1000/base"], + version: 2 + }, + { + id: "method-step", + sectionId: "methods", + type: "notebook", + content: "Run catalyst notebook", + version: 3 + }, + { + id: "discussion", + sectionId: "discussion", + type: "latex", + content: "The yield improves by 12%.", + suggestions: [{ id: "sug-1", status: "open", text: "Mention confidence interval." }], + version: 1 + } + ] +}; + +const serverOperations = [ + { + id: "srv-1", + actorId: "editor-remote", + kind: "update-block", + blockId: "abstract", + content: "We introduce a reproducible catalyst screening workflow." + } +]; + +let offlineQueue = []; +offlineQueue = queueOfflineOperation(offlineQueue, { + id: "off-1", + actorId: "author-1", + blockId: "abstract", + expectedVersion: 2, + content: "We introduce a reproducible catalyst screening workflow for open labs.", + citations: ["doi:10.1000/open-labs"] +}); +offlineQueue = queueOfflineOperation(offlineQueue, { + id: "off-2", + actorId: "author-1", + blockId: "method-step", + expectedVersion: 3, + content: "Run catalyst notebook with seeded environment capture." +}); +offlineQueue = queueOfflineOperation(offlineQueue, { + id: "off-3", + actorId: "author-3", + blockId: "discussion", + kind: "resolve-suggestion", + suggestionId: "sug-1", + expectedVersion: 1 +}); + +const result = rebaseOfflineQueue(baseDocument, serverOperations, offlineQueue); +const report = buildConflictReport(result); + +console.log(JSON.stringify({ + report, + applied: result.applied, + blocked: result.blocked, + snapshot: { + id: result.snapshot.id, + contentHash: result.snapshot.contentHash, + blockCount: result.snapshot.blockCount + } +}, null, 2)); diff --git a/offline-collaboration-conflict-resolver/src/conflict-resolver.js b/offline-collaboration-conflict-resolver/src/conflict-resolver.js new file mode 100644 index 0000000..fa6808a --- /dev/null +++ b/offline-collaboration-conflict-resolver/src/conflict-resolver.js @@ -0,0 +1,288 @@ +"use strict"; + +const crypto = require("crypto"); + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function normalizeDocument(document) { + if (!document || !Array.isArray(document.blocks)) { + throw new Error("document.blocks must be an array"); + } + + const blocks = document.blocks.map((block, index) => ({ + id: block.id || `block-${index + 1}`, + type: block.type || "markdown", + sectionId: block.sectionId || "body", + content: block.content || "", + citations: Array.isArray(block.citations) ? [...block.citations] : [], + comments: Array.isArray(block.comments) ? clone(block.comments) : [], + suggestions: Array.isArray(block.suggestions) ? clone(block.suggestions) : [], + version: Number.isInteger(block.version) ? block.version : 1 + })); + + return { + id: document.id || "research-document", + baseRevision: document.baseRevision || "rev-0", + blocks, + locks: Array.isArray(document.locks) ? clone(document.locks) : [], + snapshots: Array.isArray(document.snapshots) ? clone(document.snapshots) : [] + }; +} + +function hashPayload(value) { + return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +function createSnapshot(document, label) { + const normalized = normalizeDocument(document); + return { + id: `snapshot-${hashPayload(normalized).slice(0, 12)}`, + label, + baseRevision: normalized.baseRevision, + blockCount: normalized.blocks.length, + contentHash: hashPayload(normalized.blocks), + restorePayload: clone(normalized.blocks) + }; +} + +function queueOfflineOperation(queue, operation) { + if (!operation || !operation.id || !operation.actorId || !operation.blockId) { + throw new Error("offline operation requires id, actorId, and blockId"); + } + + return [...queue, { + kind: "update-block", + timestamp: "offline", + expectedVersion: 1, + ...operation + }]; +} + +function findBlock(document, blockId) { + return document.blocks.find((block) => block.id === blockId); +} + +function isSectionLocked(document, sectionId, actorId) { + return document.locks.find((lock) => + lock.sectionId === sectionId && + lock.status === "active" && + lock.ownerId !== actorId + ); +} + +function applyServerOperation(document, operation) { + const next = clone(document); + const block = findBlock(next, operation.blockId); + + if (!block) { + next.blocks.push({ + id: operation.blockId, + type: operation.type || "markdown", + sectionId: operation.sectionId || "body", + content: operation.content || "", + citations: [], + comments: [], + suggestions: [], + version: 1 + }); + return next; + } + + if (operation.kind === "delete-block") { + next.blocks = next.blocks.filter((candidate) => candidate.id !== operation.blockId); + return next; + } + + if (operation.content !== undefined) { + block.content = operation.content; + } + if (operation.citations) { + block.citations = [...new Set([...block.citations, ...operation.citations])]; + } + block.version += 1; + return next; +} + +function detectConflict(document, operation) { + const block = findBlock(document, operation.blockId); + if (!block) { + return { + type: "missing-block", + severity: "high", + reason: `Block ${operation.blockId} no longer exists on the server revision.` + }; + } + + const lock = isSectionLocked(document, block.sectionId, operation.actorId); + if (lock) { + return { + type: "section-lock", + severity: "high", + reason: `Section ${block.sectionId} is locked by ${lock.ownerId}.` + }; + } + + if (operation.expectedVersion !== undefined && operation.expectedVersion < block.version) { + return { + type: "stale-version", + severity: "warning", + reason: `Offline edit expected version ${operation.expectedVersion}, but server block is version ${block.version}.` + }; + } + + if (operation.kind === "resolve-suggestion" && !block.suggestions.some((item) => item.id === operation.suggestionId)) { + return { + type: "missing-suggestion", + severity: "medium", + reason: `Suggestion ${operation.suggestionId} was already resolved or removed.` + }; + } + + return null; +} + +function mergeUpdate(block, operation) { + const next = clone(block); + if (operation.content !== undefined) { + next.content = operation.content; + } + if (Array.isArray(operation.citations)) { + next.citations = [...new Set([...next.citations, ...operation.citations])]; + } + if (operation.comment) { + const exists = next.comments.some((comment) => comment.id === operation.comment.id); + if (!exists) { + next.comments.push(operation.comment); + } + } + if (operation.suggestion) { + const exists = next.suggestions.some((suggestion) => suggestion.id === operation.suggestion.id); + if (!exists) { + next.suggestions.push(operation.suggestion); + } + } + next.version += 1; + return next; +} + +function applyOfflineOperation(document, operation) { + const next = clone(document); + const index = next.blocks.findIndex((block) => block.id === operation.blockId); + if (index === -1) { + return next; + } + + if (operation.kind === "resolve-suggestion") { + next.blocks[index].suggestions = next.blocks[index].suggestions.map((suggestion) => + suggestion.id === operation.suggestionId + ? { ...suggestion, status: "accepted", resolvedBy: operation.actorId } + : suggestion + ); + next.blocks[index].version += 1; + return next; + } + + next.blocks[index] = mergeUpdate(next.blocks[index], operation); + return next; +} + +function rebaseOfflineQueue(baseDocument, serverOperations, offlineQueue) { + let serverDocument = normalizeDocument(baseDocument); + const audit = []; + const applied = []; + const blocked = []; + const seenOperationIds = new Set(); + + for (const operation of serverOperations) { + serverDocument = applyServerOperation(serverDocument, operation); + } + + for (const operation of offlineQueue) { + if (seenOperationIds.has(operation.id)) { + audit.push({ + operationId: operation.id, + actorId: operation.actorId, + status: "skipped-duplicate", + blockId: operation.blockId + }); + continue; + } + seenOperationIds.add(operation.id); + + const conflict = detectConflict(serverDocument, operation); + if (conflict && conflict.severity !== "warning") { + blocked.push({ operationId: operation.id, conflict }); + audit.push({ + operationId: operation.id, + actorId: operation.actorId, + status: "blocked", + conflict + }); + continue; + } + + serverDocument = applyOfflineOperation(serverDocument, operation); + applied.push(operation.id); + audit.push({ + operationId: operation.id, + actorId: operation.actorId, + status: conflict ? "applied-with-warning" : "applied", + blockId: operation.blockId, + warning: conflict || undefined + }); + } + + return { + document: serverDocument, + applied, + blocked, + audit, + snapshot: createSnapshot(serverDocument, "post-offline-rebase") + }; +} + +function buildConflictReport(result) { + const blockers = result.blocked.map((entry) => ({ + operationId: entry.operationId, + type: entry.conflict.type, + severity: entry.conflict.severity, + reason: entry.conflict.reason + })); + const warnings = result.audit + .filter((entry) => entry.warning) + .map((entry) => ({ + operationId: entry.operationId, + type: entry.warning.type, + reason: entry.warning.reason + })); + const skipped = result.audit + .filter((entry) => entry.status === "skipped-duplicate") + .map((entry) => ({ + operationId: entry.operationId, + type: "duplicate-operation", + reason: "Operation id was already replayed in this offline sync batch." + })); + + return { + status: blockers.length === 0 ? "ready-to-sync" : "manual-review-required", + appliedCount: result.applied.length, + blockedCount: blockers.length, + warningCount: warnings.length, + skippedCount: skipped.length, + blockers, + warnings, + skipped, + auditHash: hashPayload(result.audit), + restoreSnapshotId: result.snapshot.id + }; +} + +module.exports = { + normalizeDocument, + createSnapshot, + queueOfflineOperation, + rebaseOfflineQueue, + buildConflictReport +}; diff --git a/offline-collaboration-conflict-resolver/test/conflict-resolver.test.js b/offline-collaboration-conflict-resolver/test/conflict-resolver.test.js new file mode 100644 index 0000000..d79354f --- /dev/null +++ b/offline-collaboration-conflict-resolver/test/conflict-resolver.test.js @@ -0,0 +1,177 @@ +"use strict"; + +const assert = require("assert"); +const { + queueOfflineOperation, + rebaseOfflineQueue, + buildConflictReport, + createSnapshot +} = require("../src/conflict-resolver"); + +function fixture() { + return { + id: "doc-1", + baseRevision: "rev-1", + locks: [{ sectionId: "locked", ownerId: "editor-b", status: "active" }], + blocks: [ + { + id: "intro", + sectionId: "body", + type: "markdown", + content: "Initial claim.", + citations: [], + version: 1 + }, + { + id: "locked-block", + sectionId: "locked", + type: "markdown", + content: "Final figure caption.", + citations: [], + version: 1 + }, + { + id: "review", + sectionId: "review", + type: "markdown", + content: "Review note.", + suggestions: [{ id: "s1", status: "open", text: "Add limitation." }], + version: 1 + } + ] + }; +} + +function testRebasesOfflineUpdateAfterServerChange() { + let queue = []; + queue = queueOfflineOperation(queue, { + id: "offline-intro", + actorId: "editor-a", + blockId: "intro", + expectedVersion: 1, + content: "Initial claim with replication package.", + citations: ["doi:10.5555/repro"] + }); + + const result = rebaseOfflineQueue(fixture(), [ + { + id: "server-intro", + actorId: "editor-b", + kind: "update-block", + blockId: "intro", + content: "Initial claim with shared dataset." + } + ], queue); + + assert.deepStrictEqual(result.applied, ["offline-intro"]); + assert.strictEqual(result.blocked.length, 0); + assert.strictEqual(result.document.blocks[0].content, "Initial claim with replication package."); + assert.deepStrictEqual(result.document.blocks[0].citations, ["doi:10.5555/repro"]); + assert.strictEqual(buildConflictReport(result).warningCount, 1); + assert.ok(result.snapshot.contentHash); +} + +function testBlocksLockedSectionConflict() { + let queue = []; + queue = queueOfflineOperation(queue, { + id: "offline-locked", + actorId: "editor-a", + blockId: "locked-block", + expectedVersion: 1, + content: "Changed final figure caption." + }); + + const result = rebaseOfflineQueue(fixture(), [], queue); + const report = buildConflictReport(result); + + assert.deepStrictEqual(result.applied, []); + assert.strictEqual(result.blocked[0].conflict.type, "section-lock"); + assert.strictEqual(report.status, "manual-review-required"); + assert.strictEqual(report.blockedCount, 1); +} + +function testSuggestionResolutionIsSafe() { + let queue = []; + queue = queueOfflineOperation(queue, { + id: "offline-suggestion", + actorId: "reviewer-a", + blockId: "review", + kind: "resolve-suggestion", + suggestionId: "s1", + expectedVersion: 1 + }); + + const result = rebaseOfflineQueue(fixture(), [], queue); + const suggestion = result.document.blocks + .find((block) => block.id === "review") + .suggestions.find((item) => item.id === "s1"); + + assert.deepStrictEqual(result.applied, ["offline-suggestion"]); + assert.strictEqual(suggestion.status, "accepted"); + assert.strictEqual(suggestion.resolvedBy, "reviewer-a"); +} + +function testDuplicateOfflineOperationIsSkipped() { + let queue = []; + const operation = { + id: "offline-duplicate", + actorId: "editor-a", + blockId: "intro", + expectedVersion: 1, + content: "Initial claim with one offline replay." + }; + queue = queueOfflineOperation(queue, operation); + queue = queueOfflineOperation(queue, operation); + + const result = rebaseOfflineQueue(fixture(), [], queue); + const report = buildConflictReport(result); + const intro = result.document.blocks.find((block) => block.id === "intro"); + const duplicateAudit = result.audit.find((entry) => entry.status === "skipped-duplicate"); + + assert.deepStrictEqual(result.applied, ["offline-duplicate"]); + assert.strictEqual(intro.version, 2); + assert.strictEqual(duplicateAudit.operationId, "offline-duplicate"); + assert.strictEqual(report.skippedCount, 1); + assert.strictEqual(report.skipped[0].type, "duplicate-operation"); +} + +function testMissingSuggestionIsAudited() { + let queue = []; + queue = queueOfflineOperation(queue, { + id: "offline-missing-suggestion", + actorId: "reviewer-a", + blockId: "review", + kind: "resolve-suggestion", + suggestionId: "missing", + expectedVersion: 1 + }); + + const result = rebaseOfflineQueue(fixture(), [], queue); + const report = buildConflictReport(result); + + assert.strictEqual(result.blocked[0].conflict.type, "missing-suggestion"); + assert.ok(report.auditHash); +} + +function testSnapshotIsRestoreReady() { + const snapshot = createSnapshot(fixture(), "before-sync"); + assert.strictEqual(snapshot.label, "before-sync"); + assert.strictEqual(snapshot.blockCount, 3); + assert.ok(Array.isArray(snapshot.restorePayload)); + assert.ok(snapshot.contentHash.length >= 32); +} + +const tests = [ + testRebasesOfflineUpdateAfterServerChange, + testBlocksLockedSectionConflict, + testSuggestionResolutionIsSafe, + testDuplicateOfflineOperationIsSkipped, + testMissingSuggestionIsAudited, + testSnapshotIsRestoreReady +]; + +for (const test of tests) { + test(); +} + +console.log(`${tests.length} conflict resolver tests passed`);