The Atlas doc.haus documentation, bound to its code
108 documents

The redline pipeline

From "change clause 9" to native Word tracked changes — proposal, review, and the accept that bakes the edit into the canonical .docx.

services/ingest/src/redline.ts77 lines · buildRedlined L44–58
Outline 4 symbols
1import { docxodus } from "./docxodus"
2import type { RedlineRow } from "./db"
3
4// Replay and bake pending redline proposals against a clean .docx.
5//
6// The canonical document on disk is always the accepted ("clean") state. A
7// proposal records how to reproduce one edit — a surgical find/replace ('phrase')
8// or a whole-paragraph rewrite ('clause') — located the same way the dochaus tools
9// located it originally. We never store offsets: text is re-resolved against the
10// live document every time, so edits stay valid as the document changes underneath.
11
12type Session = ReturnType<Awaited<ReturnType<typeof docxodus>>["openDocxSession"]>
13
14// Reproduce one proposal's edit on an open session. Returns an error message
15// (rather than throwing) when the anchor text can no longer be found — e.g. an
16// earlier proposal in the same replay already rewrote the passage this one
17// anchors to. Callers decide whether that is fatal: the redlined view skips the
18// row and renders the rest; accept must fail loudly rather than bake a partial set.
19function applyProposal(session: Session, row: RedlineRow): { ok: true } | { ok: false; error: string } {
20 if (row.scope === "clause") {
21 const target = session.findByText(row.find_text, { ignoreWhitespace: true })
22 if (!target) return { ok: false, error: `Clause no longer found for redline #${row.id}: ${JSON.stringify(row.find_text)}` }
23 const result = session.replaceText(target.id, row.new_text)
24 if (!result.success) return { ok: false, error: `Redline #${row.id} failed: ${result.error?.message ?? "unknown error"}` }
25 return { ok: true }
26 }
27
28 const targets = session.findAllByText(row.find_text)
29 if (!targets.length) return { ok: false, error: `Text no longer found for redline #${row.id}: ${JSON.stringify(row.find_text)}` }
30 const results = targets.flatMap((t) => session.replaceTextRange(t.id, row.find_text, row.new_text))
31 const failed = results.find((r) => !r.success)
32 if (failed) return { ok: false, error: `Redline #${row.id} failed: ${failed.error?.message ?? "unknown error"}` }
33 return { ok: true }
34}
35
36// The redlined view the viewer renders: clean document compared against the same
37// document with every pending proposal applied, so Docxodus emits native w:ins/w:del
38// the browser paints green/red. With no pending rows the compare is a no-op and the
39// clean document round-trips unchanged.
40//
41// A proposal that no longer resolves (a leftover collision the propose-time check
42// did not retire) is skipped, not fatal: the view must render the proposals that
43// do apply rather than 500 the whole document. Skips are logged for the operator.
44export async function buildRedlined(original: Uint8Array, rows: RedlineRow[]): Promise<Uint8Array> {
45 const dx = await docxodus()
46 if (!rows.length) return original
47 const session = dx.openDocxSession(original, {})
48 const applied = rows.filter((row) => {
49 const result = applyProposal(session, row)
50 if (!result.ok) console.warn(`[redline] skipping in redlined view — ${result.error}`)
51 return result.ok
52 })
53 const modified = session.save()
54 session.close()
55 if (!applied.length) return original
56 const authors = [...new Set(applied.map((r) => r.author))]
57 return dx.compareDocuments(original, modified, { authorName: authors.length === 1 ? authors[0] : "doc.haus" })
58}
59
60// Bake the given proposals into the clean document, returning new canonical bytes
61// with the edits applied and no tracked changes — the new accepted state. Accept
62// fails loudly: a proposal that no longer resolves throws rather than silently
63// baking a partial set into the canonical document.
64export async function bake(original: Uint8Array, rows: RedlineRow[]): Promise<Uint8Array> {
65 const dx = await docxodus()
66 const session = dx.openDocxSession(original, {})
67 try {
68 for (const row of rows) {
69 const result = applyProposal(session, row)
70 if (!result.ok) throw new Error(result.error)
71 }
72 return session.save()
73 } finally {
74 session.close()
75 }
76}
77