Editor: Find & Replace, read-aloud, backup, typography, phonetic, org niceties
Writer power-ups (Phase 11), plus the selection-bubble vs copy/paste fix. - Find & Replace (Ctrl/Cmd+F): SearchHighlight decoration extension + FindReplace bar (match-case, replace-all back-to-front, scroll without popping the selection bubble). - Read-aloud (Web Speech, offline) on the word card and selection bubble. - Keyboard/touch access to the ESL helpers: Ctrl/Cmd+D look up word at caret, Ctrl/Cmd+J rewrite selection, touch long-press lookup. Refactored the right-click handler into a shared openWordLookup(pos). - Whole-corpus backup: GET /api/docs/export-all zips every doc (md/docx), de-dupes filenames, dated name; sidebar download links. TestExportAll. - Smart typography input rules (curly quotes/em-dash/ellipsis), ASCII-only so CJK is untouched. - Duplicate doc, sidebar sort (Recent/Title/Longest), toolbar outline popover. - English phonetic (chosen over pinyin for an English learner): ECDICT-built phonetic.json.gz (46,579 words) + Result.Phonetic + WordCard IPA line; scripts/build_phonetic.py (full build + --seed fallback). - Selection bubble no longer blocks copy/paste: deferred to pointer-up and made click-through except on its buttons. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
@@ -9,15 +9,101 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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
|
||||
// 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 {
|
||||
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 {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
body, err := format.render(doc)
|
||||
if err != nil {
|
||||
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 {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
if _, err := f.Write(body); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user