diff --git a/BUILD_PLAN.md b/BUILD_PLAN.md
index 5acbafb..c91954e 100644
--- a/BUILD_PLAN.md
+++ b/BUILD_PLAN.md
@@ -74,12 +74,24 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
- [x] `MisspellCard` popover + EditorCore wiring — soft **rose wavy underline** (`.petal-misspelling`, pastel take on the red squiggle, not classic red). Click a flagged word → `posAtCoords`→`wordAt` opens a bilingual card ("拼写 · Spelling") with up to 5 nspell corrections as pills (click to replace via `insertContentAt`) + "添加到词典 · Add to dictionary". Closes on outside-pointer-down, doc edit, or doc switch.
- Verified: tsc clean, vite build OK (dict in `dist/dictionaries/en/`), go build/vet clean; live server serves both dict files (200, 3086B aff / 551762B dic); nspell smoke (`helllo→hello`, `recieve→receive`, `写作` untokenized, `NASA` ok, `add()` persists).
+### Phase 8 — Trust foundation (version history + export) ✅
+- [x] **Version history** — `document_versions` table (migration `0003`), full-body snapshots that cascade with the doc. Kinds: `auto` (throttled background, ≥3min apart, max 40/doc, pruned), `manual` (explicit restore point), `pre_restore` (safety copy taken before a restore, so restore is undoable). Snapshot taken post-save in `update` only when a real body came through and content changed (empties + bare renames never snapshot). Endpoints: `GET/POST /api/docs/:id/versions`, `GET /api/docs/:id/versions/:vid`, `POST /api/docs/:id/versions/:vid/restore`. All scoped to the owner via a join on `documents`. `versions_test.go` covers lifecycle/throttle/restore/pre_restore/404.
+- [x] **Export** — pure-Go Tiptap-JSON → Markdown / HTML / plain-text / **docx** (`export.go`), no cgo/pandoc, CJK-safe. docx is a hand-built OOXML zip (marks→run props, headings→built-in styles, lists→prefix). RFC 5987 `filename*=UTF-8''` so Chinese titles download cleanly. `GET /api/docs/:id/export?format=`. **PDF is client-side** via the browser print dialog + a `@media print` stylesheet (uses the reader's fonts → CJK for free, no embedded-font bloat). `export_test.go` asserts every format incl. valid-zip docx with CJK.
+- [x] **Frontend** — `ExportMenu` (download links + Print/PDF) and `HistoryPanel` (slide-over drawer: snapshot list w/ relative-time + kind badge, preview, restore; restore remounts the editor via an `editorEpoch` bump). Both bilingual zh-first, matching chrome. Wired into the title row; `.petal-no-print` strips all chrome for print.
+- [x] **Stop saving empty docs** — blank `Untitled` drafts now self-discard: `handleCreate` reuses an existing blank instead of stacking another; `openDoc` deletes the blank doc being left. Cleaned the 2 existing orphan empties from the live DB. (Backend also refuses to snapshot empties.)
+- Verified: go build/vet/test + tsc + vite all clean; live smoke on a throwaway binary — auto-snapshot on save, throttle holds at 1, manual snapshot, restore brings back exact text + leaves a `pre_restore`, empty doc → no snapshot, md/docx export with CJK+bold+heading+list, docx validates as "Microsoft Word 2007+".
+
### Deferred (post-v1-local)
-- [ ] Authentik OIDC auth + session middleware
+- [ ] Authentik OIDC auth + session middleware ← **on hold: user doing foundational work first**
- [ ] Copyleaks Tier-2 + webhook HMAC
- [ ] Dockerfile, docker-compose, Traefik, deploy to write.parodia.dev
+### Next-up (post-v1 product, agreed with user 2026-06-26)
+- [ ] **Phase 9 — ESL superpowers**: inline Chinese gloss on hover/select; "say it more naturally" / tone-rewrite.
+- [ ] **Phase 10 — organization & polish**: cross-doc search, folders/tags, tablet/touch polish, warm LLM-down failure states.
+
## Session log
+- 2026-06-26: **Phase 8 complete** (Trust foundation: version history + export) + empty-doc fix. Backend: migration `0003_document_versions`; `internal/docs/versions.go` (throttled auto-snapshot wired into `update`, manual snapshot, restore-with-pre_restore, prune to 40 auto, owner-scoped via join) and `internal/docs/export.go` (pure-Go Tiptap-JSON → md/html/txt/docx, no deps, CJK-safe filenames via RFC 5987). `DocumentVersion` model + kind constants. Tests: `versions_test.go`, `export_test.go` (incl. valid-zip docx assertion). Frontend: `api.client` version/export methods; `ExportMenu` + `HistoryPanel` components wired into the title row; `@media print` stylesheet + `.petal-no-print` for the browser PDF path; `editorEpoch` remount on restore. Empty-doc fix in `App.tsx` (blank drafts reuse-on-create + discard-on-leave via refs to dodge stale closures); deleted 2 orphan empties from the live :8099 DB. Multi-session plan agreed: this session = Phase 8; **Phase 9 (ESL gloss + tone-rewrite)** next, then **Phase 10 (search/folders/polish)**; **auth/deploy (was Phase 11) shelved** until user's foundational work lands. All builds/tests/vet clean; live smoke verified the full version+export+restore flow end-to-end. Next: **Phase 9.**
- 2026-06-25: Spec reviewed & amended (voice/grammar decoupled, ctx cap, routes, voice DB type, honey color, string-anchoring). Build plan created.
- 2026-06-25: **Phase 0 complete.** Go module + chi server, config loader, React/Vite/Tailwind-v4 scaffold with full design tokens, frontend embedded & served by the binary, verified end-to-end. Toolchain: Go 1.24.4, Node 22, npm 10. Next: **Phase 1 (data layer)** — SQLite via modernc, models, seed `local` user.
- 2026-06-25: **Phase 1 complete.** `internal/db` package: modernc.org/sqlite (pulled go toolchain → 1.25), `Open()` does mkdir + WAL/foreign-keys DSN + versioned migration runner + idempotent local-user seed. Models with type/status constants. Wired into `main.go`; tests pass (migrate/seed idempotency, CHECK reject, FK cascade). Verified server boots and writes `petal.db`. Next: **Phase 2 (document CRUD + auto-save)** — first "it works" milestone.
diff --git a/internal/db/db.go b/internal/db/db.go
index b3cfce5..3802c1b 100644
--- a/internal/db/db.go
+++ b/internal/db/db.go
@@ -176,6 +176,31 @@ CREATE INDEX idx_plagiarism_doc_id ON plagiarism_reports(doc_id);
name: "0002_document_tone",
stmt: `ALTER TABLE documents ADD COLUMN tone TEXT NOT NULL DEFAULT 'general';`,
},
+ {
+ // Version history: point-in-time snapshots of a document's body so a
+ // bad edit or LLM mishap is always recoverable. `kind` distinguishes
+ // throttled background snapshots ('auto'), explicit user restore
+ // points ('manual'), and the safety copy taken right before a restore
+ // ('pre_restore') so restoring is itself undoable. Snapshots cascade
+ // with the document. Stored fully (content + content_text) so a
+ // restore is a plain copy with no re-derivation.
+ name: "0003_document_versions",
+ stmt: `
+CREATE TABLE document_versions (
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
+ doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
+ title TEXT NOT NULL,
+ content TEXT NOT NULL,
+ content_text TEXT NOT NULL,
+ word_count INTEGER NOT NULL DEFAULT 0,
+ kind TEXT NOT NULL DEFAULT 'auto'
+ CHECK(kind IN ('auto','manual','pre_restore')),
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX idx_versions_doc_id ON document_versions(doc_id, created_at DESC);
+`,
+ },
}
}
diff --git a/internal/db/models.go b/internal/db/models.go
index a3daa5e..fee29c1 100644
--- a/internal/db/models.go
+++ b/internal/db/models.go
@@ -27,6 +27,30 @@ type Document struct {
UpdatedAt time.Time `json:"updated_at"`
}
+// DocumentVersion is a point-in-time snapshot of a document's body, captured so
+// a writer can recover from a bad edit or an unwanted change. `Content` mirrors
+// the document's Tiptap JSON at snapshot time; `Kind` records why it was taken
+// (see the kind constants). List responses omit the heavy Content/ContentText
+// fields (the `omitempty`-friendly zero strings) and load them only on preview
+// or restore.
+type DocumentVersion struct {
+ ID string `json:"id"`
+ DocID string `json:"doc_id"`
+ Title string `json:"title"`
+ Content string `json:"content,omitempty"` // Tiptap JSON; omitted in list view
+ ContentText string `json:"content_text,omitempty"` // plain text; omitted in list view
+ WordCount int `json:"word_count"`
+ Kind string `json:"kind"` // auto | manual | pre_restore
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// Document version kinds, mirrored from the schema CHECK constraint.
+const (
+ VersionKindAuto = "auto" // throttled background snapshot on save
+ VersionKindManual = "manual" // explicit "save a restore point"
+ VersionKindPreRestore = "pre_restore" // safety copy taken just before a restore
+)
+
// Suggestion is a single LLM-proposed edit anchored to a span of the document.
//
// FromPos/ToPos are plaintext offsets into ContentText for server-side use only;
diff --git a/internal/docs/export.go b/internal/docs/export.go
new file mode 100644
index 0000000..37abd1b
--- /dev/null
+++ b/internal/docs/export.go
@@ -0,0 +1,629 @@
+package docs
+
+import (
+ "archive/zip"
+ "bytes"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+
+ "gitea.parodia.dev/drwily/petal/internal/db"
+)
+
+// exportRoutes registers the download endpoint: GET /api/docs/{id}/export?format=md|html|txt|docx
+func (h *Handler) exportRoutes(r chi.Router) {
+ r.Get("/{id}/export", h.export)
+}
+
+// exportFormat describes one downloadable format: how to render it and how to
+// label the resulting file.
+type exportFormat struct {
+ ext string
+ contentType string
+ render func(doc db.Document) ([]byte, error)
+}
+
+// exportFormats is the supported set. PDF is intentionally absent: a faithful,
+// CJK-safe PDF needs an embedded Unicode font (~10MB into the single binary) or
+// a headless browser (breaks the no-cgo, single-binary story). The frontend
+// offers "Print / Save as PDF" via the browser instead, which uses the reader's
+// own fonts and renders CJK correctly for free.
+var exportFormats = map[string]exportFormat{
+ "md": {ext: "md", contentType: "text/markdown; charset=utf-8", render: renderMarkdown},
+ "html": {ext: "html", contentType: "text/html; charset=utf-8", render: renderHTMLFile},
+ "txt": {ext: "txt", contentType: "text/plain; charset=utf-8", render: renderPlainText},
+ "docx": {
+ ext: "docx",
+ contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ render: renderDocx,
+ },
+}
+
+// export streams the document in the requested format as a download.
+func (h *Handler) export(w http.ResponseWriter, r *http.Request) {
+ formatKey := r.URL.Query().Get("format")
+ if formatKey == "" {
+ formatKey = "md"
+ }
+ format, ok := exportFormats[formatKey]
+ if !ok {
+ badRequest(w, "unsupported export format")
+ return
+ }
+
+ doc, err := h.fetch(chi.URLParam(r, "id"))
+ if errors.Is(err, sql.ErrNoRows) {
+ notFound(w)
+ return
+ }
+ if err != nil {
+ serverError(w, err)
+ return
+ }
+
+ body, err := format.render(doc)
+ if err != nil {
+ serverError(w, err)
+ return
+ }
+
+ filename := sanitizeFilename(doc.Title) + "." + format.ext
+ w.Header().Set("Content-Type", format.contentType)
+ w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", urlEscapeFilename(filename)))
+ w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
+ _, _ = w.Write(body)
+}
+
+// --- Tiptap document model --------------------------------------------------
+
+// pmNode is one ProseMirror/Tiptap node. The tree is what the editor stores in
+// Document.Content; we walk it to render every export format from one source.
+type pmNode struct {
+ Type string `json:"type"`
+ Attrs map[string]any `json:"attrs"`
+ Marks []pmMark `json:"marks"`
+ Text string `json:"text"`
+ Content []pmNode `json:"content"`
+}
+
+type pmMark struct {
+ Type string `json:"type"`
+}
+
+// parseDoc decodes Document.Content into a node tree. On empty or malformed JSON
+// it falls back to wrapping the plain-text mirror in paragraphs, so export never
+// fails just because the editor state is unusual.
+func parseDoc(doc db.Document) pmNode {
+ var root pmNode
+ if err := json.Unmarshal([]byte(doc.Content), &root); err != nil || root.Type == "" {
+ return fallbackDoc(doc.ContentText)
+ }
+ if len(root.Content) == 0 && strings.TrimSpace(doc.ContentText) != "" {
+ return fallbackDoc(doc.ContentText)
+ }
+ return root
+}
+
+// fallbackDoc builds a minimal doc node from plain text, one paragraph per line.
+func fallbackDoc(text string) pmNode {
+ root := pmNode{Type: "doc"}
+ for _, line := range strings.Split(text, "\n") {
+ p := pmNode{Type: "paragraph"}
+ if line != "" {
+ p.Content = []pmNode{{Type: "text", Text: line}}
+ }
+ root.Content = append(root.Content, p)
+ }
+ return root
+}
+
+func (n pmNode) hasMark(t string) bool {
+ for _, m := range n.Marks {
+ if m.Type == t {
+ return true
+ }
+ }
+ return false
+}
+
+func (n pmNode) level() int {
+ if n.Attrs == nil {
+ return 1
+ }
+ if l, ok := n.Attrs["level"].(float64); ok && l >= 1 {
+ return int(l)
+ }
+ return 1
+}
+
+// --- Markdown ---------------------------------------------------------------
+
+func renderMarkdown(doc db.Document) ([]byte, error) {
+ root := parseDoc(doc)
+ var blocks []string
+ for _, child := range root.Content {
+ if s := mdBlock(child, 0); s != "" {
+ blocks = append(blocks, s)
+ }
+ }
+ out := "# " + doc.Title + "\n\n" + strings.Join(blocks, "\n\n") + "\n"
+ return []byte(out), nil
+}
+
+func mdBlock(n pmNode, depth int) string {
+ switch n.Type {
+ case "heading":
+ return strings.Repeat("#", n.level()) + " " + mdInline(n.Content)
+ case "paragraph":
+ return mdInline(n.Content)
+ case "blockquote":
+ var lines []string
+ for _, c := range n.Content {
+ for _, l := range strings.Split(mdBlock(c, depth), "\n") {
+ lines = append(lines, "> "+l)
+ }
+ }
+ return strings.Join(lines, "\n")
+ case "codeBlock":
+ return "```\n" + textContent(n) + "\n```"
+ case "horizontalRule":
+ return "---"
+ case "bulletList", "orderedList":
+ var items []string
+ for i, item := range n.Content {
+ marker := "- "
+ if n.Type == "orderedList" {
+ marker = fmt.Sprintf("%d. ", i+1)
+ }
+ indent := strings.Repeat(" ", depth)
+ // A listItem holds block children (usually one paragraph).
+ var parts []string
+ for _, c := range item.Content {
+ parts = append(parts, mdBlock(c, depth+1))
+ }
+ items = append(items, indent+marker+strings.TrimSpace(strings.Join(parts, "\n")))
+ }
+ return strings.Join(items, "\n")
+ default:
+ // Unknown block: render any inline text it carries.
+ if len(n.Content) > 0 {
+ return mdInline(n.Content)
+ }
+ return ""
+ }
+}
+
+func mdInline(nodes []pmNode) string {
+ var b strings.Builder
+ for _, n := range nodes {
+ switch n.Type {
+ case "text":
+ b.WriteString(applyMdMarks(n))
+ case "hardBreak":
+ b.WriteString(" \n")
+ default:
+ b.WriteString(mdInline(n.Content))
+ }
+ }
+ return b.String()
+}
+
+func applyMdMarks(n pmNode) string {
+ t := n.Text
+ if n.hasMark("code") {
+ return "`" + t + "`" // code spans don't combine with other emphasis
+ }
+ if n.hasMark("bold") {
+ t = "**" + t + "**"
+ }
+ if n.hasMark("italic") {
+ t = "*" + t + "*"
+ }
+ if n.hasMark("strike") {
+ t = "~~" + t + "~~"
+ }
+ if n.hasMark("underline") {
+ t = "" + t + ""
+ }
+ return t
+}
+
+// --- Plain text -------------------------------------------------------------
+
+func renderPlainText(doc db.Document) ([]byte, error) {
+ root := parseDoc(doc)
+ var blocks []string
+ for _, child := range root.Content {
+ if s := txtBlock(child); s != "" {
+ blocks = append(blocks, s)
+ }
+ }
+ out := doc.Title + "\n\n" + strings.Join(blocks, "\n\n") + "\n"
+ return []byte(out), nil
+}
+
+func txtBlock(n pmNode) string {
+ switch n.Type {
+ case "bulletList", "orderedList":
+ var items []string
+ for i, item := range n.Content {
+ marker := "• "
+ if n.Type == "orderedList" {
+ marker = fmt.Sprintf("%d. ", i+1)
+ }
+ items = append(items, marker+strings.TrimSpace(textContent(item)))
+ }
+ return strings.Join(items, "\n")
+ case "horizontalRule":
+ return "----------"
+ default:
+ return textContent(n)
+ }
+}
+
+// textContent flattens all descendant text, joining hardBreaks as newlines.
+func textContent(n pmNode) string {
+ if n.Type == "text" {
+ return n.Text
+ }
+ if n.Type == "hardBreak" {
+ return "\n"
+ }
+ var b strings.Builder
+ for _, c := range n.Content {
+ b.WriteString(textContent(c))
+ }
+ return b.String()
+}
+
+// --- HTML -------------------------------------------------------------------
+
+func renderHTMLFile(doc db.Document) ([]byte, error) {
+ root := parseDoc(doc)
+ var body strings.Builder
+ for _, child := range root.Content {
+ body.WriteString(htmlBlock(child))
+ }
+ page := fmt.Sprintf(htmlTemplate, htmlEscape(doc.Title), htmlEscape(doc.Title), body.String())
+ return []byte(page), nil
+}
+
+// htmlTemplate is a standalone, self-contained page with a warm, readable
+// stylesheet and a CJK-first font stack so exported writing looks like Petal,
+// not a raw dump. No external assets — opens offline anywhere.
+const htmlTemplate = `
+
+
+
+
+%s
+
+
+
+%s
+%s
+
+`
+
+func htmlBlock(n pmNode) string {
+ switch n.Type {
+ case "heading":
+ tag := fmt.Sprintf("h%d", clampHeading(n.level()))
+ return fmt.Sprintf("<%s>%s%s>\n", tag, htmlInline(n.Content), tag)
+ case "paragraph":
+ inner := htmlInline(n.Content)
+ if inner == "" {
+ return "
\n"
+ }
+ return "" + inner + "
\n"
+ case "blockquote":
+ var b strings.Builder
+ for _, c := range n.Content {
+ b.WriteString(htmlBlock(c))
+ }
+ return "" + b.String() + "
\n"
+ case "codeBlock":
+ return "" + htmlEscape(textContent(n)) + "
\n"
+ case "horizontalRule":
+ return "
\n"
+ case "bulletList", "orderedList":
+ tag := "ul"
+ if n.Type == "orderedList" {
+ tag = "ol"
+ }
+ var b strings.Builder
+ b.WriteString("<" + tag + ">\n")
+ for _, item := range n.Content {
+ var inner strings.Builder
+ for _, c := range item.Content {
+ // Unwrap a lone paragraph so list items aren't double-spaced.
+ if c.Type == "paragraph" {
+ inner.WriteString(htmlInline(c.Content))
+ } else {
+ inner.WriteString(htmlBlock(c))
+ }
+ }
+ b.WriteString("" + inner.String() + "\n")
+ }
+ b.WriteString("" + tag + ">\n")
+ return b.String()
+ default:
+ if len(n.Content) > 0 {
+ return "" + htmlInline(n.Content) + "
\n"
+ }
+ return ""
+ }
+}
+
+func htmlInline(nodes []pmNode) string {
+ var b strings.Builder
+ for _, n := range nodes {
+ switch n.Type {
+ case "text":
+ b.WriteString(applyHTMLMarks(n))
+ case "hardBreak":
+ b.WriteString("
")
+ default:
+ b.WriteString(htmlInline(n.Content))
+ }
+ }
+ return b.String()
+}
+
+func applyHTMLMarks(n pmNode) string {
+ t := htmlEscape(n.Text)
+ if n.hasMark("code") {
+ return "" + t + ""
+ }
+ if n.hasMark("bold") {
+ t = "" + t + ""
+ }
+ if n.hasMark("italic") {
+ t = "" + t + ""
+ }
+ if n.hasMark("underline") {
+ t = "" + t + ""
+ }
+ if n.hasMark("strike") {
+ t = "" + t + ""
+ }
+ return t
+}
+
+func clampHeading(level int) int {
+ if level < 1 {
+ return 1
+ }
+ if level > 6 {
+ return 6
+ }
+ return level
+}
+
+// --- DOCX -------------------------------------------------------------------
+
+// renderDocx builds a minimal but valid .docx (Office Open XML) in pure Go: a
+// zip of the few XML parts Word needs. Bold/italic/underline/strike map to run
+// properties; headings use Word's built-in styles; lists are rendered with a
+// bullet/number prefix (no numbering.xml dependency). CJK renders with the
+// reader's own fonts, so no font embedding is required.
+func renderDocx(doc db.Document) ([]byte, error) {
+ root := parseDoc(doc)
+
+ var body strings.Builder
+ body.WriteString(docxHeading(doc.Title, 1))
+ for _, child := range root.Content {
+ body.WriteString(docxBlock(child))
+ }
+
+ document := xmlHeader + `` +
+ `` + body.String() +
+ `` +
+ ``
+
+ var buf bytes.Buffer
+ zw := zip.NewWriter(&buf)
+ parts := []struct{ name, content string }{
+ {"[Content_Types].xml", docxContentTypes},
+ {"_rels/.rels", docxRootRels},
+ {"word/_rels/document.xml.rels", docxDocRels},
+ {"word/document.xml", document},
+ }
+ for _, p := range parts {
+ fw, err := zw.Create(p.name)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := fw.Write([]byte(p.content)); err != nil {
+ return nil, err
+ }
+ }
+ if err := zw.Close(); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
+
+const (
+ xmlHeader = `` + "\n"
+ docxContentTypes = xmlHeader + `` +
+ `` +
+ `` +
+ `` +
+ ``
+ docxRootRels = xmlHeader + `` +
+ `` +
+ ``
+ docxDocRels = xmlHeader + ``
+)
+
+func docxBlock(n pmNode) string {
+ switch n.Type {
+ case "heading":
+ return docxHeading(textContent(n), n.level())
+ case "paragraph":
+ return docxPara(n, "")
+ case "blockquote":
+ var b strings.Builder
+ for _, c := range n.Content {
+ b.WriteString(docxPara(c, "Quote"))
+ }
+ return b.String()
+ case "codeBlock":
+ // One paragraph per line preserves layout without a code style.
+ var b strings.Builder
+ for _, line := range strings.Split(textContent(n), "\n") {
+ b.WriteString(`` +
+ `` + xmlEscape(line) + ``)
+ }
+ return b.String()
+ case "horizontalRule":
+ return ``
+ case "bulletList", "orderedList":
+ var b strings.Builder
+ for i, item := range n.Content {
+ prefix := "• "
+ if n.Type == "orderedList" {
+ prefix = fmt.Sprintf("%d. ", i+1)
+ }
+ for _, c := range item.Content {
+ b.WriteString(docxPara(c, "", prefix))
+ }
+ }
+ return b.String()
+ default:
+ if len(n.Content) > 0 {
+ return docxPara(n, "")
+ }
+ return ""
+ }
+}
+
+// docxPara renders a block's inline children as a Word paragraph. An optional
+// style name (e.g. "Quote") and an optional literal text prefix (for list
+// markers) may be supplied.
+func docxPara(n pmNode, style string, prefix ...string) string {
+ var b strings.Builder
+ b.WriteString("")
+ if style != "" {
+ b.WriteString(``)
+ }
+ if len(prefix) > 0 && prefix[0] != "" {
+ b.WriteString(docxRun(pmNode{Type: "text", Text: prefix[0]}))
+ }
+ b.WriteString(docxInline(n.Content))
+ b.WriteString("")
+ return b.String()
+}
+
+func docxHeading(text string, level int) string {
+ return `` +
+ docxRun(pmNode{Type: "text", Text: text}) + ``
+}
+
+func docxInline(nodes []pmNode) string {
+ var b strings.Builder
+ for _, n := range nodes {
+ switch n.Type {
+ case "text":
+ b.WriteString(docxRun(n))
+ case "hardBreak":
+ b.WriteString(``)
+ default:
+ b.WriteString(docxInline(n.Content))
+ }
+ }
+ return b.String()
+}
+
+func docxRun(n pmNode) string {
+ var props strings.Builder
+ if n.hasMark("bold") {
+ props.WriteString("")
+ }
+ if n.hasMark("italic") {
+ props.WriteString("")
+ }
+ if n.hasMark("underline") {
+ props.WriteString(``)
+ }
+ if n.hasMark("strike") {
+ props.WriteString("")
+ }
+ rpr := ""
+ if props.Len() > 0 {
+ rpr = "" + props.String() + ""
+ }
+ return `` + rpr + `` + xmlEscape(n.Text) + ``
+}
+
+// --- small helpers ----------------------------------------------------------
+
+func htmlEscape(s string) string {
+ r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """, "'", "'")
+ return r.Replace(s)
+}
+
+func xmlEscape(s string) string {
+ r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """)
+ return r.Replace(s)
+}
+
+// sanitizeFilename makes a title safe as a download filename, preserving CJK and
+// most letters while dropping path separators and control characters.
+func sanitizeFilename(title string) string {
+ title = strings.TrimSpace(title)
+ if title == "" {
+ return "untitled"
+ }
+ var b strings.Builder
+ for _, r := range title {
+ switch {
+ case r < 0x20, r == '/', r == '\\', r == ':', r == '*', r == '?', r == '"', r == '<', r == '>', r == '|':
+ b.WriteRune('-')
+ default:
+ b.WriteRune(r)
+ }
+ }
+ out := strings.TrimSpace(b.String())
+ if out == "" {
+ return "untitled"
+ }
+ return out
+}
+
+// urlEscapeFilename percent-encodes a filename for the RFC 5987 filename*=
+// Content-Disposition form, which carries UTF-8 (CJK titles) safely.
+func urlEscapeFilename(s string) string {
+ var b strings.Builder
+ for _, c := range []byte(s) {
+ if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
+ c == '-' || c == '_' || c == '.' || c == '~' {
+ b.WriteByte(c)
+ } else {
+ fmt.Fprintf(&b, "%%%02X", c)
+ }
+ }
+ return b.String()
+}
diff --git a/internal/docs/export_test.go b/internal/docs/export_test.go
new file mode 100644
index 0000000..fbb607c
--- /dev/null
+++ b/internal/docs/export_test.go
@@ -0,0 +1,135 @@
+package docs
+
+import (
+ "archive/zip"
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+// richDocJSON is a Tiptap document exercising headings, marks, and a list —
+// including CJK text, which every format must carry through intact.
+const richDocJSON = `{"type":"doc","content":[` +
+ `{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"My Section"}]},` +
+ `{"type":"paragraph","content":[{"type":"text","text":"Hello "},{"type":"text","marks":[{"type":"bold"}],"text":"world"},{"type":"text","text":" 你好"}]},` +
+ `{"type":"bulletList","content":[` +
+ `{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]},` +
+ `{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"second"}]}]}` +
+ `]}` +
+ `]}`
+
+func seedRichDoc(t *testing.T, srv http.Handler) string {
+ t.Helper()
+ id := newDoc(t, srv)
+ body := `{"title":"日记 Diary","content":` + jsonString(richDocJSON) + `,"content_text":"My Section\nHello world 你好\nfirst\nsecond","word_count":6}`
+ if rec := do(t, srv, http.MethodPut, "/"+id, body); rec.Code != http.StatusOK {
+ t.Fatalf("seed rich doc: code=%d body=%s", rec.Code, rec.Body)
+ }
+ return id
+}
+
+// jsonString quotes a string as a JSON string literal (so the Tiptap JSON can be
+// embedded as the "content" field value).
+func jsonString(s string) string {
+ b, _ := json.Marshal(s)
+ return string(b)
+}
+
+func TestExportMarkdown(t *testing.T) {
+ srv := newTestServer(t)
+ id := seedRichDoc(t, srv)
+
+ rec := do(t, srv, http.MethodGet, "/"+id+"/export?format=md", "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("export md: code=%d body=%s", rec.Code, rec.Body)
+ }
+ if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/markdown") {
+ t.Fatalf("unexpected content-type: %q", ct)
+ }
+ out := rec.Body.String()
+ for _, want := range []string{"## My Section", "**world**", "你好", "- first", "- second"} {
+ if !strings.Contains(out, want) {
+ t.Fatalf("markdown missing %q in:\n%s", want, out)
+ }
+ }
+ // CJK filename must survive in the RFC 5987 form.
+ if cd := rec.Header().Get("Content-Disposition"); !strings.Contains(cd, "filename*=UTF-8''") {
+ t.Fatalf("expected RFC 5987 filename, got %q", cd)
+ }
+}
+
+func TestExportHTML(t *testing.T) {
+ srv := newTestServer(t)
+ id := seedRichDoc(t, srv)
+
+ rec := do(t, srv, http.MethodGet, "/"+id+"/export?format=html", "")
+ out := rec.Body.String()
+ for _, want := range []string{"", "My Section
", "world", "你好", "first"} {
+ if !strings.Contains(out, want) {
+ t.Fatalf("html missing %q", want)
+ }
+ }
+}
+
+func TestExportPlainText(t *testing.T) {
+ srv := newTestServer(t)
+ id := seedRichDoc(t, srv)
+
+ rec := do(t, srv, http.MethodGet, "/"+id+"/export?format=txt", "")
+ out := rec.Body.String()
+ for _, want := range []string{"My Section", "Hello world 你好", "• first"} {
+ if !strings.Contains(out, want) {
+ t.Fatalf("txt missing %q in:\n%s", want, out)
+ }
+ }
+}
+
+func TestExportDocx(t *testing.T) {
+ srv := newTestServer(t)
+ id := seedRichDoc(t, srv)
+
+ rec := do(t, srv, http.MethodGet, "/"+id+"/export?format=docx", "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("export docx: code=%d", rec.Code)
+ }
+ raw := rec.Body.Bytes()
+ zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
+ if err != nil {
+ t.Fatalf("docx is not a valid zip: %v", err)
+ }
+
+ want := map[string]bool{"[Content_Types].xml": false, "word/document.xml": false}
+ var docXML string
+ for _, f := range zr.File {
+ if _, ok := want[f.Name]; ok {
+ want[f.Name] = true
+ }
+ if f.Name == "word/document.xml" {
+ rc, _ := f.Open()
+ b, _ := io.ReadAll(rc)
+ rc.Close()
+ docXML = string(b)
+ }
+ }
+ for name, found := range want {
+ if !found {
+ t.Fatalf("docx missing part %q", name)
+ }
+ }
+ for _, w := range []string{"My Section", "你好", "", "Heading2"} {
+ if !strings.Contains(docXML, w) {
+ t.Fatalf("document.xml missing %q", w)
+ }
+ }
+}
+
+func TestExportUnsupportedFormat(t *testing.T) {
+ srv := newTestServer(t)
+ id := newDoc(t, srv)
+ if rec := do(t, srv, http.MethodGet, "/"+id+"/export?format=pdf", ""); rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400 for unsupported format, got %d", rec.Code)
+ }
+}
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go
index 9f2858d..81597aa 100644
--- a/internal/docs/handlers.go
+++ b/internal/docs/handlers.go
@@ -7,6 +7,7 @@ import (
"database/sql"
"encoding/json"
"errors"
+ "log"
"net/http"
"github.com/go-chi/chi/v5"
@@ -33,6 +34,8 @@ func (h *Handler) Routes() chi.Router {
r.Get("/{id}", h.get)
r.Put("/{id}", h.update)
r.Delete("/{id}", h.delete)
+ h.versionRoutes(r)
+ h.exportRoutes(r)
return r
}
@@ -155,6 +158,16 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
serverError(w, err)
return
}
+
+ // Capture a throttled history snapshot when a real body save came through
+ // (not a bare rename) and the document has content. Best-effort: a failed
+ // snapshot must never fail the save itself.
+ if req.Content != nil && doc.ContentText != "" {
+ if err := h.maybeAutoSnapshot(doc); err != nil {
+ log.Printf("docs: auto-snapshot for %s failed: %v", id, err)
+ }
+ }
+
writeJSON(w, http.StatusOK, doc)
}
@@ -208,3 +221,4 @@ func serverError(w http.ResponseWriter, err error) {
func badRequest(w http.ResponseWriter, msg string) { errorJSON(w, http.StatusBadRequest, msg) }
func notFound(w http.ResponseWriter) { errorJSON(w, http.StatusNotFound, "document not found") }
+func notFoundMsg(w http.ResponseWriter, msg string) { errorJSON(w, http.StatusNotFound, msg) }
diff --git a/internal/docs/versions.go b/internal/docs/versions.go
new file mode 100644
index 0000000..c0cc315
--- /dev/null
+++ b/internal/docs/versions.go
@@ -0,0 +1,245 @@
+package docs
+
+import (
+ "database/sql"
+ "errors"
+ "net/http"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+
+ "gitea.parodia.dev/drwily/petal/internal/db"
+)
+
+// Version-history tuning.
+const (
+ // autoSnapshotInterval is the minimum gap between background ('auto')
+ // snapshots. Auto-save fires every ~1.5s; without a floor we'd store a
+ // version per keystroke-burst. A few minutes keeps history useful for
+ // recovery without unbounded growth.
+ autoSnapshotInterval = 3 * time.Minute
+
+ // maxAutoVersions caps how many 'auto' snapshots we retain per document.
+ // 'manual' and 'pre_restore' versions are never pruned — they're explicit
+ // restore points the writer (or a restore) deliberately created.
+ maxAutoVersions = 40
+)
+
+// versionRoutes registers the history endpoints on the docs sub-router. Paths
+// resolve to /api/docs/{id}/versions...
+func (h *Handler) versionRoutes(r chi.Router) {
+ r.Get("/{id}/versions", h.listVersions)
+ r.Post("/{id}/versions", h.createVersion) // explicit "save a restore point"
+ r.Get("/{id}/versions/{vid}", h.getVersion) // full body for preview
+ r.Post("/{id}/versions/{vid}/restore", h.restoreVersion)
+}
+
+// listVersions returns the document's snapshots, newest first, without the heavy
+// content fields (those load on preview/restore).
+func (h *Handler) listVersions(w http.ResponseWriter, r *http.Request) {
+ docID := chi.URLParam(r, "id")
+
+ // Scope through the documents table so a snapshot is only visible to the
+ // owner of its parent document.
+ rows, err := h.DB.Query(
+ `SELECT v.id, v.doc_id, v.title, v.word_count, v.kind, v.created_at
+ FROM document_versions v
+ JOIN documents d ON d.id = v.doc_id
+ WHERE v.doc_id = ? AND d.user_id = ?
+ ORDER BY v.created_at DESC`,
+ docID, db.LocalUserID,
+ )
+ if err != nil {
+ serverError(w, err)
+ return
+ }
+ defer rows.Close()
+
+ out := []db.DocumentVersion{} // non-nil so an empty history serializes as []
+ for rows.Next() {
+ var v db.DocumentVersion
+ if err := rows.Scan(&v.ID, &v.DocID, &v.Title, &v.WordCount, &v.Kind, &v.CreatedAt); err != nil {
+ serverError(w, err)
+ return
+ }
+ out = append(out, v)
+ }
+ if err := rows.Err(); err != nil {
+ serverError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, out)
+}
+
+// getVersion returns one snapshot in full (including content) for preview.
+func (h *Handler) getVersion(w http.ResponseWriter, r *http.Request) {
+ v, err := h.fetchVersion(chi.URLParam(r, "id"), chi.URLParam(r, "vid"))
+ if errors.Is(err, sql.ErrNoRows) {
+ notFoundMsg(w, "version not found")
+ return
+ }
+ if err != nil {
+ serverError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, v)
+}
+
+// createVersion takes an explicit, user-requested ('manual') restore point from
+// the document's current saved state.
+func (h *Handler) createVersion(w http.ResponseWriter, r *http.Request) {
+ docID := chi.URLParam(r, "id")
+
+ doc, err := h.fetch(docID)
+ if errors.Is(err, sql.ErrNoRows) {
+ notFound(w)
+ return
+ }
+ if err != nil {
+ serverError(w, err)
+ return
+ }
+
+ v, err := h.insertVersion(doc, db.VersionKindManual)
+ if err != nil {
+ serverError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusCreated, v)
+}
+
+// restoreVersion copies a snapshot back onto the live document. Before
+// overwriting, it captures the current state as a 'pre_restore' version so the
+// restore is itself undoable. Returns the restored document.
+func (h *Handler) restoreVersion(w http.ResponseWriter, r *http.Request) {
+ docID := chi.URLParam(r, "id")
+ vid := chi.URLParam(r, "vid")
+
+ v, err := h.fetchVersion(docID, vid)
+ if errors.Is(err, sql.ErrNoRows) {
+ notFoundMsg(w, "version not found")
+ return
+ }
+ if err != nil {
+ serverError(w, err)
+ return
+ }
+
+ current, err := h.fetch(docID)
+ if err != nil {
+ serverError(w, err)
+ return
+ }
+ if _, err := h.insertVersion(current, db.VersionKindPreRestore); err != nil {
+ serverError(w, err)
+ return
+ }
+
+ res, err := h.DB.Exec(
+ `UPDATE documents
+ SET title = ?, content = ?, content_text = ?, word_count = ?,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ? AND user_id = ?`,
+ v.Title, v.Content, v.ContentText, v.WordCount, docID, db.LocalUserID,
+ )
+ if err != nil {
+ serverError(w, err)
+ return
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ notFound(w)
+ return
+ }
+
+ doc, err := h.fetch(docID)
+ if err != nil {
+ serverError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, doc)
+}
+
+// maybeAutoSnapshot records a throttled background snapshot of the just-saved
+// document. It no-ops when the newest snapshot is younger than
+// autoSnapshotInterval, or when the body is unchanged since the last snapshot,
+// so an idle or rename-only save costs nothing. Best-effort: callers log but do
+// not fail the save if this errors. Prunes old 'auto' versions on success.
+func (h *Handler) maybeAutoSnapshot(doc db.Document) error {
+ var (
+ lastAt time.Time
+ lastText string
+ hasPrev bool
+ )
+ err := h.DB.QueryRow(
+ `SELECT created_at, content_text FROM document_versions
+ WHERE doc_id = ? ORDER BY created_at DESC LIMIT 1`,
+ doc.ID,
+ ).Scan(&lastAt, &lastText)
+ switch {
+ case errors.Is(err, sql.ErrNoRows):
+ hasPrev = false
+ case err != nil:
+ return err
+ default:
+ hasPrev = true
+ }
+
+ if hasPrev {
+ if lastText == doc.ContentText {
+ return nil // nothing meaningful changed
+ }
+ if time.Since(lastAt) < autoSnapshotInterval {
+ return nil // too soon; let edits accumulate
+ }
+ }
+
+ if _, err := h.insertVersion(doc, db.VersionKindAuto); err != nil {
+ return err
+ }
+ return h.pruneAutoVersions(doc.ID)
+}
+
+// insertVersion writes a snapshot row of the given kind and returns it (without
+// the heavy content fields, matching the list shape).
+func (h *Handler) insertVersion(doc db.Document, kind string) (db.DocumentVersion, error) {
+ var v db.DocumentVersion
+ err := h.DB.QueryRow(
+ `INSERT INTO document_versions (doc_id, title, content, content_text, word_count, kind)
+ VALUES (?, ?, ?, ?, ?, ?)
+ RETURNING id, doc_id, title, word_count, kind, created_at`,
+ doc.ID, doc.Title, doc.Content, doc.ContentText, doc.WordCount, kind,
+ ).Scan(&v.ID, &v.DocID, &v.Title, &v.WordCount, &v.Kind, &v.CreatedAt)
+ return v, err
+}
+
+// pruneAutoVersions trims a document's 'auto' snapshots to the newest
+// maxAutoVersions, leaving 'manual' and 'pre_restore' restore points intact.
+func (h *Handler) pruneAutoVersions(docID string) error {
+ _, err := h.DB.Exec(
+ `DELETE FROM document_versions
+ WHERE doc_id = ? AND kind = 'auto'
+ AND id NOT IN (
+ SELECT id FROM document_versions
+ WHERE doc_id = ? AND kind = 'auto'
+ ORDER BY created_at DESC LIMIT ?
+ )`,
+ docID, docID, maxAutoVersions,
+ )
+ return err
+}
+
+// fetchVersion loads one full snapshot, scoped to its owner via the parent doc.
+func (h *Handler) fetchVersion(docID, vid string) (db.DocumentVersion, error) {
+ var v db.DocumentVersion
+ err := h.DB.QueryRow(
+ `SELECT v.id, v.doc_id, v.title, v.content, v.content_text, v.word_count, v.kind, v.created_at
+ FROM document_versions v
+ JOIN documents d ON d.id = v.doc_id
+ WHERE v.id = ? AND v.doc_id = ? AND d.user_id = ?`,
+ vid, docID, db.LocalUserID,
+ ).Scan(
+ &v.ID, &v.DocID, &v.Title, &v.Content, &v.ContentText,
+ &v.WordCount, &v.Kind, &v.CreatedAt,
+ )
+ return v, err
+}
diff --git a/internal/docs/versions_test.go b/internal/docs/versions_test.go
new file mode 100644
index 0000000..3f41710
--- /dev/null
+++ b/internal/docs/versions_test.go
@@ -0,0 +1,107 @@
+package docs
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "gitea.parodia.dev/drwily/petal/internal/db"
+)
+
+// newDoc creates a document and returns its id.
+func newDoc(t *testing.T, srv http.Handler) string {
+ t.Helper()
+ rec := do(t, srv, http.MethodPost, "/", "")
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("create doc: code=%d body=%s", rec.Code, rec.Body)
+ }
+ var d db.Document
+ if err := json.Unmarshal(rec.Body.Bytes(), &d); err != nil {
+ t.Fatalf("decode doc: %v", err)
+ }
+ return d.ID
+}
+
+func listVersions(t *testing.T, srv http.Handler, id string) []db.DocumentVersion {
+ t.Helper()
+ rec := do(t, srv, http.MethodGet, "/"+id+"/versions", "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("list versions: code=%d body=%s", rec.Code, rec.Body)
+ }
+ var vs []db.DocumentVersion
+ if err := json.Unmarshal(rec.Body.Bytes(), &vs); err != nil {
+ t.Fatalf("decode versions: %v", err)
+ }
+ return vs
+}
+
+func TestVersionLifecycle(t *testing.T) {
+ srv := newTestServer(t)
+ id := newDoc(t, srv)
+
+ // A bare rename (no content) must not snapshot.
+ do(t, srv, http.MethodPut, "/"+id, `{"title":"Just A Name"}`)
+ if vs := listVersions(t, srv, id); len(vs) != 0 {
+ t.Fatalf("rename should not snapshot, got %d", len(vs))
+ }
+
+ // First real body save takes one auto snapshot (no prior version to throttle).
+ body := `{"content":"{\"type\":\"doc\"}","content_text":"first draft","word_count":2}`
+ do(t, srv, http.MethodPut, "/"+id, body)
+ vs := listVersions(t, srv, id)
+ if len(vs) != 1 || vs[0].Kind != db.VersionKindAuto {
+ t.Fatalf("expected 1 auto version, got %+v", vs)
+ }
+ // List view omits heavy content fields.
+ if vs[0].Content != "" {
+ t.Fatalf("list should omit content, got %q", vs[0].Content)
+ }
+
+ // A near-immediate second save is throttled (no new auto version).
+ do(t, srv, http.MethodPut, "/"+id, `{"content":"{\"type\":\"doc\"}","content_text":"first draft v2","word_count":3}`)
+ if vs := listVersions(t, srv, id); len(vs) != 1 {
+ t.Fatalf("second save within interval should be throttled, got %d", len(vs))
+ }
+
+ // Manual snapshot always records, capturing current saved state.
+ rec := do(t, srv, http.MethodPost, "/"+id+"/versions", "")
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("manual snapshot: code=%d body=%s", rec.Code, rec.Body)
+ }
+ var manual db.DocumentVersion
+ _ = json.Unmarshal(rec.Body.Bytes(), &manual)
+ if manual.Kind != db.VersionKindManual {
+ t.Fatalf("expected manual kind, got %q", manual.Kind)
+ }
+
+ // Mutate the live doc, then restore the manual snapshot.
+ do(t, srv, http.MethodPut, "/"+id, `{"content":"{\"type\":\"doc\"}","content_text":"ruined everything","word_count":2}`)
+ rec = do(t, srv, http.MethodPost, "/"+id+"/versions/"+manual.ID+"/restore", "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("restore: code=%d body=%s", rec.Code, rec.Body)
+ }
+ var restored db.Document
+ _ = json.Unmarshal(rec.Body.Bytes(), &restored)
+ if restored.ContentText != "first draft v2" {
+ t.Fatalf("restore did not bring back snapshot text: %q", restored.ContentText)
+ }
+
+ // Restore left a pre_restore safety copy, so the bad state is itself recoverable.
+ var foundPreRestore bool
+ for _, v := range listVersions(t, srv, id) {
+ if v.Kind == db.VersionKindPreRestore {
+ foundPreRestore = true
+ }
+ }
+ if !foundPreRestore {
+ t.Fatal("restore should record a pre_restore safety snapshot")
+ }
+}
+
+func TestVersionNotFound(t *testing.T) {
+ srv := newTestServer(t)
+ id := newDoc(t, srv)
+ if rec := do(t, srv, http.MethodGet, "/"+id+"/versions/nope", ""); rec.Code != http.StatusNotFound {
+ t.Fatalf("expected 404 for unknown version, got %d", rec.Code)
+ }
+}
diff --git a/web/src/App.tsx b/web/src/App.tsx
index d57f11f..4fbf6c7 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -6,6 +6,8 @@ import { useSpellChecker } from './hooks/useSpellChecker'
import { DocList } from './components/DocList/DocList'
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
import { ToneSelect } from './components/Editor/ToneSelect'
+import { ExportMenu } from './components/Export/ExportMenu'
+import { HistoryPanel } from './components/History/HistoryPanel'
import { StatusBar } from './components/StatusBar/StatusBar'
import { PetalCompanion } from './components/Companion/PetalCompanion'
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
@@ -29,6 +31,28 @@ export default function App() {
// Monotonic counters the companion watches to react to writing + accepts.
const [editTick, setEditTick] = useState(0)
const [acceptTick, setAcceptTick] = useState(0)
+ // History drawer visibility, and an epoch bumped on restore to force the
+ // editor to remount with the restored content (its initialContent is read
+ // only on mount).
+ const [historyOpen, setHistoryOpen] = useState(false)
+ const [editorEpoch, setEditorEpoch] = useState(0)
+
+ // Live mirrors of the current doc's editable fields so the "discard blank
+ // drafts on navigation" logic can read the latest values without rebuilding
+ // callbacks on every keystroke.
+ const currentDocRef = useRef(currentDoc)
+ const titleRef = useRef(title)
+ const wordCountRef = useRef(wordCount)
+ currentDocRef.current = currentDoc
+ titleRef.current = title
+ wordCountRef.current = wordCount
+
+ // A throwaway blank draft: no words and still the default/empty title. We
+ // delete these on navigation rather than leave orphan "Untitled" docs behind.
+ const isBlankDraft = useCallback(() => {
+ const t = titleRef.current.trim()
+ return wordCountRef.current === 0 && (t === '' || t === 'Untitled')
+ }, [])
const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null)
const {
@@ -49,6 +73,9 @@ export default function App() {
const openDoc = useCallback(
async (id: string) => {
+ // Decide the leaving doc's fate before any await mutates state.
+ const leaving = currentDocRef.current
+ const leavingBlank = isBlankDraft()
await saveNow() // flush any pending edits to the doc we're leaving
const doc = await api.getDoc(id)
setCurrentDoc(doc)
@@ -56,8 +83,14 @@ export default function App() {
setWordCount(doc.word_count)
setTone(doc.tone || 'general')
setDocText(doc.content_text)
+ // Leaving an untouched blank draft for a different doc? Drop it silently
+ // (best-effort cleanup — a 404 just means it's already gone).
+ if (leaving && leaving.id !== id && leavingBlank) {
+ api.deleteDoc(leaving.id).catch(() => {})
+ setDocs((prev) => prev.filter((d) => d.id !== leaving.id))
+ }
},
- [saveNow],
+ [saveNow, isBlankDraft],
)
// Initial load: fetch the list, opening the first doc (or creating one).
@@ -90,6 +123,9 @@ export default function App() {
}, [openDoc])
const handleCreate = useCallback(async () => {
+ // Already sitting on a fresh blank draft? Reuse it instead of stacking
+ // another empty "Untitled" on top.
+ if (currentDocRef.current && isBlankDraft()) return
await saveNow()
const fresh = await api.createDoc()
setDocs((prev) => [
@@ -101,7 +137,7 @@ export default function App() {
setWordCount(0)
setTone(fresh.tone || 'general')
setDocText('')
- }, [saveNow])
+ }, [saveNow, isBlankDraft])
const handleDelete = useCallback(
async (id: string) => {
@@ -179,6 +215,21 @@ export default function App() {
[removeSuggestion],
)
+ // After restoring a version, swap the restored doc into the editor. Bumping
+ // editorEpoch remounts EditorCore so it picks up the restored content.
+ const handleRestored = useCallback(
+ (doc: Document) => {
+ setCurrentDoc(doc)
+ setTitle(doc.title)
+ setWordCount(doc.word_count)
+ setTone(doc.tone || 'general')
+ setDocText(doc.content_text)
+ patchSummary(doc.id, { title: doc.title, word_count: doc.word_count })
+ setEditorEpoch((n) => n + 1)
+ },
+ [patchSummary],
+ )
+
// Escape always restores the sidebar while in distraction-free mode.
useEffect(() => {
if (!focusMode) return
@@ -211,7 +262,7 @@ export default function App() {