mirror of
https://github.com/osmarks/mycorrhiza.git
synced 2024-12-12 13:30:26 +00:00
d8c82855ff
Feeds items used to only be grouped if they had the same author. Now this can be configured to require the same author, message, both, or neither.
319 lines
8.3 KiB
Go
319 lines
8.3 KiB
Go
package history
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/bouncepaw/mycorrhiza/cfg"
|
|
|
|
"github.com/gorilla/feeds"
|
|
)
|
|
|
|
const changeGroupMaxSize = 30
|
|
|
|
func recentChangesFeed(opts FeedOptions) *feeds.Feed {
|
|
feed := &feeds.Feed{
|
|
Title: cfg.WikiName + " (recent changes)",
|
|
Link: &feeds.Link{Href: cfg.URL},
|
|
Description: fmt.Sprintf("List of %d recent changes on the wiki", changeGroupMaxSize),
|
|
Updated: time.Now(),
|
|
}
|
|
revs := newRecentChangesStream()
|
|
groups := groupRevisions(revs, opts)
|
|
for _, grp := range groups {
|
|
item := grp.feedItem(opts)
|
|
feed.Add(&item)
|
|
}
|
|
return feed
|
|
}
|
|
|
|
// RecentChangesRSS creates recent changes feed in RSS format.
|
|
func RecentChangesRSS(opts FeedOptions) (string, error) {
|
|
return recentChangesFeed(opts).ToRss()
|
|
}
|
|
|
|
// RecentChangesAtom creates recent changes feed in Atom format.
|
|
func RecentChangesAtom(opts FeedOptions) (string, error) {
|
|
return recentChangesFeed(opts).ToAtom()
|
|
}
|
|
|
|
// RecentChangesJSON creates recent changes feed in JSON format.
|
|
func RecentChangesJSON(opts FeedOptions) (string, error) {
|
|
return recentChangesFeed(opts).ToJSON()
|
|
}
|
|
|
|
// revisionGroup is a slice of revisions, ordered most recent first.
|
|
type revisionGroup []Revision
|
|
|
|
func newRevisionGroup(rev Revision) revisionGroup {
|
|
return revisionGroup([]Revision{rev})
|
|
}
|
|
|
|
func (grp *revisionGroup) addRevision(rev Revision) {
|
|
*grp = append(*grp, rev)
|
|
}
|
|
|
|
// orderedIndex returns the ith revision in the group following the given order.
|
|
func (grp *revisionGroup) orderedIndex(i int, order feedGroupOrder) *Revision {
|
|
switch order {
|
|
case newToOld:
|
|
return &(*grp)[i]
|
|
case oldToNew:
|
|
return &(*grp)[len(*grp)-1-i]
|
|
}
|
|
// unreachable
|
|
return nil
|
|
}
|
|
|
|
func groupRevisionsByMonth(revs []Revision) (res []revisionGroup) {
|
|
var (
|
|
currentYear int
|
|
currentMonth time.Month
|
|
)
|
|
for _, rev := range revs {
|
|
if rev.Time.Month() != currentMonth || rev.Time.Year() != currentYear {
|
|
currentYear = rev.Time.Year()
|
|
currentMonth = rev.Time.Month()
|
|
res = append(res, newRevisionGroup(rev))
|
|
} else {
|
|
res[len(res)-1].addRevision(rev)
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
// groupRevisions groups revisions for a feed.
|
|
// It returns the first changeGroupMaxSize (30) groups.
|
|
// The grouping parameter determines when two revisions will be grouped.
|
|
func groupRevisions(revs recentChangesStream, opts FeedOptions) (res []revisionGroup) {
|
|
nextRev := revs.iterator()
|
|
rev, empty := nextRev()
|
|
if empty {
|
|
return res
|
|
}
|
|
|
|
currGroup := newRevisionGroup(rev)
|
|
for rev, done := nextRev(); !done; rev, done = nextRev() {
|
|
if opts.canGroup(currGroup, rev) {
|
|
currGroup.addRevision(rev)
|
|
} else {
|
|
res = append(res, currGroup)
|
|
if len(res) == changeGroupMaxSize {
|
|
return res
|
|
}
|
|
currGroup = newRevisionGroup(rev)
|
|
}
|
|
}
|
|
// no more revisions, haven't added the last group yet
|
|
return append(res, currGroup)
|
|
}
|
|
|
|
func (grp revisionGroup) feedItem(opts FeedOptions) feeds.Item {
|
|
title, author := grp.titleAndAuthor(opts.order)
|
|
return feeds.Item{
|
|
Title: title,
|
|
Author: author,
|
|
Id: grp[len(grp)-1].Hash,
|
|
Description: grp.descriptionForFeed(opts.order),
|
|
Created: grp[len(grp)-1].Time, // earliest revision
|
|
Updated: grp[0].Time, // latest revision
|
|
Link: &feeds.Link{Href: cfg.URL + grp[0].bestLink()},
|
|
}
|
|
}
|
|
|
|
// titleAndAuthor creates a title and author for a feed item.
|
|
// If all messages and authors are the same (or there's just one rev), "message by author"
|
|
// If all authors are the same, "num edits (first message, ...) by author"
|
|
// Else (even if all messages are the same), "num edits (first message, ...)"
|
|
func (grp revisionGroup) titleAndAuthor(order feedGroupOrder) (title string, author *feeds.Author) {
|
|
allMessagesSame := true
|
|
allAuthorsSame := true
|
|
for _, rev := range grp[1:] {
|
|
if rev.Message != grp[0].Message {
|
|
allMessagesSame = false
|
|
}
|
|
if rev.Username != grp[0].Username {
|
|
allAuthorsSame = false
|
|
}
|
|
if !allMessagesSame && !allAuthorsSame {
|
|
break
|
|
}
|
|
}
|
|
|
|
if allMessagesSame && allAuthorsSame {
|
|
title = grp[0].Message
|
|
} else {
|
|
title = fmt.Sprintf("%d edits (%s, ...)", len(grp), grp.orderedIndex(0, order).Message)
|
|
}
|
|
|
|
if allAuthorsSame {
|
|
title += fmt.Sprintf(" by %s", grp[0].Username)
|
|
author = &feeds.Author{Name: grp[0].Username}
|
|
} else {
|
|
author = nil
|
|
}
|
|
|
|
return title, author
|
|
}
|
|
|
|
func (grp revisionGroup) descriptionForFeed(order feedGroupOrder) string {
|
|
builder := strings.Builder{}
|
|
for i := 0; i < len(grp); i++ {
|
|
desc := grp.orderedIndex(i, order).descriptionForFeed()
|
|
builder.WriteString(desc)
|
|
}
|
|
return builder.String()
|
|
}
|
|
|
|
type feedOptionParserState struct {
|
|
isAnythingSet bool
|
|
conds []groupingCondition
|
|
order feedGroupOrder
|
|
}
|
|
|
|
// feedGrouping represents a set of conditions that must all be satisfied for revisions to be grouped.
|
|
// If there are no conditions, revisions will never be grouped.
|
|
type FeedOptions struct {
|
|
conds []groupingCondition
|
|
order feedGroupOrder
|
|
}
|
|
|
|
func ParseFeedOptions(query url.Values) (FeedOptions, error) {
|
|
parser := feedOptionParserState{}
|
|
|
|
err := parser.parseFeedGroupingPeriod(query)
|
|
if err != nil {
|
|
return FeedOptions{}, err
|
|
}
|
|
err = parser.parseFeedGroupingSame(query)
|
|
if err != nil {
|
|
return FeedOptions{}, err
|
|
}
|
|
err = parser.parseFeedGroupingOrder(query)
|
|
if err != nil {
|
|
return FeedOptions{}, err
|
|
}
|
|
|
|
var conds []groupingCondition
|
|
if parser.isAnythingSet {
|
|
conds = parser.conds
|
|
} else {
|
|
// if no options are applied, do no grouping instead of using the default options
|
|
conds = nil
|
|
}
|
|
return FeedOptions{conds: conds, order: parser.order}, nil
|
|
}
|
|
|
|
func (parser *feedOptionParserState) parseFeedGroupingPeriod(query url.Values) error {
|
|
if query["period"] != nil {
|
|
parser.isAnythingSet = true
|
|
period, err := time.ParseDuration(query.Get("period"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
parser.conds = append(parser.conds, periodGroupingCondition{period})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (parser *feedOptionParserState) parseFeedGroupingSame(query url.Values) error {
|
|
if same := query["same"]; same != nil {
|
|
parser.isAnythingSet = true
|
|
if len(same) == 1 && same[0] == "none" {
|
|
// same=none adds no condition
|
|
parser.conds = append(parser.conds, sameGroupingCondition{})
|
|
return nil
|
|
} else {
|
|
// handle same=author, same=author&same=message, etc.
|
|
cond := sameGroupingCondition{}
|
|
for _, sameCond := range same {
|
|
switch sameCond {
|
|
case "author":
|
|
if cond.author {
|
|
return errors.New("set same=author twice")
|
|
}
|
|
cond.author = true
|
|
case "message":
|
|
if cond.message {
|
|
return errors.New("set same=message twice")
|
|
}
|
|
cond.message = true
|
|
default:
|
|
return errors.New("unknown same option " + sameCond)
|
|
}
|
|
}
|
|
parser.conds = append(parser.conds, cond)
|
|
return nil
|
|
}
|
|
} else {
|
|
// same defaults to both author and message
|
|
// but this won't be applied if no grouping options are set
|
|
parser.conds = append(parser.conds, sameGroupingCondition{author: true, message: true})
|
|
return nil
|
|
}
|
|
}
|
|
|
|
type feedGroupOrder int
|
|
|
|
const (
|
|
newToOld feedGroupOrder = iota
|
|
oldToNew feedGroupOrder = iota
|
|
)
|
|
|
|
func (parser *feedOptionParserState) parseFeedGroupingOrder(query url.Values) error {
|
|
if order := query["order"]; order != nil {
|
|
parser.isAnythingSet = true
|
|
switch query.Get("order") {
|
|
case "old-to-new":
|
|
parser.order = oldToNew
|
|
case "new-to-old":
|
|
parser.order = newToOld
|
|
default:
|
|
return errors.New("unknown order option " + query.Get("order"))
|
|
}
|
|
} else {
|
|
parser.order = oldToNew
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// canGroup determines whether a revision can be added to a group.
|
|
func (opts FeedOptions) canGroup(grp revisionGroup, rev Revision) bool {
|
|
if len(opts.conds) == 0 {
|
|
return false
|
|
}
|
|
|
|
for _, cond := range opts.conds {
|
|
if !cond.canGroup(grp, rev) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
type groupingCondition interface {
|
|
canGroup(grp revisionGroup, rev Revision) bool
|
|
}
|
|
|
|
// periodGroupingCondition will group two revisions if they are within period of each other.
|
|
type periodGroupingCondition struct {
|
|
period time.Duration
|
|
}
|
|
|
|
func (cond periodGroupingCondition) canGroup(grp revisionGroup, rev Revision) bool {
|
|
return grp[len(grp)-1].Time.Sub(rev.Time) < cond.period
|
|
}
|
|
|
|
type sameGroupingCondition struct {
|
|
author bool
|
|
message bool
|
|
}
|
|
|
|
func (c sameGroupingCondition) canGroup(grp revisionGroup, rev Revision) bool {
|
|
return (!c.author || grp[0].Username == rev.Username) &&
|
|
(!c.message || grp[0].Message == rev.Message)
|
|
}
|