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..{id,area,kind,title,body,count,pos,del} and every menu // entry menu..{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., 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..), 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. 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 }