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/auth"
"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`,
auth.UserID(r.Context()),
)
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(auth.UserID(r.Context()), 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")
src := safeURL(n.attrStr("src"))
if src == "" {
// Nowhere safe to point. Keep the alt text as plain prose — it is
// the part that carries meaning — rather than emitting an image
// whose destination was rejected. See safeURL.
return alt
}
return fmt.Sprintf("", alt, 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 + ""
}
// Same rule as the HTML export: plenty of Markdown renderers pass a
// `javascript:` destination straight through into an . See safeURL.
if href := safeURL(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 + " " + alt + " " + htmlInline(n.Content) + "%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 "" + b.String() + "
\n"
case "codeBlock":
return "
\n"
case "horizontalRule":
return "" + htmlEscape(textContent(n)) + "
\n"
case "image":
alt := htmlEscape(n.attrStr("alt"))
src := safeURL(n.attrStr("src"))
if src == "" {
// Nowhere safe to point: keep the alt text, which is the part that
// carries meaning, rather than emitting a broken image.
if alt == "" {
return ""
}
return "
| , 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 = " |
|---|