Version history: new document_versions table (migration 0003) holding full-body snapshots that cascade with the doc. Throttled auto-snapshots on save (>=3min apart, max 40/doc, pruned), explicit manual restore points, and a pre_restore safety copy taken before each restore so restoring is itself undoable. Endpoints under /api/docs/:id/versions, all owner-scoped. Empties and bare renames never snapshot. Export: pure-Go Tiptap-JSON -> Markdown / HTML / plain-text / docx (no cgo/pandoc, single-binary intact), CJK-safe with RFC 5987 filenames. docx is a hand-built OOXML zip. PDF is handled client-side via the browser print dialog + an @media print stylesheet so CJK renders with the reader's own fonts. Frontend: ExportMenu (downloads + Print/PDF) and HistoryPanel (snapshot list, preview, restore) wired into the title row; bilingual zh-first to match chrome. Restore remounts the editor via editorEpoch. Stop saving empty docs: blank Untitled drafts now reuse-on-create and self-discard when navigated away from (refs avoid stale closures). Tests: versions_test.go, export_test.go (incl. valid-zip docx). go build/vet/test, tsc, vite build all clean; live end-to-end smoke verified snapshot/throttle/restore/export. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
136 lines
4.2 KiB
Go
136 lines
4.2 KiB
Go
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{"<!doctype html>", "<h2>My Section</h2>", "<strong>world</strong>", "你好", "<li>first</li>"} {
|
|
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", "你好", "<w:b/>", "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)
|
|
}
|
|
}
|