Skip to content
quarry Artifact [af83644f07]
Login

Artifact [af83644f07]

Artifact af83644f0741c388b3d659431d2ff8d2e021bc7ab8425701c6bcff685dbfec1d:


package web

import (
	"encoding/xml"
	"net/http"
	"net/url"
	"strings"
	"time"

	"craftdesign.group/quarry/internal/ops"
)

// An Atom feed of a repository's timeline, so a visitor who cannot
// sign in can still follow a project (7a608b5b): no star or watch
// needs an account, a feed reader does not. It is the timeline page's
// data under the same op, so it shows what an anonymous reader of that
// page would see — a private repository answers a feed reader, which
// carries no session, the way it answers any stranger.

type atomFeed struct {
	XMLName xml.Name    `xml:"http://www.w3.org/2005/Atom feed"`
	ID      string      `xml:"id"`
	Title   string      `xml:"title"`
	Updated string      `xml:"updated"`
	Links   []atomLink  `xml:"link"`
	Entries []atomEntry `xml:"entry"`
}

type atomLink struct {
	Rel  string `xml:"rel,attr,omitempty"`
	Type string `xml:"type,attr,omitempty"`
	Href string `xml:"href,attr"`
}

type atomEntry struct {
	ID      string     `xml:"id"`
	Title   string     `xml:"title"`
	Updated string     `xml:"updated"`
	Author  atomAuthor `xml:"author"`
	Link    atomLink   `xml:"link"`
	Summary string     `xml:"summary,omitempty"`
}

type atomAuthor struct {
	Name string `xml:"name"`
}

// feedKinds names an event for a reader who sees it out of context,
// in a list mixed with every other feed they follow.
var feedKinds = map[string]string{
	"w": "Wiki", "t": "Ticket", "e": "Note", "f": "Forum",
	"l": "Landing", "lc": "Landing comment",
}

// feedFilters are the timeline filters a feed honours; the page's
// feed link carries whichever are set, so following a narrowed
// timeline follows that narrowing.
var feedFilters = []string{"branch", "tag", "user", "y"}

// feedHref is the feed of the timeline as filtered by q.
func feedHref(repo string, q url.Values) string {
	v := url.Values{}
	for _, k := range feedFilters {
		if s := q.Get(k); s != "" {
			v.Set(k, s)
		}
	}
	h := "/r/" + repo + "/timeline.atom"
	if len(v) > 0 {
		h += "?" + v.Encode()
	}
	return h
}

func timelineAtom(r *ops.Registry, w http.ResponseWriter, req *http.Request) {
	repo := req.PathValue("repo")
	q := url.Values{}
	for _, k := range feedFilters {
		if s := req.URL.Query().Get(k); s != "" {
			q.Set(k, s)
		}
	}
	// A bare feed follows everything, as fossil's timeline.rss does:
	// someone following a project wants its tickets and forum too.
	if q.Get("y") == "" {
		q.Set("y", "all")
	}
	vals, _ := timelineQuery(q, repo)
	out, err := invoke(req.Context(), r, "timeline.list", vals)
	if err != nil {
		fail(r, w, req, err)
		return
	}
	var tl struct {
		Entries []timelineEntry `json:"entries"`
	}
	remarshal(out, &tl)

	base := origin(req)
	page := base + "/r/" + repo + "/timeline"
	if len(q) > 0 {
		page += "?" + q.Encode()
	}
	title := repo + " timeline"
	for _, k := range []string{"branch", "tag", "user"} {
		if s := q.Get(k); s != "" {
			title += " · " + k + " " + s
		}
	}
	f := atomFeed{
		ID:    page,
		Title: title,
		Links: []atomLink{
			{Rel: "self", Type: "application/atom+xml", Href: base + feedHref(repo, q)},
			{Rel: "alternate", Type: "text/html", Href: page},
		},
	}
	for _, e := range tl.Entries {
		updated := feedTime(e.Date)
		if f.Updated == "" || updated > f.Updated {
			f.Updated = updated
		}
		head, _, _ := strings.Cut(strings.TrimSpace(e.Comment), "\n")
		head = feedTitle(e.Kind, head)
		summary := strings.TrimSpace(e.Comment)
		if e.Excerpt != "" {
			summary = strings.TrimSpace(e.Excerpt)
		}
		href := base + eventPath(repo, e)
		f.Entries = append(f.Entries, atomEntry{
			// Several landing posts share one page, so the id names
			// the event, not the link: a tag URI (RFC 4151) that stays
			// the same however the pages are later arranged.
			ID:      "tag:" + req.Host + ",2026:" + repo + "/" + e.Kind + "/" + e.Hash,
			Title:   head,
			Updated: updated,
			Author:  atomAuthor{Name: e.User},
			Link:    atomLink{Rel: "alternate", Type: "text/html", Href: href},
			Summary: summary,
		})
	}
	if f.Updated == "" {
		f.Updated = time.Now().UTC().Format(time.RFC3339)
	}
	w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")
	w.Header().Set("Cache-Control", "public, max-age=300")
	_, _ = w.Write([]byte(xml.Header))
	enc := xml.NewEncoder(w)
	enc.Indent("", " ")
	_ = enc.Encode(f)
}

// feedTime writes an artifact time as Atom requires (RFC 3339 with a
// zone). Artifact times are UTC without one.
func feedTime(s string) string {
	if t, ok := parseArtifactTime(s); ok {
		return t.UTC().Format(time.RFC3339)
	}
	return s
}

// feedTitle names an event's kind once. Stored comments for tickets
// and landings already begin with the word ("Ticket [bf5fbed5d6] …",
// "Landing accepted: …"), and a second label read "Ticket: Ticket".
func feedTitle(kind, head string) string {
	k := feedKinds[kind]
	if k == "" {
		return head
	}
	head = strings.TrimPrefix(head, "Technote: ")
	word, _, _ := strings.Cut(k, " ")
	if strings.HasPrefix(strings.ToLower(head), strings.ToLower(word)) {
		return head
	}
	return k + ": " + head
}