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
}