package docs import ( "archive/zip" "bytes" "database/sql" "encoding/json" "errors" "fmt" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/internal/httputil" ) // exportRoutes registers the download endpoints: one document // (GET /api/docs/{id}/export?format=…) and a whole-corpus backup zip // (GET /api/docs/export-all?format=…). The static "export-all" segment takes // priority over the {id} param in chi's router, so the two don't collide. func (h *Handler) exportRoutes(r chi.Router) { r.Get("/{id}/export", h.export) r.Get("/export-all", h.exportAll) } // exportAll streams every document the user owns, each rendered in the requested // format, bundled into a single zip — a one-click "download all my writing" // backup. Per-doc version history guards against bad edits; this guards against // a lost disk. Reuses the same renderers as the single-document export. func (h *Handler) exportAll(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 } rows, err := h.DB.Query( `SELECT id, user_id, title, content, content_text, tone, word_count, created_at, updated_at FROM documents WHERE user_id = ? ORDER BY updated_at DESC`, db.LocalUserID, ) if err != nil { httputil.ServerError(w, err) return } defer rows.Close() var buf bytes.Buffer zw := zip.NewWriter(&buf) seen := map[string]int{} // de-duplicate filenames from same-titled docs for rows.Next() { var doc db.Document if err := rows.Scan( &doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText, &doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt, ); err != nil { httputil.ServerError(w, err) return } body, err := format.render(doc) if err != nil { httputil.ServerError(w, err) return } base := sanitizeFilename(doc.Title) if base == "" { base = "untitled" } key := base + "." + format.ext name := key if c := seen[key]; c > 0 { name = fmt.Sprintf("%s (%d).%s", base, c, format.ext) } seen[key]++ f, err := zw.Create(name) if err != nil { httputil.ServerError(w, err) return } if _, err := f.Write(body); err != nil { httputil.ServerError(w, err) return } } if err := rows.Err(); err != nil { httputil.ServerError(w, err) return } if err := zw.Close(); err != nil { httputil.ServerError(w, err) return } filename := "petal-backup-" + time.Now().Format("2006-01-02") + ".zip" w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", urlEscapeFilename(filename))) w.Header().Set("Content-Length", fmt.Sprintf("%d", buf.Len())) _, _ = w.Write(buf.Bytes()) } // 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 { httputil.ServerError(w, err) return } body, err := format.render(doc) if err != nil { httputil.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"` Attrs map[string]any `json:"attrs"` } // 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 } // attrStr returns a string-valued attribute (e.g. an image src), or "". func (n pmNode) attrStr(key string) string { if n.Attrs == nil { return "" } if s, ok := n.Attrs[key].(string); ok { return s } return "" } // markAttr returns a string attribute from the named mark (e.g. a link href). func (n pmNode) markAttr(markType, key string) string { for _, m := range n.Marks { if m.Type == markType && m.Attrs != nil { if s, ok := m.Attrs[key].(string); ok { return s } } } return "" } 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 "image": alt := n.attrStr("alt") return fmt.Sprintf("", alt, n.attrStr("src")) case "table": return mdTable(n) 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() } // mdTable renders a table node as a GitHub-flavored Markdown table. The first // row is used as the header (Markdown tables require one); a separator row is // inserted after it. Cell text is flattened to inline Markdown. func mdTable(n pmNode) string { var rows [][]string for _, row := range n.Content { if row.Type != "tableRow" { continue } var cells []string for _, cell := range row.Content { // A cell holds block children (usually one paragraph); flatten them. var parts []string for _, c := range cell.Content { parts = append(parts, mdInline(c.Content)) } // Escape pipes so cell content doesn't break the column layout. cells = append(cells, strings.ReplaceAll(strings.TrimSpace(strings.Join(parts, " ")), "|", "\\|")) } rows = append(rows, cells) } if len(rows) == 0 { return "" } cols := 0 for _, r := range rows { if len(r) > cols { cols = len(r) } } pad := func(r []string) string { for len(r) < cols { r = append(r, "") } return "| " + strings.Join(r, " | ") + " |" } var lines []string lines = append(lines, pad(rows[0])) sep := make([]string, cols) for i := range sep { sep[i] = "---" } lines = append(lines, "| "+strings.Join(sep, " | ")+" |") for _, r := range rows[1:] { lines = append(lines, pad(r)) } return strings.Join(lines, "\n") } 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 + "" } if href := n.markAttr("link", "href"); href != "" { t = "[" + t + "](" + href + ")" } 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 = `
" + 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 "" + htmlInline(n.Content) + "
\n" } return "" } } // htmlTable renders a table node as an HTML| , tableCell cells become | ; each cell's block children are flattened
// to inline HTML.
func htmlTable(n pmNode) string {
var b strings.Builder
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 = " |
|---|