aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web/design_form.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/web/design_form.go')
-rw-r--r--internal/web/design_form.go228
1 files changed, 228 insertions, 0 deletions
diff --git a/internal/web/design_form.go b/internal/web/design_form.go
new file mode 100644
index 0000000..9edf8e3
--- /dev/null
+++ b/internal/web/design_form.go
@@ -0,0 +1,228 @@
+package web
+
+import (
+ "errors"
+ "net/url"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/gramanas/blogspace/internal/i18n"
+ "github.com/gramanas/blogspace/internal/store"
+)
+
+// The design form carries the whole layout as indexed fields: every module
+// row is mod.<i>.{id,area,kind,title,body,count,pos,del} and every menu
+// entry menu.<i>.{id,page,label,url,pos,del}. The index only tells rows
+// apart; the order is pos (renumbered from the page by the script, typed in
+// by hand without it). add.<area>, menu_add_page and menu_add_label/url are
+// the no-script way of adding: they are appended on save.
+
+const (
+ maxModuleBytes = 20_000
+ maxRecentCount = 20
+ maxMenuLabel = 60
+)
+
+// newModule is a module as it is first added: sensible headings, in the
+// blog's language, so it reads well before it is edited.
+func newModule(area, kind, blogLang string) store.Module {
+ m := store.Module{Area: area, Kind: kind, Count: 5}
+ switch kind {
+ case "archive":
+ m.Title = i18n.T(blogLang, "Archive")
+ case "recent":
+ m.Title = i18n.T(blogLang, "Recent posts")
+ case "sitemap":
+ m.Title = i18n.T(blogLang, "Pages")
+ case "tags":
+ m.Title = i18n.T(blogLang, "Tags")
+ m.Count = 10
+ case "tagcloud":
+ m.Title = i18n.T(blogLang, "Tag cloud")
+ }
+ return m
+}
+
+// formIndexes lists the row indexes present under a prefix ("mod." → the i
+// of every mod.<i>.<field>), in numeric order.
+func formIndexes(f url.Values, prefix string) []int {
+ seen := map[int]bool{}
+ for k := range f {
+ if !strings.HasPrefix(k, prefix) {
+ continue
+ }
+ rest := k[len(prefix):]
+ dot := strings.IndexByte(rest, '.')
+ if dot < 0 {
+ continue
+ }
+ if i, err := strconv.Atoi(rest[:dot]); err == nil && !seen[i] {
+ seen[i] = true
+ }
+ }
+ out := make([]int, 0, len(seen))
+ for i := range seen {
+ out = append(out, i)
+ }
+ sort.Ints(out)
+ return out
+}
+
+type formRow struct {
+ pos, idx int
+}
+
+// byPos orders rows by their pos field, the index breaking ties (so rows
+// without one keep the order they were rendered in).
+func byPos(rows []formRow) {
+ sort.SliceStable(rows, func(a, b int) bool {
+ if rows[a].pos != rows[b].pos {
+ return rows[a].pos < rows[b].pos
+ }
+ return rows[a].idx < rows[b].idx
+ })
+}
+
+// parseModules reads the module rows of the design form, in display order
+// area by area, and appends the add.<area> choices. lang is the viewer's,
+// for the messages; blogLang the blog's, for new headings. The modules come
+// back even with an error, so the form can be shown again as it was sent.
+func parseModules(f url.Values, lang, blogLang string) ([]store.Module, error) {
+ get := func(i int, k string) string { return f.Get("mod." + strconv.Itoa(i) + "." + k) }
+ var rows []formRow
+ for _, i := range formIndexes(f, "mod.") {
+ if get(i, "del") == "on" {
+ continue
+ }
+ pos, _ := strconv.Atoi(get(i, "pos"))
+ rows = append(rows, formRow{pos, i})
+ }
+ byPos(rows)
+ var mods []store.Module
+ var err error
+ fail := func(msg string) { // the first problem is the one reported
+ if err == nil {
+ err = errors.New(msg)
+ }
+ }
+ for _, r := range rows {
+ i := r.idx
+ m := store.Module{Area: get(i, "area"), Kind: get(i, "kind")}
+ m.ID, _ = strconv.ParseInt(get(i, "id"), 10, 64)
+ if !allowedKind(m.Area, m.Kind) { // not something the form offers: drop it
+ continue
+ }
+ m.Title = strings.TrimSpace(get(i, "title"))
+ m.Body = strings.ReplaceAll(get(i, "body"), "\r\n", "\n")
+ if n, err := strconv.Atoi(get(i, "count")); err == nil {
+ if m.Kind == "tags" { // 0 = all of them
+ m.Count = clampInt(n, 0, maxTagListCount)
+ } else {
+ m.Count = clampInt(n, 1, maxRecentCount)
+ }
+ }
+ switch {
+ case len(m.Title) > 120:
+ fail(i18n.T(lang, "Heading is too long (max 120 characters)."))
+ case len(m.Body) > maxModuleBytes:
+ fail(i18n.T(lang, "Content is too long (20 KB max)."))
+ }
+ mods = append(mods, m)
+ }
+ for _, area := range Areas {
+ if kind := f.Get("add." + area); kind != "" && allowedKind(area, kind) {
+ mods = append(mods, newModule(area, kind, blogLang))
+ }
+ }
+ // stable: the rows are already in order within each area
+ sort.SliceStable(mods, func(a, b int) bool { return areaIndex(mods[a].Area) < areaIndex(mods[b].Area) })
+ seen := map[string]bool{}
+ for _, m := range mods {
+ if singleModuleAreas[m.Area] && seen[m.Area] {
+ fail(i18n.T(lang, "Only one module fits before or after the posts; remove one first."))
+ }
+ seen[m.Area] = true
+ }
+ return mods, err
+}
+
+func areaIndex(area string) int {
+ for i, a := range Areas {
+ if a == area {
+ return i
+ }
+ }
+ return len(Areas)
+}
+
+// parseMenu reads the menu rows of the design form, in display order, and
+// appends the "add a page" / "add a link" fields. Page entries are filled in
+// from pages (title, slug, home) so the form can be shown again as sent —
+// which it is, with a bad link as a row to fix, when there is an error.
+func parseMenu(f url.Values, lang string, pages []store.Page) ([]store.MenuItem, error) {
+ byID := map[int64]store.Page{}
+ for _, p := range pages {
+ byID[p.ID] = p
+ }
+ get := func(i int, k string) string { return strings.TrimSpace(f.Get("menu." + strconv.Itoa(i) + "." + k)) }
+ var rows []formRow
+ for _, i := range formIndexes(f, "menu.") {
+ if get(i, "del") == "on" {
+ continue
+ }
+ pos, _ := strconv.Atoi(get(i, "pos"))
+ rows = append(rows, formRow{pos, i})
+ }
+ byPos(rows)
+ var menu []store.MenuItem
+ var err error
+ fail := func(msg string) { // the first problem is the one reported
+ if err == nil {
+ err = errors.New(msg)
+ }
+ }
+ inMenu := map[int64]bool{}
+ addPage := func(id int64) {
+ p, ok := byID[id]
+ if !ok || inMenu[id] { // deleted meanwhile, or listed twice: drop it
+ return
+ }
+ inMenu[id] = true
+ menu = append(menu, store.MenuItem{PageID: &p.ID, PageTitle: p.Title, PageSlug: p.Slug, IsHome: p.IsHome})
+ }
+ addLink := func(label, typed string) {
+ link := typed
+ if link != "" && !strings.HasPrefix(link, "/") && !strings.Contains(link, ":") {
+ link = "https://" + link // "example.org" is what people type
+ }
+ switch {
+ case label == "" || len(label) > maxMenuLabel:
+ fail(i18n.T(lang, "Give the link a short text (max 60 characters)."))
+ case !validLinkURL(link):
+ fail(i18n.T(lang, "That address does not look right. Use a full web address like https://example.org or a path like /about."))
+ link = typed // shown again as typed, to be fixed
+ }
+ menu = append(menu, store.MenuItem{Label: label, URL: link})
+ }
+ for _, r := range rows {
+ i := r.idx
+ id, _ := strconv.ParseInt(get(i, "id"), 10, 64)
+ n := len(menu)
+ if pageID, _ := strconv.ParseInt(get(i, "page"), 10, 64); pageID != 0 {
+ addPage(pageID)
+ } else {
+ addLink(get(i, "label"), get(i, "url"))
+ }
+ if len(menu) > n {
+ menu[n].ID = id
+ }
+ }
+ if pageID, _ := strconv.ParseInt(strings.TrimSpace(f.Get("menu_add_page")), 10, 64); pageID != 0 {
+ addPage(pageID)
+ }
+ if label, link := strings.TrimSpace(f.Get("menu_add_label")), strings.TrimSpace(f.Get("menu_add_url")); label != "" || link != "" {
+ addLink(label, link)
+ }
+ return menu, err
+}