diff --git a/collaborative-editor-governance/README.md b/collaborative-editor-governance/README.md new file mode 100644 index 0000000..e0beec4 --- /dev/null +++ b/collaborative-editor-governance/README.md @@ -0,0 +1,66 @@ +# Collaborative Editor Governance + +Self-contained collaborative research editor governance milestone for [SCIBASE.AI issue #12](https://github.com/SCIBASE-AI/SCIBASE.AI/issues/12). + +The issue asks for a real-time collaborative research editor. This module focuses on a reviewable core for deterministic operation replay and editorial governance: typed scientific blocks, locks, comments, suggestions, tasks, snapshots, collaborator presence, and publication outline export. + +## What It Adds + +- Scientific document blocks with Markdown, LaTeX, code metadata, references, and section headings. +- Deterministic operation handling for block inserts, updates, deletes, comments, suggestions, and tasks. +- Section-lock enforcement that rejects edits from non-owners. +- Inline comment and suggestion state for peer review. +- Autosave/version snapshot with content hash and open review counts. +- Open-task dashboard and collaborator presence summary. +- Scientific formatting summary covering LaTeX, code highlighting, notebook blocks, reference-provider metadata, citation resolution, and publication templates. +- Offline collaboration conflict report for queued client operations, stale block versions, section-lock conflicts, missing review targets, safe suggestion conversion, restore snapshots, and audit hashes. +- Publication outline export with section block types, word counts, and export hash. +- Sample document fixture, tests, requirement map, CLI demo, and short demo GIF. + +## Run + +```bash +cd collaborative-editor-governance +npm run check +npm test +npm run demo +``` + +Expected demo shape: + +```json +{ + "title": "Longitudinal microbiome shifts after coastal flooding", + "acceptedOperations": 3, + "rejectedOperations": 1, + "readyForSubmission": false, + "formatting": { + "supportsLatex": true, + "referenceManager": { + "providers": ["zotero"], + "unresolvedCitations": [] + } + }, + "offlineConflicts": { + "queueCount": 1, + "conflictCodes": ["REVIEW_TARGET_MISSING", "SECTION_LOCK_CONFLICT", "STALE_BLOCK_VERSION"] + }, + "outlineHash": "..." +} +``` + +## Demo Artifact + +See [docs/demo.gif](docs/demo.gif) for a short visual walkthrough. The SVG source is included at [docs/demo.svg](docs/demo.svg). + +## Files + +- `src/editor-governance.js` - operation replay, locks, snapshots, dashboard, offline conflict resolution, and outline export. +- `data/sample-document.json` - reviewable scientific document fixture. +- `test/editor-governance.test.js` - dependency-free Node tests. +- `scripts/demo.js` - CLI demo. +- `docs/issue-12-requirement-map.md` - maps the implementation to bounty requirements. + +## AI-Assisted Disclosure + +This contribution was produced with AI assistance and manually verified with the local commands above. diff --git a/collaborative-editor-governance/data/sample-document.json b/collaborative-editor-governance/data/sample-document.json new file mode 100644 index 0000000..ccce812 --- /dev/null +++ b/collaborative-editor-governance/data/sample-document.json @@ -0,0 +1,159 @@ +{ + "document": { + "id": "doc-flooding-microbiome", + "title": "Longitudinal microbiome shifts after coastal flooding", + "collaborators": [ + { "id": "u-1", "name": "Principal investigator", "role": "owner" }, + { "id": "u-2", "name": "Methods reviewer", "role": "reviewer" }, + { "id": "u-3", "name": "Data analyst", "role": "editor" } + ], + "blocks": [ + { + "id": "block-abstract", + "sectionId": "abstract", + "type": "markdown", + "content": "We report longitudinal microbiome shifts after coastal flooding.", + "metadata": { "heading": "Abstract" } + }, + { + "id": "block-methods", + "sectionId": "methods", + "type": "markdown", + "content": "Samples were collected monthly and sequenced with a documented protocol @smith2026.", + "metadata": { "heading": "Methods" } + }, + { + "id": "block-equation", + "sectionId": "methods", + "type": "latex", + "content": "$H = -\\sum_i p_i \\log(p_i)$", + "metadata": { "label": "eq:diversity" } + }, + { + "id": "block-code", + "sectionId": "methods", + "type": "code", + "content": "normalize_counts(samples)", + "metadata": { "language": "python" } + } + ], + "comments": [], + "suggestions": [], + "locks": [ + { "sectionId": "methods", "ownerId": "u-2", "status": "active" } + ], + "tasks": [ + { "id": "task-1", "title": "Attach data dictionary", "assigneeId": "u-3", "status": "open" } + ], + "presence": [ + { + "userId": "u-1", + "name": "Principal investigator", + "sectionId": "abstract", + "cursorBlockId": "block-abstract", + "lastSeenAt": "2026-05-14T07:00:00Z" + }, + { + "userId": "u-2", + "name": "Methods reviewer", + "sectionId": "methods", + "cursorBlockId": "block-methods", + "lastSeenAt": "2026-05-14T07:00:00Z" + } + ], + "references": [ + { + "key": "smith2026", + "provider": "zotero", + "title": "Flooding exposure sequencing protocol", + "doi": "10.5555/example" + } + ], + "publicationTemplates": [ + { + "id": "nature-methods", + "name": "Nature Methods", + "style": "nature", + "requiredSections": ["abstract", "methods", "results", "references"] + } + ], + "versions": [], + "offlineQueues": [ + { + "clientId": "offline-u-3", + "baseVersionId": "snapshot-draft-1", + "baseContentHash": "draft-base-hash", + "operations": [ + { + "type": "update-block", + "actorId": "u-3", + "blockId": "block-abstract", + "baseBlockHash": "stale-abstract-hash", + "content": "We report flood-linked microbiome shifts with a larger validation cohort.", + "rebasedSuggestionId": "suggestion-offline-abstract" + }, + { + "type": "update-block", + "actorId": "u-3", + "blockId": "block-methods", + "baseBlockHash": "stale-methods-hash", + "content": "Offline edit should wait because the methods section is locked." + }, + { + "type": "comment", + "id": "comment-missing-block", + "actorId": "u-3", + "blockId": "block-removed", + "body": "This comment targets a block that disappeared while offline." + }, + { + "type": "insert-block", + "actorId": "u-3", + "index": 4, + "block": { + "id": "block-offline-note", + "sectionId": "discussion", + "type": "markdown", + "content": "Offline reviewer note preserved after reconnect.", + "metadata": { "heading": "Discussion" } + } + } + ] + } + ] + }, + "operations": [ + { + "type": "comment", + "id": "comment-1", + "actorId": "u-2", + "blockId": "block-methods", + "body": "Please cite the sequencing protocol and include reagent version." + }, + { + "type": "suggestion", + "id": "suggestion-1", + "actorId": "u-2", + "blockId": "block-methods", + "proposedContent": "Samples were collected monthly and sequenced with protocol v2.1." + }, + { + "type": "update-block", + "actorId": "u-3", + "blockId": "block-methods", + "content": "This edit should be rejected while the methods section is locked." + }, + { + "type": "insert-block", + "actorId": "u-1", + "index": 3, + "block": { + "id": "block-results", + "sectionId": "results", + "type": "markdown", + "content": "Observed diversity increased after flooding and moved toward baseline after remediation.", + "metadata": { "heading": "Results" } + } + } + ] +} diff --git a/collaborative-editor-governance/docs/demo.gif b/collaborative-editor-governance/docs/demo.gif new file mode 100644 index 0000000..2037cd4 Binary files /dev/null and b/collaborative-editor-governance/docs/demo.gif differ diff --git a/collaborative-editor-governance/docs/demo.mp4 b/collaborative-editor-governance/docs/demo.mp4 new file mode 100644 index 0000000..75d1c9a Binary files /dev/null and b/collaborative-editor-governance/docs/demo.mp4 differ diff --git a/collaborative-editor-governance/docs/demo.svg b/collaborative-editor-governance/docs/demo.svg new file mode 100644 index 0000000..9e517af --- /dev/null +++ b/collaborative-editor-governance/docs/demo.svg @@ -0,0 +1,34 @@ + + Collaborative Editor Governance Demo + Visual demo of collaborative scientific editor operations, locks, snapshots, and review dashboard. + + + Collaborative Editor Governance + Operations · section locks · review dashboard · outline export + + Operations + 3 / 1 + accepted / rejected + + Review state + open + comment + suggestion + + Snapshot + v1 + content hash saved + + Publication outline + abstract · methods · results + Locked methods edit from non-owner is rejected deterministically. + diff --git a/collaborative-editor-governance/docs/issue-12-requirement-map.md b/collaborative-editor-governance/docs/issue-12-requirement-map.md new file mode 100644 index 0000000..0615a6b --- /dev/null +++ b/collaborative-editor-governance/docs/issue-12-requirement-map.md @@ -0,0 +1,30 @@ +# Issue #12 Requirement Map + +This module is a deterministic milestone for SCIBASE issue #12, Real-time collaborative research editor & interface. It focuses on review governance, operation replay, and offline conflict handling for scientific documents. + +| Issue requirement | Implementation | +| --- | --- | +| Scientific document blocks | Blocks include `markdown`, `latex`, `code`, section IDs, headings, language metadata, and publication outline export. | +| Markdown and LaTeX formatting | `buildScientificFormattingSummary()` reports Markdown blocks and LaTeX/equation support from typed blocks and inline equation syntax. | +| Reference manager integration | Document references preserve provider metadata such as Zotero/BibTeX source, cited keys, and unresolved citations. | +| Publication templates | Publication template metadata captures style names and required sections for common journal formats. | +| Code snippet highlighting | Code blocks carry language metadata and are reported in the scientific formatting summary. | +| Real-time operation application | `applyOperation()` and `applyOperationBatch()` deterministically apply insert, update, delete, comment, suggestion, and task operations. | +| Comments and suggestions | Comment and suggestion operations are stored with block links, actor IDs, and open/pending status. | +| Section locks | `isSectionLocked()` rejects edits from non-owners while allowing the lock owner to edit. | +| Offline/local caching conflict recovery | `rebaseOfflineQueue()` and `buildOfflineConflictReport()` process queued offline edits, detect stale block versions, preserve safe edits as suggestions, flag missing review targets, and create restore-ready snapshots. | +| Version history and autosave | `createVersionSnapshot()` records block count, open review items, content hash, and timestamp. | +| Task workflow | Task operations and dashboard open-task reporting support editorial handoff. | +| Collaborator presence | `buildPresenceSummary()` turns presence data into reviewer-ready cursor and staleness state. | +| Publication outline export | `exportPublicationOutline()` summarizes sections, block types, word counts, and export hash. | +| Reviewer demo | `npm run demo` prints accepted/rejected operation counts, snapshot, offline conflict codes, dashboard sections, and outline hash. | + +## Verification + +```bash +npm run check +npm test +npm run demo +``` + +The module is dependency-free and isolated under `collaborative-editor-governance/`. diff --git a/collaborative-editor-governance/package.json b/collaborative-editor-governance/package.json new file mode 100644 index 0000000..4e718be --- /dev/null +++ b/collaborative-editor-governance/package.json @@ -0,0 +1,12 @@ +{ + "name": "scibase-collaborative-editor-governance", + "version": "0.1.0", + "private": true, + "description": "Deterministic collaborative research editor governance milestone for SCIBASE issue #12.", + "type": "commonjs", + "scripts": { + "check": "node --check src/editor-governance.js && node --check scripts/demo.js && node --check test/editor-governance.test.js", + "demo": "node scripts/demo.js", + "test": "node test/editor-governance.test.js" + } +} diff --git a/collaborative-editor-governance/scripts/demo.js b/collaborative-editor-governance/scripts/demo.js new file mode 100644 index 0000000..889973a --- /dev/null +++ b/collaborative-editor-governance/scripts/demo.js @@ -0,0 +1,29 @@ +"use strict"; + +const sample = require("../data/sample-document.json"); +const { buildCollaborativeEditorPacket } = require("../src/editor-governance"); + +const packet = buildCollaborativeEditorPacket(sample.document, sample.operations); + +console.log( + JSON.stringify( + { + title: packet.document.title, + acceptedOperations: packet.operationResults.filter((result) => result.accepted).length, + rejectedOperations: packet.operationResults.filter((result) => !result.accepted).length, + snapshot: packet.snapshot, + readyForSubmission: packet.dashboard.readyForSubmission, + formatting: packet.formatting, + offlineConflicts: { + queueCount: packet.offlineConflicts.queueCount, + appliedCount: packet.offlineConflicts.appliedCount, + conflictCount: packet.offlineConflicts.conflictCount, + conflictCodes: packet.offlineConflicts.conflictCodes, + }, + sections: packet.dashboard.sections, + outlineHash: packet.outline.exportHash, + }, + null, + 2, + ), +); diff --git a/collaborative-editor-governance/src/editor-governance.js b/collaborative-editor-governance/src/editor-governance.js new file mode 100644 index 0000000..31e2a24 --- /dev/null +++ b/collaborative-editor-governance/src/editor-governance.js @@ -0,0 +1,454 @@ +"use strict"; + +const crypto = require("crypto"); + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function hashRecord(value) { + return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 18); +} + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function normalizeDocument(documentInput) { + if (!documentInput || typeof documentInput !== "object") { + throw new TypeError("document must be an object"); + } + + return { + id: documentInput.id || "document-unknown", + title: documentInput.title || "Untitled research document", + collaborators: asArray(documentInput.collaborators), + blocks: asArray(documentInput.blocks), + comments: asArray(documentInput.comments), + suggestions: asArray(documentInput.suggestions), + locks: asArray(documentInput.locks), + tasks: asArray(documentInput.tasks), + presence: asArray(documentInput.presence), + versions: asArray(documentInput.versions), + references: asArray(documentInput.references), + publicationTemplates: asArray(documentInput.publicationTemplates), + offlineQueues: asArray(documentInput.offlineQueues), + }; +} + +function findBlockIndex(document, blockId) { + return document.blocks.findIndex((block) => block.id === blockId); +} + +function isSectionLocked(document, sectionId, actorId) { + return document.locks.some( + (lock) => + lock.sectionId === sectionId && + lock.status === "active" && + lock.ownerId !== actorId, + ); +} + +function applyOperation(documentInput, operation) { + const document = normalizeDocument(clone(documentInput)); + const op = operation || {}; + const actorId = op.actorId || "unknown"; + const blockIndex = op.blockId ? findBlockIndex(document, op.blockId) : -1; + const currentBlock = blockIndex >= 0 ? document.blocks[blockIndex] : null; + const sectionId = op.sectionId || (currentBlock && currentBlock.sectionId); + + if (sectionId && isSectionLocked(document, sectionId, actorId)) { + return { + document, + accepted: false, + reason: "section-locked", + operationHash: hashRecord(op), + }; + } + + if (op.type === "insert-block") { + const block = { + id: op.block.id, + sectionId: op.block.sectionId || sectionId || "general", + type: op.block.type || "markdown", + content: op.block.content || "", + metadata: op.block.metadata || {}, + }; + document.blocks.splice(Number.isInteger(op.index) ? op.index : document.blocks.length, 0, block); + } else if (op.type === "update-block" && currentBlock) { + document.blocks[blockIndex] = { + ...currentBlock, + content: op.content === undefined ? currentBlock.content : op.content, + metadata: { ...currentBlock.metadata, ...(op.metadata || {}) }, + }; + } else if (op.type === "delete-block" && currentBlock) { + document.blocks.splice(blockIndex, 1); + } else if (op.type === "comment") { + document.comments.push({ + id: op.id || `comment-${document.comments.length + 1}`, + blockId: op.blockId, + actorId, + body: op.body || "", + status: "open", + }); + } else if (op.type === "suggestion") { + document.suggestions.push({ + id: op.id || `suggestion-${document.suggestions.length + 1}`, + blockId: op.blockId, + actorId, + proposedContent: op.proposedContent || "", + status: "pending", + }); + } else if (op.type === "task") { + document.tasks.push({ + id: op.id || `task-${document.tasks.length + 1}`, + title: op.title || "Untitled task", + assigneeId: op.assigneeId || actorId, + status: op.status || "open", + linkedBlockId: op.blockId || null, + }); + } else { + return { + document, + accepted: false, + reason: "unsupported-operation", + operationHash: hashRecord(op), + }; + } + + return { + document, + accepted: true, + operationHash: hashRecord(op), + }; +} + +function applyOperationBatch(documentInput, operations) { + let document = normalizeDocument(documentInput); + const results = asArray(operations).map((operation) => { + const result = applyOperation(document, operation); + if (result.accepted) document = result.document; + return { + accepted: result.accepted, + reason: result.reason || null, + operationHash: result.operationHash, + }; + }); + + return { + document, + results, + acceptedCount: results.filter((result) => result.accepted).length, + rejectedCount: results.filter((result) => !result.accepted).length, + }; +} + +function createVersionSnapshot(documentInput, label) { + const document = normalizeDocument(documentInput); + return { + id: `snapshot-${document.versions.length + 1}`, + label: label || "autosave", + documentId: document.id, + blockCount: document.blocks.length, + openComments: document.comments.filter((comment) => comment.status === "open").length, + pendingSuggestions: document.suggestions.filter((suggestion) => suggestion.status === "pending").length, + openTasks: document.tasks.filter((task) => task.status !== "done").length, + contentHash: hashRecord(document.blocks), + createdAt: new Date().toISOString(), + }; +} + +function buildPresenceSummary(documentInput) { + const document = normalizeDocument(documentInput); + return document.presence.map((presence) => ({ + userId: presence.userId, + name: presence.name || presence.userId, + sectionId: presence.sectionId || null, + cursorBlockId: presence.cursorBlockId || null, + stale: presence.lastSeenAt + ? Date.now() - new Date(presence.lastSeenAt).getTime() > 1000 * 60 * 5 + : true, + })); +} + +function buildScientificFormattingSummary(documentInput) { + const document = normalizeDocument(documentInput); + const blocks = document.blocks; + const citationKeys = new Set(document.references.map((reference) => reference.key).filter(Boolean)); + const citedKeys = new Set(); + const unresolvedCitations = []; + + for (const block of blocks) { + const content = String(block.content || ""); + for (const match of content.matchAll(/@([A-Za-z0-9:_-]+)/g)) { + citedKeys.add(match[1]); + if (!citationKeys.has(match[1])) unresolvedCitations.push(match[1]); + } + } + + const blockTypes = new Set(blocks.map((block) => block.type)); + const hasLatex = blocks.some((block) => block.type === "latex" || /\$[^$]+\$/.test(String(block.content || ""))); + const hasCodeHighlighting = blocks.some((block) => block.type === "code" && block.metadata.language); + const hasNotebook = blocks.some((block) => block.type === "notebook-cell"); + const templates = document.publicationTemplates.map((template) => ({ + id: template.id, + name: template.name || template.id, + style: template.style || "generic", + requiredSections: asArray(template.requiredSections), + })); + + return { + markdownBlocks: blocks.filter((block) => block.type === "markdown").length, + supportsLatex: hasLatex, + supportsCodeHighlighting: hasCodeHighlighting, + supportsNotebookCells: hasNotebook, + blockTypes: [...blockTypes].sort(), + referenceManager: { + totalReferences: document.references.length, + providers: [...new Set(document.references.map((reference) => reference.provider || "manual"))].sort(), + citedKeys: [...citedKeys].sort(), + unresolvedCitations: [...new Set(unresolvedCitations)].sort(), + }, + publicationTemplates: templates, + }; +} + +function buildReviewDashboard(documentInput) { + const document = normalizeDocument(documentInput); + const sectionMap = new Map(); + + for (const block of document.blocks) { + const section = sectionMap.get(block.sectionId) || { + sectionId: block.sectionId, + blocks: 0, + comments: 0, + suggestions: 0, + locked: document.locks.some((lock) => lock.sectionId === block.sectionId && lock.status === "active"), + }; + section.blocks += 1; + sectionMap.set(block.sectionId, section); + } + + for (const comment of document.comments.filter((comment) => comment.status === "open")) { + const block = document.blocks.find((candidate) => candidate.id === comment.blockId); + if (block && sectionMap.has(block.sectionId)) sectionMap.get(block.sectionId).comments += 1; + } + + for (const suggestion of document.suggestions.filter((suggestion) => suggestion.status === "pending")) { + const block = document.blocks.find((candidate) => candidate.id === suggestion.blockId); + if (block && sectionMap.has(block.sectionId)) sectionMap.get(block.sectionId).suggestions += 1; + } + + return { + documentId: document.id, + title: document.title, + sections: [...sectionMap.values()], + presence: buildPresenceSummary(document), + formatting: buildScientificFormattingSummary(document), + openTasks: document.tasks.filter((task) => task.status !== "done"), + readyForSubmission: + document.comments.every((comment) => comment.status !== "open") && + document.suggestions.every((suggestion) => suggestion.status !== "pending") && + document.tasks.every((task) => task.status === "done"), + }; +} + +function exportPublicationOutline(documentInput) { + const document = normalizeDocument(documentInput); + const sections = []; + for (const block of document.blocks) { + let section = sections.find((candidate) => candidate.sectionId === block.sectionId); + if (!section) { + section = { + sectionId: block.sectionId, + heading: block.metadata.heading || block.sectionId, + blockTypes: [], + wordCount: 0, + }; + sections.push(section); + } + section.blockTypes.push(block.type); + section.wordCount += String(block.content || "").split(/\s+/).filter(Boolean).length; + } + + return { + documentId: document.id, + title: document.title, + sections, + exportHash: hashRecord({ title: document.title, sections }), + }; +} + +function blockContentHash(block) { + return block ? hashRecord({ content: block.content || "", metadata: block.metadata || {} }) : null; +} + +function offlineConflictForOperation(document, operation) { + const op = operation || {}; + const actorId = op.actorId || "unknown"; + const blockIndex = op.blockId ? findBlockIndex(document, op.blockId) : -1; + const currentBlock = blockIndex >= 0 ? document.blocks[blockIndex] : null; + const sectionId = op.sectionId || (currentBlock && currentBlock.sectionId) || (op.block && op.block.sectionId); + const conflicts = []; + + if (op.blockId && !currentBlock && op.type !== "insert-block") { + conflicts.push({ + code: "REVIEW_TARGET_MISSING", + severity: "high", + message: "Queued operation targets a block that no longer exists.", + blockId: op.blockId, + resolution: "manual-review", + }); + } + + if (sectionId && isSectionLocked(document, sectionId, actorId)) { + conflicts.push({ + code: "SECTION_LOCK_CONFLICT", + severity: "high", + message: "Queued operation conflicts with an active section lock.", + sectionId, + actorId, + resolution: "defer-until-unlocked", + }); + } + + if (currentBlock && op.baseBlockHash && op.baseBlockHash !== blockContentHash(currentBlock)) { + conflicts.push({ + code: op.type === "update-block" ? "STALE_BLOCK_VERSION" : "REVIEW_CONTEXT_STALE", + severity: "medium", + message: "Queued operation was based on an older block version.", + blockId: op.blockId, + expectedHash: op.baseBlockHash, + actualHash: blockContentHash(currentBlock), + resolution: op.type === "update-block" ? "converted-to-suggestion" : "preserve-with-context-warning", + }); + } + + return conflicts; +} + +function rebaseOfflineQueue(documentInput, queueInput) { + let document = normalizeDocument(documentInput); + const queue = queueInput || {}; + const conflicts = []; + const applied = []; + const baseSnapshot = createVersionSnapshot(document, `${queue.clientId || "offline"} rebase base`); + + for (const operation of asArray(queue.operations)) { + const operationConflicts = offlineConflictForOperation(document, operation); + conflicts.push(...operationConflicts.map((conflict) => ({ + ...conflict, + operationHash: hashRecord(operation), + }))); + + const hasBlockingConflict = operationConflicts.some( + (conflict) => conflict.code === "SECTION_LOCK_CONFLICT" || conflict.code === "REVIEW_TARGET_MISSING", + ); + if (hasBlockingConflict) continue; + + const staleUpdate = operationConflicts.some((conflict) => conflict.code === "STALE_BLOCK_VERSION"); + const operationToApply = staleUpdate + ? { + type: "suggestion", + id: operation.rebasedSuggestionId || `offline-suggestion-${applied.length + 1}`, + actorId: operation.actorId, + blockId: operation.blockId, + proposedContent: operation.content || operation.proposedContent || "", + } + : operation; + + const result = applyOperation(document, operationToApply); + if (result.accepted) { + document = result.document; + applied.push({ + originalOperationHash: hashRecord(operation), + appliedOperationHash: result.operationHash, + rebasedAs: staleUpdate ? "suggestion" : operation.type, + }); + } else { + conflicts.push({ + code: "REBASE_APPLICATION_REJECTED", + severity: "medium", + message: "Queued operation could not be applied after conflict checks.", + reason: result.reason, + operationHash: result.operationHash, + resolution: "manual-review", + }); + } + } + + const restoreSnapshot = createVersionSnapshot(document, `${queue.clientId || "offline"} restore point`); + + return { + clientId: queue.clientId || "offline-client", + baseVersionId: queue.baseVersionId || null, + baseContentHash: queue.baseContentHash || baseSnapshot.contentHash, + appliedCount: applied.length, + conflictCount: conflicts.length, + applied, + conflicts, + restoreSnapshot, + auditHash: hashRecord({ + clientId: queue.clientId, + applied, + conflicts, + restoreSnapshot: restoreSnapshot.contentHash, + }), + }; +} + +function buildOfflineConflictReport(documentInput) { + const document = normalizeDocument(documentInput); + const queueReports = document.offlineQueues.map((queue) => rebaseOfflineQueue(document, queue)); + const conflicts = queueReports.flatMap((report) => report.conflicts); + + return { + documentId: document.id, + queueCount: queueReports.length, + appliedCount: queueReports.reduce((sum, report) => sum + report.appliedCount, 0), + conflictCount: conflicts.length, + conflictCodes: [...new Set(conflicts.map((conflict) => conflict.code))].sort(), + queues: queueReports, + auditHash: hashRecord(queueReports.map((report) => ({ + clientId: report.clientId, + appliedCount: report.appliedCount, + conflictCount: report.conflictCount, + auditHash: report.auditHash, + }))), + }; +} + +function buildCollaborativeEditorPacket(documentInput, operations) { + const batch = applyOperationBatch(documentInput, operations); + const snapshot = createVersionSnapshot(batch.document, "post-operation autosave"); + const documentWithSnapshot = { + ...batch.document, + versions: [...batch.document.versions, snapshot], + }; + + return { + operationResults: batch.results, + document: documentWithSnapshot, + snapshot, + dashboard: buildReviewDashboard(documentWithSnapshot), + formatting: buildScientificFormattingSummary(documentWithSnapshot), + offlineConflicts: buildOfflineConflictReport(documentInput), + outline: exportPublicationOutline(documentWithSnapshot), + }; +} + +module.exports = { + applyOperation, + applyOperationBatch, + buildCollaborativeEditorPacket, + buildOfflineConflictReport, + buildPresenceSummary, + buildReviewDashboard, + buildScientificFormattingSummary, + createVersionSnapshot, + exportPublicationOutline, + hashRecord, + isSectionLocked, + normalizeDocument, + rebaseOfflineQueue, +}; diff --git a/collaborative-editor-governance/test/editor-governance.test.js b/collaborative-editor-governance/test/editor-governance.test.js new file mode 100644 index 0000000..6942b69 --- /dev/null +++ b/collaborative-editor-governance/test/editor-governance.test.js @@ -0,0 +1,120 @@ +"use strict"; + +const assert = require("assert"); +const sample = require("../data/sample-document.json"); +const { + applyOperation, + applyOperationBatch, + buildCollaborativeEditorPacket, + buildOfflineConflictReport, + buildReviewDashboard, + buildScientificFormattingSummary, + createVersionSnapshot, + exportPublicationOutline, + isSectionLocked, + rebaseOfflineQueue, +} = require("../src/editor-governance"); + +function testSectionLocks() { + assert.strictEqual(isSectionLocked(sample.document, "methods", "u-3"), true); + assert.strictEqual(isSectionLocked(sample.document, "methods", "u-2"), false); +} + +function testApplyOperationRejectsLockedEdit() { + const result = applyOperation(sample.document, sample.operations[2]); + + assert.strictEqual(result.accepted, false); + assert.strictEqual(result.reason, "section-locked"); +} + +function testOperationBatch() { + const batch = applyOperationBatch(sample.document, sample.operations); + + assert.strictEqual(batch.acceptedCount, 3); + assert.strictEqual(batch.rejectedCount, 1); + assert.strictEqual(batch.document.comments.length, 1); + assert.strictEqual(batch.document.suggestions.length, 1); + assert.ok(batch.document.blocks.find((block) => block.id === "block-results")); +} + +function testSnapshotAndDashboard() { + const batch = applyOperationBatch(sample.document, sample.operations); + const snapshot = createVersionSnapshot(batch.document, "test snapshot"); + const dashboard = buildReviewDashboard({ ...batch.document, versions: [snapshot] }); + + assert.strictEqual(snapshot.openComments, 1); + assert.strictEqual(snapshot.pendingSuggestions, 1); + assert.strictEqual(dashboard.readyForSubmission, false); + assert.ok(dashboard.sections.find((section) => section.sectionId === "methods").locked); +} + +function testPublicationOutline() { + const batch = applyOperationBatch(sample.document, sample.operations); + const outline = exportPublicationOutline(batch.document); + + assert.strictEqual(outline.sections.length, 3); + assert.ok(outline.sections.find((section) => section.sectionId === "results")); + assert.ok(outline.exportHash.length >= 12); +} + +function testScientificFormattingSummary() { + const summary = buildScientificFormattingSummary(sample.document); + + assert.strictEqual(summary.supportsLatex, true); + assert.strictEqual(summary.supportsCodeHighlighting, true); + assert.ok(summary.blockTypes.includes("latex")); + assert.strictEqual(summary.referenceManager.totalReferences, 1); + assert.deepStrictEqual(summary.referenceManager.citedKeys, ["smith2026"]); + assert.deepStrictEqual(summary.referenceManager.unresolvedCitations, []); + assert.strictEqual(summary.publicationTemplates[0].style, "nature"); +} + +function testOfflineQueueRebase() { + const report = rebaseOfflineQueue(sample.document, sample.document.offlineQueues[0]); + + assert.strictEqual(report.clientId, "offline-u-3"); + assert.strictEqual(report.appliedCount, 2); + assert.strictEqual(report.conflictCount, 4); + assert.ok(report.applied.find((item) => item.rebasedAs === "suggestion")); + assert.ok(report.conflicts.find((conflict) => conflict.code === "STALE_BLOCK_VERSION")); + assert.ok(report.conflicts.find((conflict) => conflict.code === "SECTION_LOCK_CONFLICT")); + assert.ok(report.conflicts.find((conflict) => conflict.code === "REVIEW_TARGET_MISSING")); + assert.ok(report.restoreSnapshot.contentHash); + assert.ok(report.auditHash.length >= 12); +} + +function testOfflineConflictReport() { + const report = buildOfflineConflictReport(sample.document); + + assert.strictEqual(report.queueCount, 1); + assert.strictEqual(report.appliedCount, 2); + assert.strictEqual(report.conflictCount, 4); + assert.deepStrictEqual(report.conflictCodes, [ + "REVIEW_TARGET_MISSING", + "SECTION_LOCK_CONFLICT", + "STALE_BLOCK_VERSION", + ]); +} + +function testFullPacket() { + const packet = buildCollaborativeEditorPacket(sample.document, sample.operations); + + assert.strictEqual(packet.operationResults.length, 4); + assert.strictEqual(packet.document.versions.length, 1); + assert.strictEqual(packet.dashboard.openTasks.length, 1); + assert.strictEqual(packet.dashboard.formatting.supportsLatex, true); + assert.strictEqual(packet.offlineConflicts.conflictCount, 4); + assert.ok(packet.outline.exportHash); +} + +testSectionLocks(); +testApplyOperationRejectsLockedEdit(); +testOperationBatch(); +testSnapshotAndDashboard(); +testPublicationOutline(); +testScientificFormattingSummary(); +testOfflineQueueRebase(); +testOfflineConflictReport(); +testFullPacket(); + +console.log("collaborative-editor-governance tests passed");