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\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("\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() {
    🌸 @@ -219,7 +270,9 @@ export default function App() {
    -
    +
    - +
    + +
    + +
    + +
    -
    +
    + {historyOpen && currentDoc && ( + setHistoryOpen(false)} + onRestored={handleRestored} + /> + )} + {updateAvailable && } - +
    + +
    ) } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index de0ad46..2996d61 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -47,6 +47,23 @@ export interface WordInfo { export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice' +// A point-in-time snapshot of a document. List responses omit the heavy +// content/content_text fields; they arrive only on getVersion (preview/restore). +export interface DocumentVersion { + id: string + doc_id: string + title: string + content?: string + content_text?: string + word_count: number + kind: 'auto' | 'manual' | 'pre_restore' + created_at: string +} + +// Downloadable export formats. PDF is handled client-side via the browser's +// print dialog (CJK-safe, no server-side font embedding). +export type ExportFormat = 'md' | 'html' | 'txt' | 'docx' + // A single LLM-proposed edit. `original` is the source of truth for placement — // the editor re-anchors by matching this string in the live document (spec // Note #6); from_pos/to_pos are server-side advisory only. `replacement` is @@ -100,6 +117,23 @@ export const api = { dismissSuggestion: (id: string) => req(`/suggestions/${id}/dismiss`, { method: 'POST' }), + // Version history. listVersions returns metadata only (no bodies); getVersion + // loads one full snapshot for preview; snapshotDoc takes an explicit restore + // point; restoreVersion copies a snapshot back onto the live doc (capturing a + // pre_restore safety copy server-side first) and returns the restored doc. + listVersions: (id: string) => req(`/docs/${id}/versions`), + getVersion: (id: string, vid: string) => + req(`/docs/${id}/versions/${vid}`), + snapshotDoc: (id: string) => + req(`/docs/${id}/versions`, { method: 'POST' }), + restoreVersion: (id: string, vid: string) => + req(`/docs/${id}/versions/${vid}/restore`, { method: 'POST' }), + + // Download URL for an exported document (md/html/txt/docx). Used as an target so the browser handles the file save. + exportUrl: (id: string, format: ExportFormat) => + `/api/docs/${id}/export?format=${format}`, + // Offline word lookup (definition + synonyms) for the right-click popover. lookupWord: (word: string) => req(`/word/${encodeURIComponent(word)}`), diff --git a/web/src/components/Export/ExportMenu.tsx b/web/src/components/Export/ExportMenu.tsx new file mode 100644 index 0000000..d0944e4 --- /dev/null +++ b/web/src/components/Export/ExportMenu.tsx @@ -0,0 +1,120 @@ +import { useEffect, useRef, useState } from 'react' +import { api, type ExportFormat } from '../../api/client' + +// ExportMenu is the "get your writing out of Petal" dropdown. File formats are +// plain links to the server's export endpoint (which sets the +// Content-Disposition filename, CJK and all). "Print / Save as PDF" calls the +// browser print dialog against the print stylesheet, so PDF stays CJK-safe with +// no server-side font embedding. Bilingual zh·en labels match Petal's chrome. + +interface FormatOption { + format: ExportFormat + emoji: string + zh: string + en: string +} + +const FORMATS: FormatOption[] = [ + { format: 'docx', emoji: '📄', zh: 'Word 文档', en: 'Word (.docx)' }, + { format: 'md', emoji: '📝', zh: 'Markdown', en: 'Markdown (.md)' }, + { format: 'html', emoji: '🌐', zh: '网页', en: 'Web page (.html)' }, + { format: 'txt', emoji: '🧾', zh: '纯文本', en: 'Plain text (.txt)' }, +] + +interface Props { + docId: string +} + +export function ExportMenu({ docId }: Props) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + if (!open) return + const onDown = (e: MouseEvent) => { + if (!ref.current?.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', onDown) + return () => document.removeEventListener('mousedown', onDown) + }, [open]) + + return ( +
    + + + {open && ( +
    + {FORMATS.map((f) => ( + setOpen(false)} + className="flex w-full items-center gap-2 rounded-xl px-2.5 py-1.5 text-left text-sm font-semibold no-underline" + style={{ color: 'var(--color-plum)' }} + onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')} + onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')} + > + {f.emoji} + {f.zh} + + {f.en} + + + ))} + +
    + + +
    + )} +
    + ) +} diff --git a/web/src/components/History/HistoryPanel.tsx b/web/src/components/History/HistoryPanel.tsx new file mode 100644 index 0000000..f3cfc39 --- /dev/null +++ b/web/src/components/History/HistoryPanel.tsx @@ -0,0 +1,230 @@ +import { useCallback, useEffect, useState } from 'react' +import { api, type Document, type DocumentVersion } from '../../api/client' + +// HistoryPanel is the "time machine" drawer: every snapshot Petal kept of this +// document, newest first, with a one-click preview and restore. It's the safety +// net that makes trusting Petal with real writing reasonable — a bad edit or a +// regretted rewrite is always recoverable, and restoring is itself undoable +// (the server keeps a pre_restore copy). Bilingual, zh-first, to match chrome. + +interface Props { + docId: string + onClose: () => void + // Called after a successful restore with the freshly-restored document so the + // editor can reload it. + onRestored: (doc: Document) => void +} + +// kindLabel maps a snapshot kind to its bilingual badge + accent color. +const KIND: Record = { + manual: { zh: '保存点', en: 'Saved point', color: 'var(--color-accent)' }, + auto: { zh: '自动', en: 'Auto', color: 'var(--color-muted)' }, + pre_restore: { zh: '恢复前', en: 'Before restore', color: 'var(--color-lavender)' }, +} + +// relativeTime renders a UTC timestamp as a gentle "x minutes ago" string. +function relativeTime(iso: string): string { + const then = new Date(iso).getTime() + const secs = Math.max(0, Math.round((Date.now() - then) / 1000)) + if (secs < 60) return 'just now · 刚刚' + const mins = Math.round(secs / 60) + if (mins < 60) return `${mins} min ago · ${mins} 分钟前` + const hrs = Math.round(mins / 60) + if (hrs < 24) return `${hrs} hr ago · ${hrs} 小时前` + const days = Math.round(hrs / 24) + return `${days} day${days > 1 ? 's' : ''} ago · ${days} 天前` +} + +export function HistoryPanel({ docId, onClose, onRestored }: Props) { + const [versions, setVersions] = useState(null) + const [error, setError] = useState(false) + const [selected, setSelected] = useState(null) + const [preview, setPreview] = useState(null) + const [busy, setBusy] = useState(false) + + const load = useCallback(async () => { + setError(false) + try { + setVersions(await api.listVersions(docId)) + } catch { + setError(true) + } + }, [docId]) + + useEffect(() => { + void load() + }, [load]) + + // Escape closes the drawer. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose]) + + const choose = useCallback( + async (v: DocumentVersion) => { + setSelected(v) + setPreview(null) + try { + setPreview(await api.getVersion(docId, v.id)) + } catch { + setPreview(null) + } + }, + [docId], + ) + + const restore = useCallback(async () => { + if (!selected) return + setBusy(true) + try { + const doc = await api.restoreVersion(docId, selected.id) + onRestored(doc) + onClose() + } catch { + setBusy(false) + } + }, [docId, selected, onRestored, onClose]) + + return ( +
    + {/* Backdrop */} +
    + + +
    + ) +} diff --git a/web/src/index.css b/web/src/index.css index 1023046..a9037a2 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -269,3 +269,32 @@ button, a, input { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } } + +/* Print / Save-as-PDF: strip every bit of app chrome and editing decoration so + only the title and the writing itself reach the page. This is Petal's PDF + path — it uses the browser's own fonts, so CJK renders correctly with no + server-side font embedding. */ +@media print { + .petal-no-print, + .petal-sidebar, + .petal-suggestion-card, + .petal-misspell-card, + .petal-confetti, + .petal-companion { + display: none !important; + } + html, body, #root { + height: auto !important; + overflow: visible !important; + background: #fff !important; + } + /* Drop the in-editor highlight underlines/tints — they're guidance, not ink. */ + .petal-suggestion, + .petal-misspelling { + background: none !important; + text-decoration: none !important; + border-bottom: none !important; + } + /* Let the writing use the full page width. */ + .max-w-\[720px\] { max-width: none !important; } +}