Files

428 lines
14 KiB
Go

package editor
import (
"bytes"
"fmt"
"net/http"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"gopkg.in/yaml.v3"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/preview"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/site"
)
// Quick posts: the things an operator needs up in minutes. A banner or
// alert across the top of the site, a news post, an event, the words on the
// front page. Each is one form and one button. Posting it makes the same
// commit on a draft, the same review and the same checks as any other
// change, then publishes it straight away if the platform lets this person
// merge. If it doesn't, the review is left open for someone who can.
// QuickInfo is what the quick post forms need to know about a site.
type QuickInfo struct {
Timezone string `json:"timezone"`
Now string `json:"now"` // in the site's time zone: 2026-11-07 18:00
Banners []QuickBanner `json:"banners"`
Collections []CollectionInfo `json:"collections"` // where a news post can go
Calendars []CollectionInfo `json:"calendars"` // where an event can go
Commit string `json:"commit"`
}
// QuickBanner is a banner as the form shows it.
type QuickBanner struct {
site.Banner
Showing bool `json:"showing"`
Expired bool `json:"expired"`
}
func (s *Server) apiQuick(w http.ResponseWriter, r *http.Request, sess *Session, f *ForgeConfig, id string) {
_, _, siteDir, commit, ok := s.siteFor(w, r, sess, f, id)
if !ok {
return
}
cfg, err := site.LoadConfig(siteDir)
if err != nil {
apiError(w, http.StatusBadGateway, "Couldn't read site.yaml: "+trimErr(err))
return
}
loc, err := cfg.Location()
if err != nil {
apiError(w, http.StatusBadGateway, err.Error())
return
}
now := time.Now().In(loc)
info := QuickInfo{Timezone: loc.String(), Now: now.Format("2006-01-02 15:04"), Banners: []QuickBanner{}, Collections: []CollectionInfo{}, Calendars: []CollectionInfo{}, Commit: commit}
banners, err := site.LoadBanners(siteDir, loc)
if err != nil {
apiError(w, http.StatusBadGateway, err.Error())
return
}
for _, b := range banners {
info.Banners = append(info.Banners, QuickBanner{Banner: b, Showing: b.Active(now), Expired: b.Expired(now)})
}
for _, name := range collectionNames(siteDir, cfg) {
ci := CollectionInfo{Name: name, Title: titleOf(name)}
if cfg.Collections[name].Calendar {
info.Calendars = append(info.Calendars, ci)
} else {
info.Collections = append(info.Collections, ci)
}
}
writeJSON(w, http.StatusOK, info)
}
// collectionNames lists the site's collections: folders in content/, and
// any configured in site.yaml.
func collectionNames(siteDir string, cfg *site.Config) []string {
seen := map[string]bool{}
entries, _ := os.ReadDir(filepath.Join(siteDir, "content"))
for _, e := range entries {
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
seen[e.Name()] = true
}
}
for name := range cfg.Collections {
seen[name] = true
}
var out []string
for name := range seen {
if slug(name) == name {
out = append(out, name)
}
}
sort.Strings(out)
return out
}
func titleOf(name string) string {
if name == "" {
return ""
}
return strings.ToUpper(name[:1]) + strings.ReplaceAll(name[1:], "-", " ")
}
type quickRequest struct {
Kind string `json:"kind"` // banner, banner-remove, post, event, home
Publish bool `json:"publish"` // publish now if allowed, rather than leave it for review
// banner
Text string `json:"text"`
Link string `json:"link"`
LinkText string `json:"linkText"`
Tone string `json:"tone"`
Show string `json:"show"`
From string `json:"from"`
Until string `json:"until"`
Index int `json:"index"` // banner-remove: which, with Text to be sure
// post, event
Collection string `json:"collection"`
Title string `json:"title"`
Summary string `json:"summary"`
Body string `json:"body"`
Date string `json:"date"` // event: 2026-11-07
Start string `json:"start"` // event: 18:00, or empty for all day
End string `json:"end"` // event: 20:00
EndDate string `json:"endDate"` // event: the last day, if it runs over several
Location string `json:"location"`
EventLink string `json:"eventLink"`
// home
Edit *Edit `json:"edit"`
BaseCommit string `json:"baseCommit"`
}
var (
dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
clockRe = regexp.MustCompile(`^\d{2}:\d{2}$`)
// dateTimeRe: 2026-11-07 or 2026-11-07 18:00, safe to write unquoted.
dateTimeRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}( \d{2}:\d{2})?$`)
)
func (s *Server) quickPost(w http.ResponseWriter, r *http.Request, sess *Session, f *ForgeConfig, st *SiteConfig) {
var req quickRequest
if !decode(w, r, &req) {
return
}
_, siteDir, base, err := s.ws.checkout(sess, f.Kind, st, s.cloneURL(st), st.Branch)
if siteDir == "" {
s.writeError(w, r, err)
return
}
cfg, err := site.LoadConfig(siteDir)
if err != nil {
apiError(w, http.StatusBadGateway, "Couldn't read site.yaml: "+trimErr(err))
return
}
loc, err := cfg.Location()
if err != nil {
apiError(w, http.StatusBadGateway, err.Error())
return
}
now := time.Now().In(loc)
var source, title string
var content []byte
switch req.Kind {
case "banner", "banner-remove":
source, title = site.BannersFile, "Banner: "+oneLine(req.Text, 60)
if req.Kind == "banner-remove" {
title = "Take down banner: " + oneLine(req.Text, 50)
}
content, err = s.quickBanners(siteDir, loc, now, &req)
case "post":
title = strings.TrimSpace(req.Title)
source, content, err = quickPage(siteDir, cfg, req.Collection, req.Title, false, func() ([][2]any, error) {
fm := [][2]any{{"title", strings.TrimSpace(req.Title)}, {"date", now.Format("2006-01-02")}}
if s := strings.TrimSpace(req.Summary); s != "" {
fm = append(fm, [2]any{"summary", s})
}
return fm, nil
}, req.Body)
case "event":
title = "Event: " + strings.TrimSpace(req.Title)
source, content, err = quickPage(siteDir, cfg, req.Collection, req.Title, true, func() ([][2]any, error) { return eventFields(&req, loc) }, req.Body)
case "home":
source, title = "content/index.md", "Front page"
if req.Edit == nil {
err = fmt.Errorf("nothing to change")
break
}
var original []byte
if original, err = readSource(siteDir, source); err == nil {
content, err = compose(original, *req.Edit)
}
if req.BaseCommit != "" {
base = req.BaseCommit
}
default:
err = fmt.Errorf("no such kind of quick post")
}
if err != nil {
apiError(w, http.StatusBadRequest, err.Error())
return
}
target := s.draftName(r, sess, f, st, source)
commit, err := s.ws.save(sess, f.Kind, st, s.cloneURL(st), st.Branch, target, true, base, map[string][]byte{repoFile(st, source): content}, title)
if err != nil {
s.writeError(w, r, err)
return
}
s.log.Printf("%s quick post (%s) %s on %s@%s (%s)", sess.Login, req.Kind, source, st.ID, target, shortSHA(commit))
out := map[string]any{"branch": target, "commit": commit, "source": source, "preview": st.Preview.url(preview.Slug(target))}
_, draftDir, draftCommit, err := s.ws.checkout(sess, f.Kind, st, s.cloneURL(st), target)
if err != nil {
s.writeError(w, r, err)
return
}
res := s.ws.buildAndCheck(draftDir, draftCommit)
rv, err := s.openReview(r.Context(), f, sess.Token, st, target, "Quick post: "+title, reviewBody(st, target, res, "", sess.Login))
if err != nil {
out["message"] = "Saved on a draft, " + target + ", but opening its review didn't work: " + trimErr(err)
writeJSON(w, http.StatusOK, out)
return
}
out["review"] = map[string]any{"number": rv.Number, "url": rv.URL}
switch {
case res.Failed != "" || res.Errors > 0:
out["problems"] = res.Problems
out["message"] = fmt.Sprintf("Not published: the checks found %d error(s). Review #%d is open; fix them there or in the editor.", res.Errors, rv.Number)
if res.Failed != "" {
out["message"] = "Not published: the site doesn't build with this change. " + res.Failed
}
case !req.Publish:
out["message"] = fmt.Sprintf("Sent for review: #%d.", rv.Number)
default:
if err := s.merge(r.Context(), f, sess.Token, st, rv.Number); err != nil {
out["message"] = fmt.Sprintf("Review #%d is open, but it couldn't be published from here (%s). Someone who can publish on this site can merge it.", rv.Number, trimErr(err))
break
}
s.log.Printf("%s published quick post %s@%s (review #%d)", sess.Login, st.ID, target, rv.Number)
out["published"] = true
out["message"] = "Posted. The live site updates when its publishing pipeline runs."
}
writeJSON(w, http.StatusOK, out)
}
// quickBanners writes data/banners.yaml with the change: a banner added, or
// one taken down. Banners that are over are tidied away at the same time.
func (s *Server) quickBanners(siteDir string, loc *time.Location, now time.Time, req *quickRequest) ([]byte, error) {
existing, err := site.LoadBanners(siteDir, loc)
if err != nil {
return nil, err
}
var keep []site.Banner
for i, b := range existing {
if req.Kind == "banner-remove" && i == req.Index {
if b.Text != strings.TrimSpace(req.Text) {
return nil, fmt.Errorf("the banners changed since this page loaded; reload and try again")
}
continue
}
if !b.Expired(now) {
keep = append(keep, b)
}
}
if req.Kind == "banner-remove" && len(keep) == len(existing) {
return nil, fmt.Errorf("that banner isn't there any more")
}
if req.Kind == "banner" {
b := site.Banner{Text: req.Text, Link: strings.TrimSpace(req.Link), LinkText: strings.TrimSpace(req.LinkText), Tone: req.Tone, Show: req.Show, From: strings.TrimSpace(req.From), Until: strings.TrimSpace(req.Until)}
if b.Tone == "info" {
b.Tone = ""
}
if b.Show == "all" {
b.Show = ""
}
if b.Link == "" {
b.LinkText = ""
}
if err := b.Resolve(loc); err != nil {
return nil, err
}
if !b.End.IsZero() && !b.End.After(now) {
return nil, fmt.Errorf("until is already past")
}
keep = append(keep, b)
}
var buf bytes.Buffer
buf.WriteString("# Notices across the top of the site, newest last. The editor's quick\n# posts write this file; times are in the site's timezone.\n")
if len(keep) == 0 {
buf.WriteString("[]\n")
return buf.Bytes(), nil
}
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err := enc.Encode(keep); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// quickPage writes a new page in a collection, with its front matter in the
// order given.
func quickPage(siteDir string, cfg *site.Config, collection, title string, calendar bool, fields func() ([][2]any, error), body string) (string, []byte, error) {
title = strings.TrimSpace(title)
if title == "" || slug(title) == "" {
return "", nil, fmt.Errorf("give it a title with some letters or numbers in it")
}
if collection == "" || slug(collection) != collection {
return "", nil, fmt.Errorf("choose where it goes")
}
known := false
for _, n := range collectionNames(siteDir, cfg) {
known = known || n == collection
}
if !known || cfg.Collections[collection].Calendar != calendar {
return "", nil, fmt.Errorf("no such place for it: %s", collection)
}
fm, err := fields()
if err != nil {
return "", nil, err
}
name := slug(title)
if len(name) > 60 {
name = strings.Trim(name[:60], "-")
}
source := path.Join("content", collection, name+".md")
for i := 2; ; i++ {
if _, err := os.Stat(filepath.Join(siteDir, filepath.FromSlash(source))); err != nil {
break
}
source = path.Join("content", collection, fmt.Sprintf("%s-%d.md", name, i))
}
doc := &yaml.Node{Kind: yaml.MappingNode}
for _, kv := range fm {
var k, v yaml.Node
k.SetString(kv[0].(string))
if str, ok := kv[1].(string); ok && dateTimeRe.MatchString(str) {
// Dates and times written plainly, as people write them by hand.
v = yaml.Node{Kind: yaml.ScalarNode, Value: str}
} else if err := v.Encode(kv[1]); err != nil {
return "", nil, err
}
doc.Content = append(doc.Content, &k, &v)
}
var buf bytes.Buffer
buf.WriteString("---\n")
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err := enc.Encode(doc); err != nil {
return "", nil, err
}
buf.WriteString("---\n")
if b := strings.TrimSpace(strings.ReplaceAll(body, "\r\n", "\n")); b != "" {
buf.WriteString(b + "\n")
}
return source, buf.Bytes(), nil
}
// eventFields turns the event form into front matter, checking it the way
// the build will.
func eventFields(req *quickRequest, loc *time.Location) ([][2]any, error) {
if !dateRe.MatchString(req.Date) {
return nil, fmt.Errorf("give the event a date")
}
start, end := req.Date, ""
switch {
case req.Start != "":
if !clockRe.MatchString(req.Start) || (req.End != "" && !clockRe.MatchString(req.End)) {
return nil, fmt.Errorf("times should be like 18:00")
}
start = req.Date + " " + req.Start
if req.End != "" {
endDay := req.Date
if req.EndDate != "" {
endDay = req.EndDate
}
end = endDay + " " + req.End
}
case req.EndDate != "" && req.EndDate != req.Date:
end = req.EndDate
}
if req.EndDate != "" && !dateRe.MatchString(req.EndDate) {
return nil, fmt.Errorf("the last day should be a date")
}
fm := [][2]any{{"title", strings.TrimSpace(req.Title)}}
if s := strings.TrimSpace(req.Summary); s != "" {
fm = append(fm, [2]any{"summary", s})
}
fm = append(fm, [2]any{"start", start})
if end != "" {
fm = append(fm, [2]any{"end", end})
}
if l := strings.TrimSpace(req.Location); l != "" {
fm = append(fm, [2]any{"location", l})
}
if l := strings.TrimSpace(req.EventLink); l != "" {
fm = append(fm, [2]any{"link", l})
}
// Check it as the build will, so a mistake shows here, not as a failed
// publish.
params := map[string]any{}
for _, kv := range fm {
params[kv[0].(string)] = kv[1]
}
if err := site.CheckEvent(params, loc); err != nil {
return nil, err
}
return fm, nil
}
func oneLine(s string, n int) string {
s = strings.Join(strings.Fields(s), " ")
if r := []rune(s); len(r) > n {
return string(r[:n-1]) + "…"
}
return s
}