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 = `
" + 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 "" } } 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("" + t + ""
}
if n.hasMark("bold") {
t = "" + t + ""
}
if n.hasMark("italic") {
t = "" + t + ""
}
if n.hasMark("underline") {
t = "" + t + ""
}
if n.hasMark("strike") {
t = "