diff --git a/collaborative-footnote-export-guard/README.md b/collaborative-footnote-export-guard/README.md new file mode 100644 index 00000000..d2df1991 --- /dev/null +++ b/collaborative-footnote-export-guard/README.md @@ -0,0 +1,36 @@ +# Collaborative Footnote Export Guard + +This module is a focused slice for the real-time collaborative research editor +described in issue #12. It checks whether footnotes and endnotes are safe to +include in a manuscript export after collaborative editing. + +The guard is intentionally dependency-free and uses synthetic data only. It +does not call journal, payment, identity, storage, or external citation systems. + +## What It Checks + +- Orphaned manuscript footnote markers with no matching note text. +- Duplicate note markers that would collapse during Markdown, LaTeX, or EndNote + export. +- Private reviewer or collaborator notes that should not leave the editor. +- Notes changed inside locked or final-review sections. +- Missing citation bindings for source-backed notes. +- Journal export order mismatches where endnotes must follow first appearance. + +## Local Verification + +```bash +node collaborative-footnote-export-guard/test.js +node collaborative-footnote-export-guard/demo.js +``` + +The demo writes reviewer-ready artifacts to +`collaborative-footnote-export-guard/reports/`. + +## Issue #12 Mapping + +This complements the collaborative editor requirements for scientific formatting, +cross-referencing, version history, section locks, comments/suggestions, and +publication export readiness without overlapping broader editor, reference merge, +figure/table, equation anchor, clipboard import, journal-style, or private +comment export slices. diff --git a/collaborative-footnote-export-guard/demo.js b/collaborative-footnote-export-guard/demo.js new file mode 100644 index 00000000..aa6d3a97 --- /dev/null +++ b/collaborative-footnote-export-guard/demo.js @@ -0,0 +1,16 @@ +const fs = require("fs"); +const path = require("path"); +const { evaluateFootnoteExport, toMarkdownReport, toSvgBadge } = require("./footnoteExportGuard"); +const sampleWorkspace = require("./sampleWorkspace"); + +const result = evaluateFootnoteExport(sampleWorkspace); +const reportDir = path.join(__dirname, "reports"); + +fs.mkdirSync(reportDir, { recursive: true }); +fs.writeFileSync(path.join(reportDir, "footnote-export-packet.json"), `${JSON.stringify(result, null, 2)}\n`); +fs.writeFileSync(path.join(reportDir, "footnote-export-report.md"), toMarkdownReport(result)); +fs.writeFileSync(path.join(reportDir, "summary.svg"), toSvgBadge(result)); + +console.log(`decision=${result.decision}`); +console.log(`findings=${result.findings.length}`); +console.log(`reports=${reportDir}`); diff --git a/collaborative-footnote-export-guard/footnoteExportGuard.js b/collaborative-footnote-export-guard/footnoteExportGuard.js new file mode 100644 index 00000000..92c9f7db --- /dev/null +++ b/collaborative-footnote-export-guard/footnoteExportGuard.js @@ -0,0 +1,210 @@ +const DEFAULT_JOURNAL_POLICY = Object.freeze({ + requireCitationBindings: true, + requireEndnoteOrder: true, + allowPrivateNotesInExport: false, + lockedSectionsBlockNoteEdits: true +}); + +function normalizePolicy(policy = {}) { + return { ...DEFAULT_JOURNAL_POLICY, ...policy }; +} + +function markerId(marker) { + if (typeof marker === "string") { + return marker.trim(); + } + return marker && typeof marker.id === "string" ? marker.id.trim() : ""; +} + +function sectionById(sections) { + return new Map((sections || []).map((section) => [section.id, section])); +} + +function firstAppearanceOrder(sections) { + const seen = new Map(); + + for (const section of sections || []) { + for (const marker of section.markers || []) { + const id = markerId(marker); + if (id && !seen.has(id)) { + seen.set(id, seen.size + 1); + } + } + } + + return seen; +} + +function buildDuplicateMarkerFindings(sections) { + const locations = new Map(); + + for (const section of sections || []) { + for (const marker of section.markers || []) { + const id = markerId(marker); + if (!id) { + continue; + } + const sectionLocations = locations.get(id) || []; + sectionLocations.push(section.id); + locations.set(id, sectionLocations); + } + } + + return [...locations.entries()] + .filter(([, sectionIds]) => sectionIds.length > 1) + .map(([id, sectionIds]) => ({ + id: `duplicate-marker:${id}`, + severity: "hold", + message: `Footnote marker ${id} appears in multiple manuscript sections.`, + evidence: { marker: id, sectionIds }, + action: "Assign unique note markers or confirm that repeated references use an explicit shared-note alias." + })); +} + +function evaluateFootnoteExport(workspace, policyInput = {}) { + const policy = normalizePolicy(policyInput); + const sections = workspace.sections || []; + const notes = workspace.notes || []; + const sectionsById = sectionById(sections); + const markerOrder = firstAppearanceOrder(sections); + const noteById = new Map(notes.map((note) => [note.id, note])); + const findings = []; + + for (const marker of markerOrder.keys()) { + if (!noteById.has(marker)) { + findings.push({ + id: `orphan-marker:${marker}`, + severity: "block", + message: `Marker ${marker} is present in the manuscript without matching note text.`, + evidence: { marker }, + action: "Add the missing note text or remove the marker before export." + }); + } + } + + for (const note of notes) { + if (!markerOrder.has(note.id)) { + findings.push({ + id: `unused-note:${note.id}`, + severity: "warn", + message: `Note ${note.id} has text but no visible manuscript marker.`, + evidence: { noteId: note.id }, + action: "Either insert the marker in the manuscript or exclude the unused note from export." + }); + } + + if (!policy.allowPrivateNotesInExport && note.visibility === "private") { + findings.push({ + id: `private-note:${note.id}`, + severity: "block", + message: `Private note ${note.id} would be included in the export packet.`, + evidence: { noteId: note.id, owner: note.owner, visibility: note.visibility }, + action: "Redact the note or convert it to an export-safe public note after review." + }); + } + + if (policy.requireCitationBindings && note.requiresCitation && !note.citationKey) { + findings.push({ + id: `missing-citation:${note.id}`, + severity: "hold", + message: `Source-backed note ${note.id} is missing a citation binding.`, + evidence: { noteId: note.id }, + action: "Bind the note to an approved bibliography entry before export." + }); + } + + const section = sectionsById.get(note.sectionId); + if (policy.lockedSectionsBlockNoteEdits && section && section.locked && note.modifiedAfterLock) { + findings.push({ + id: `locked-section-note-edit:${note.id}`, + severity: "block", + message: `Note ${note.id} changed after section ${section.id} entered final-review lock.`, + evidence: { noteId: note.id, sectionId: section.id, lockReason: section.lockReason }, + action: "Ask the section owner to unlock, approve, or roll back the note edit." + }); + } + } + + findings.push(...buildDuplicateMarkerFindings(sections)); + + if (policy.requireEndnoteOrder) { + const exportableNotes = notes + .filter((note) => markerOrder.has(note.id)) + .map((note) => ({ id: note.id, expectedOrder: markerOrder.get(note.id), exportedOrder: note.exportedOrder })); + + for (const note of exportableNotes) { + if (note.exportedOrder !== note.expectedOrder) { + findings.push({ + id: `endnote-order:${note.id}`, + severity: "hold", + message: `Endnote ${note.id} is exported as ${note.exportedOrder}, but first appears at position ${note.expectedOrder}.`, + evidence: note, + action: "Rebuild the endnote list from first marker appearance before journal export." + }); + } + } + } + + const blockers = findings.filter((finding) => finding.severity === "block"); + const holds = findings.filter((finding) => finding.severity === "hold"); + const warnings = findings.filter((finding) => finding.severity === "warn"); + const decision = blockers.length > 0 ? "block-export" : holds.length > 0 ? "hold-for-editor-review" : "ready-for-export"; + + return { + workspaceId: workspace.id, + checkedAt: workspace.checkedAt || new Date().toISOString(), + decision, + summary: { + sections: sections.length, + notes: notes.length, + markers: markerOrder.size, + blockers: blockers.length, + holds: holds.length, + warnings: warnings.length + }, + findings + }; +} + +function toMarkdownReport(result) { + const lines = [ + `# Footnote Export Guard Report`, + ``, + `Workspace: ${result.workspaceId}`, + `Decision: ${result.decision}`, + ``, + `| Severity | Finding | Action |`, + `| --- | --- | --- |` + ]; + + for (const finding of result.findings) { + lines.push(`| ${finding.severity} | ${finding.message} | ${finding.action} |`); + } + + if (result.findings.length === 0) { + lines.push(`| ok | No export-blocking footnote issues found. | Export may continue. |`); + } + + return `${lines.join("\n")}\n`; +} + +function toSvgBadge(result) { + const color = result.decision === "ready-for-export" ? "#0f7b45" : result.decision === "hold-for-editor-review" ? "#9a6700" : "#b42318"; + const label = result.decision.replaceAll("-", " "); + return [ + ``, + ``, + ``, + `Footnote Export Guard`, + `Decision: ${label}`, + `Blockers: ${result.summary.blockers} | Holds: ${result.summary.holds} | Warnings: ${result.summary.warnings}`, + `Workspace ${result.workspaceId} checked ${result.checkedAt}`, + `` + ].join("\n"); +} + +module.exports = { + evaluateFootnoteExport, + toMarkdownReport, + toSvgBadge +}; diff --git a/collaborative-footnote-export-guard/reports/demo.webm b/collaborative-footnote-export-guard/reports/demo.webm new file mode 100644 index 00000000..df007ff3 Binary files /dev/null and b/collaborative-footnote-export-guard/reports/demo.webm differ diff --git a/collaborative-footnote-export-guard/reports/footnote-export-packet.json b/collaborative-footnote-export-guard/reports/footnote-export-packet.json new file mode 100644 index 00000000..b779bd30 --- /dev/null +++ b/collaborative-footnote-export-guard/reports/footnote-export-packet.json @@ -0,0 +1,99 @@ +{ + "workspaceId": "editor-room-ms-042", + "checkedAt": "2026-05-30T19:30:00.000Z", + "decision": "block-export", + "summary": { + "sections": 3, + "notes": 4, + "markers": 4, + "blockers": 3, + "holds": 4, + "warnings": 1 + }, + "findings": [ + { + "id": "orphan-marker:fn-5", + "severity": "block", + "message": "Marker fn-5 is present in the manuscript without matching note text.", + "evidence": { + "marker": "fn-5" + }, + "action": "Add the missing note text or remove the marker before export." + }, + { + "id": "private-note:fn-2", + "severity": "block", + "message": "Private note fn-2 would be included in the export packet.", + "evidence": { + "noteId": "fn-2", + "owner": "reviewer-7", + "visibility": "private" + }, + "action": "Redact the note or convert it to an export-safe public note after review." + }, + { + "id": "locked-section-note-edit:fn-2", + "severity": "block", + "message": "Note fn-2 changed after section methods entered final-review lock.", + "evidence": { + "noteId": "fn-2", + "sectionId": "methods", + "lockReason": "final-review" + }, + "action": "Ask the section owner to unlock, approve, or roll back the note edit." + }, + { + "id": "missing-citation:fn-3", + "severity": "hold", + "message": "Source-backed note fn-3 is missing a citation binding.", + "evidence": { + "noteId": "fn-3" + }, + "action": "Bind the note to an approved bibliography entry before export." + }, + { + "id": "unused-note:fn-4", + "severity": "warn", + "message": "Note fn-4 has text but no visible manuscript marker.", + "evidence": { + "noteId": "fn-4" + }, + "action": "Either insert the marker in the manuscript or exclude the unused note from export." + }, + { + "id": "duplicate-marker:fn-2", + "severity": "hold", + "message": "Footnote marker fn-2 appears in multiple manuscript sections.", + "evidence": { + "marker": "fn-2", + "sectionIds": [ + "methods", + "results" + ] + }, + "action": "Assign unique note markers or confirm that repeated references use an explicit shared-note alias." + }, + { + "id": "endnote-order:fn-2", + "severity": "hold", + "message": "Endnote fn-2 is exported as 3, but first appears at position 2.", + "evidence": { + "id": "fn-2", + "expectedOrder": 2, + "exportedOrder": 3 + }, + "action": "Rebuild the endnote list from first marker appearance before journal export." + }, + { + "id": "endnote-order:fn-3", + "severity": "hold", + "message": "Endnote fn-3 is exported as 2, but first appears at position 3.", + "evidence": { + "id": "fn-3", + "expectedOrder": 3, + "exportedOrder": 2 + }, + "action": "Rebuild the endnote list from first marker appearance before journal export." + } + ] +} diff --git a/collaborative-footnote-export-guard/reports/footnote-export-report.md b/collaborative-footnote-export-guard/reports/footnote-export-report.md new file mode 100644 index 00000000..653deb8f --- /dev/null +++ b/collaborative-footnote-export-guard/reports/footnote-export-report.md @@ -0,0 +1,15 @@ +# Footnote Export Guard Report + +Workspace: editor-room-ms-042 +Decision: block-export + +| Severity | Finding | Action | +| --- | --- | --- | +| block | Marker fn-5 is present in the manuscript without matching note text. | Add the missing note text or remove the marker before export. | +| block | Private note fn-2 would be included in the export packet. | Redact the note or convert it to an export-safe public note after review. | +| block | Note fn-2 changed after section methods entered final-review lock. | Ask the section owner to unlock, approve, or roll back the note edit. | +| hold | Source-backed note fn-3 is missing a citation binding. | Bind the note to an approved bibliography entry before export. | +| warn | Note fn-4 has text but no visible manuscript marker. | Either insert the marker in the manuscript or exclude the unused note from export. | +| hold | Footnote marker fn-2 appears in multiple manuscript sections. | Assign unique note markers or confirm that repeated references use an explicit shared-note alias. | +| hold | Endnote fn-2 is exported as 3, but first appears at position 2. | Rebuild the endnote list from first marker appearance before journal export. | +| hold | Endnote fn-3 is exported as 2, but first appears at position 3. | Rebuild the endnote list from first marker appearance before journal export. | diff --git a/collaborative-footnote-export-guard/reports/summary.svg b/collaborative-footnote-export-guard/reports/summary.svg new file mode 100644 index 00000000..4a2f3ba7 --- /dev/null +++ b/collaborative-footnote-export-guard/reports/summary.svg @@ -0,0 +1,8 @@ + + + +Footnote Export Guard +Decision: block export +Blockers: 3 | Holds: 4 | Warnings: 1 +Workspace editor-room-ms-042 checked 2026-05-30T19:30:00.000Z + \ No newline at end of file diff --git a/collaborative-footnote-export-guard/sampleWorkspace.js b/collaborative-footnote-export-guard/sampleWorkspace.js new file mode 100644 index 00000000..d747d781 --- /dev/null +++ b/collaborative-footnote-export-guard/sampleWorkspace.js @@ -0,0 +1,66 @@ +module.exports = { + id: "editor-room-ms-042", + checkedAt: "2026-05-30T19:30:00.000Z", + sections: [ + { + id: "methods", + locked: true, + lockReason: "final-review", + markers: ["fn-1", "fn-2"] + }, + { + id: "results", + locked: false, + markers: ["fn-2", "fn-3", "fn-5"] + }, + { + id: "discussion", + locked: false, + markers: [] + } + ], + notes: [ + { + id: "fn-1", + sectionId: "methods", + text: "Protocol preregistration details.", + visibility: "public", + owner: "author-a", + requiresCitation: true, + citationKey: "doe2026protocol", + exportedOrder: 1, + modifiedAfterLock: false + }, + { + id: "fn-2", + sectionId: "methods", + text: "Reviewer-only note about the assay batch.", + visibility: "private", + owner: "reviewer-7", + requiresCitation: false, + exportedOrder: 3, + modifiedAfterLock: true + }, + { + id: "fn-3", + sectionId: "results", + text: "Derived from the instrument calibration record.", + visibility: "public", + owner: "author-b", + requiresCitation: true, + citationKey: "", + exportedOrder: 2, + modifiedAfterLock: false + }, + { + id: "fn-4", + sectionId: "discussion", + text: "Unused historical note from an earlier draft.", + visibility: "public", + owner: "author-c", + requiresCitation: false, + exportedOrder: 4, + modifiedAfterLock: false + } + ] +}; diff --git a/collaborative-footnote-export-guard/test.js b/collaborative-footnote-export-guard/test.js new file mode 100644 index 00000000..a5117515 --- /dev/null +++ b/collaborative-footnote-export-guard/test.js @@ -0,0 +1,52 @@ +const assert = require("assert"); +const { evaluateFootnoteExport } = require("./footnoteExportGuard"); +const sampleWorkspace = require("./sampleWorkspace"); + +const result = evaluateFootnoteExport(sampleWorkspace); + +assert.equal(result.decision, "block-export"); +assert.equal(result.summary.sections, 3); +assert.equal(result.summary.notes, 4); +assert.equal(result.summary.markers, 4); +assert.ok(result.findings.some((finding) => finding.id === "orphan-marker:fn-5")); +assert.ok(result.findings.some((finding) => finding.id === "private-note:fn-2")); +assert.ok(result.findings.some((finding) => finding.id === "locked-section-note-edit:fn-2")); +assert.ok(result.findings.some((finding) => finding.id === "missing-citation:fn-3")); +assert.ok(result.findings.some((finding) => finding.id === "unused-note:fn-4")); +assert.ok(result.findings.some((finding) => finding.id === "duplicate-marker:fn-2")); +assert.ok(result.findings.some((finding) => finding.id === "endnote-order:fn-2")); + +const cleanWorkspace = { + id: "editor-room-clean", + sections: [ + { id: "intro", locked: false, markers: ["fn-1"] }, + { id: "methods", locked: true, lockReason: "final-review", markers: ["fn-2"] } + ], + notes: [ + { + id: "fn-1", + sectionId: "intro", + text: "Open science statement.", + visibility: "public", + requiresCitation: true, + citationKey: "nguyen2026open", + exportedOrder: 1, + modifiedAfterLock: false + }, + { + id: "fn-2", + sectionId: "methods", + text: "Instrument model reference.", + visibility: "public", + requiresCitation: false, + exportedOrder: 2, + modifiedAfterLock: false + } + ] +}; + +const cleanResult = evaluateFootnoteExport(cleanWorkspace); +assert.equal(cleanResult.decision, "ready-for-export"); +assert.equal(cleanResult.findings.length, 0); + +console.log("footnote export guard tests passed");