PlotWeaver #docx #publishing #sqlite

Six features, one day: PlotDevice grows a manuscript layer

PlotDevice has been good at drafting for a while: chapters, prose, AI assistance. What it hasn't been is a tool you could round-trip a real manuscript through. Today's work changed that: one commit landing DOCX export, manuscript import, revision snapshots, writing goals, and inline images (51 files, +3,929 lines), followed by two more commits that redesigned the data model for everything around the story. Twice.

DOCX, in both directions

Export leans on the docx package rather than hand-rolled XML, and most of the 455-line renderer is unit conversion and taste. Word thinks in half-points and twips, so the renderer is full of lines like:

const fontSize = settings.font_size * 2;              // docx uses half-points
const lineSpacing = Math.round(settings.line_spacing * 240);

plus an 11-entry page-size map, proper headers and footers with page numbers, a real TableOfContents field, and scene breaks in the user's chosen dialect (* * *, ———, or ~).

Import is the braver feature. It takes a .docx, runs it through mammoth to get HTML, and then has to answer the question every import tool dreads: where do the chapters start? The heuristic is two regexes: proper <h1>/<h2> headings if the document has them, otherwise paragraphs that look like Chapter 12 or CHAPTER XIV:

const headingPattern = /<h[12][^>]*>(.*?)<\/h[12]>/gi
const chapterPattern = /<p[^>]*>\s*(Chapter\s+\d+[^<]*|CHAPTER\s+[IVXLCDM\d]+[^<]*)\s*<\/p>/gi
const matches = headingMatches.length > 0 ? headingMatches : chapterPattern...

If neither matches, the whole manuscript becomes one honest "Chapter 1". Text before the first marker becomes a "Preamble" chapter, but only if it's more than ten words — a threshold that exists because title pages are not chapters, no matter how confidently they appear first. The import dialog shows the detected chapter list with word counts before committing, which is the difference between a heuristic and a trap.

Snapshots: the ten-minute rule

Every section now gets automatic revision snapshots: the full TipTap document, no deltas, written at most once per section per ten minutes:

const SNAPSHOT_INTERVAL_MS = 10 * 60 * 1000;

Full copies are unfashionable next to a proper delta chain, but they have the property that matters in a writing tool: restore can never fail, and a snapshot from three weeks ago doesn't depend on every snapshot in between being intact. A diff viewer (via diffWords) shows what changed between any snapshot and now. The known cost, recorded here so past me can be graded: the ten-minute floor means a burst of edits inside one window is unrecoverable, and nothing prunes old snapshots yet. Storage is cheap; that decision will age at a rate proportional to my users' word counts.

The matter problem, solved three times before midnight

The interesting engineering story today is not any single feature but watching a data model find its shape under pressure, in public, in the commit log.

Attempt one (morning): front and back matter (title page, copyright, dedication, epilogue, all the furniture around a story) got its own book_matter table. Clean, obvious, wrong.

Attempt two (afternoon): wrong because matter is sections. It wants ordering, editing, and inclusion in exports, exactly like everything else in the outline. So the second commit migrated book_matter into the existing sections table as front_matter/back_matter section types with a matter_item_type column, walking the old table at startup and inserting each enabled row as a real section. A table that lived for roughly one commit. I'd be embarrassed, except shipping the wrong table and migrating it four hours later is strictly cheaper than a week of whiteboard arguing about the right one.

Attempt three (evening): the migration surfaced a subtler wrongness. Prologues and epilogues aren't matter at all. They're chapters that decline to be numbered. So the third commit introduced chapter subtypes:

export type ChapterSubtype = 'prologue' | 'epilogue' | 'part' | null

and promoted prologue/epilogue out of matter into content chapters (sort_order = -1 for prologues, 999999 for epilogues, then everything renormalised), with the assembler skipping numbering for subtyped chapters. part dividers came along too, a chapter subtype that renders as a big centred page break in PDF, DOCX, EPUB, and Markdown alike.

The template library rounds it out: twenty matter types, each a TipTap document of placeholder text ('[Title] © [Year] [Author Name]. All rights reserved.', 'For [dedicatee].'), with a populate step that fills the book's actual title, author, and year. The copyright page writes itself, which feels appropriate.

Honest ledger

Where the seams still show, as of tonight:

  • Import is DOCX-only (no RTF, Markdown, or Scrivener), and both the chapter splitter and the HTML→TipTap converter are regex-based, not real parsers. Well-structured mammoth output makes this mostly fine; duplicate chapter headings can mis-slice, and imported scene breaks aren't detected at all.
  • Matter renders inconsistently across formats. EPUB is the good citizen: proper epub:type="frontmatter", unindented paragraphs, matter kept out of chapter numbering. DOCX and PDF treat matter as ordinary paragraph flow. And the table of contents currently exists three different ways: a real Word TOC field in DOCX, a generated list in Markdown, and a skipped placeholder everywhere else.
  • The dead table remains. book_matter is migrated but not dropped, and the template registry still carries orphaned prologue/epilogue keys from attempt two. Memorials, both.

The pattern I want to remember from today: for the parts of a book nobody loves writing — copyright pages, half titles, the machinery of manuscripts — the schema wants to converge on one list of sections with types, not a constellation of special tables. It took three tries in one day to get there. The number of tries matters less than the fact that all three fit in a day.