2021-10-23 02:00:44 +00:00
|
|
|
package history
|
|
|
|
|
|
|
|
import (
|
2021-10-25 21:51:36 +00:00
|
|
|
"errors"
|
2021-10-23 02:00:44 +00:00
|
|
|
"fmt"
|
2021-10-25 21:51:36 +00:00
|
|
|
"net/url"
|
2021-10-23 02:00:44 +00:00
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/bouncepaw/mycorrhiza/cfg"
|
|
|
|
|
|
|
|
"github.com/gorilla/feeds"
|
|
|
|
)
|
|
|
|
|
2021-10-28 00:21:54 +00:00
|
|
|
const changeGroupMaxSize = 30
|
2021-10-27 02:45:38 +00:00
|
|
|
|
2021-10-25 21:51:36 +00:00
|
|
|
func recentChangesFeed(opts FeedOptions) *feeds.Feed {
|
2021-10-23 02:00:44 +00:00
|
|
|
feed := &feeds.Feed{
|
2021-10-26 22:34:11 +00:00
|
|
|
Title: cfg.WikiName + " (recent changes)",
|
2021-10-23 02:00:44 +00:00
|
|
|
Link: &feeds.Link{Href: cfg.URL},
|
2021-10-27 02:45:38 +00:00
|
|
|
Description: fmt.Sprintf("List of %d recent changes on the wiki", changeGroupMaxSize),
|
2021-10-23 02:00:44 +00:00
|
|
|
Updated: time.Now(),
|
|
|
|
}
|
2021-10-27 02:45:38 +00:00
|
|
|
revs := newRecentChangesStream()
|
2021-10-28 00:21:54 +00:00
|
|
|
groups := groupRevisions(revs, opts)
|
2021-10-23 02:00:44 +00:00
|
|
|
for _, grp := range groups {
|
2021-10-25 21:51:36 +00:00
|
|
|
item := grp.feedItem(opts)
|
2021-10-23 02:00:44 +00:00
|
|
|
feed.Add(&item)
|
|
|
|
}
|
|
|
|
return feed
|
|
|
|
}
|
|
|
|
|
|
|
|
// RecentChangesRSS creates recent changes feed in RSS format.
|
2021-10-25 21:51:36 +00:00
|
|
|
func RecentChangesRSS(opts FeedOptions) (string, error) {
|
|
|
|
return recentChangesFeed(opts).ToRss()
|
2021-10-23 02:00:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// RecentChangesAtom creates recent changes feed in Atom format.
|
2021-10-25 21:51:36 +00:00
|
|
|
func RecentChangesAtom(opts FeedOptions) (string, error) {
|
|
|
|
return recentChangesFeed(opts).ToAtom()
|
2021-10-23 02:00:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// RecentChangesJSON creates recent changes feed in JSON format.
|
2021-10-25 21:51:36 +00:00
|
|
|
func RecentChangesJSON(opts FeedOptions) (string, error) {
|
|
|
|
return recentChangesFeed(opts).ToJSON()
|
|
|
|
}
|
|
|
|
|
2021-10-27 02:45:38 +00:00
|
|
|
// revisionGroup is a slice of revisions, ordered most recent first.
|
2021-10-25 21:51:36 +00:00
|
|
|
type revisionGroup []Revision
|
|
|
|
|
|
|
|
func newRevisionGroup(rev Revision) revisionGroup {
|
|
|
|
return revisionGroup([]Revision{rev})
|
|
|
|
}
|
|
|
|
|
|
|
|
func (grp *revisionGroup) addRevision(rev Revision) {
|
|
|
|
*grp = append(*grp, rev)
|
|
|
|
}
|
|
|
|
|
2021-10-28 00:21:54 +00:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2021-10-25 21:51:36 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2021-10-28 00:21:54 +00:00
|
|
|
// 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) {
|
2021-10-27 02:45:38 +00:00
|
|
|
nextRev := revs.iterator()
|
|
|
|
rev, empty := nextRev()
|
|
|
|
if empty {
|
2021-10-25 21:51:36 +00:00
|
|
|
return res
|
|
|
|
}
|
|
|
|
|
2021-10-27 02:45:38 +00:00
|
|
|
currGroup := newRevisionGroup(rev)
|
2021-10-28 00:21:54 +00:00
|
|
|
for rev, done := nextRev(); !done; rev, done = nextRev() {
|
|
|
|
if opts.canGroup(currGroup, rev) {
|
2021-10-26 22:34:11 +00:00
|
|
|
currGroup.addRevision(rev)
|
2021-10-25 21:51:36 +00:00
|
|
|
} else {
|
2021-10-26 22:34:11 +00:00
|
|
|
res = append(res, currGroup)
|
2021-10-27 02:45:38 +00:00
|
|
|
if len(res) == changeGroupMaxSize {
|
|
|
|
return res
|
|
|
|
}
|
2021-10-26 22:34:11 +00:00
|
|
|
currGroup = newRevisionGroup(rev)
|
2021-10-25 21:51:36 +00:00
|
|
|
}
|
|
|
|
}
|
2021-10-28 00:21:54 +00:00
|
|
|
// no more revisions, haven't added the last group yet
|
|
|
|
return append(res, currGroup)
|
2021-10-23 02:00:44 +00:00
|
|
|
}
|
|
|
|
|
2021-10-25 21:51:36 +00:00
|
|
|
func (grp revisionGroup) feedItem(opts FeedOptions) feeds.Item {
|
2021-10-28 00:21:54 +00:00
|
|
|
title, author := grp.titleAndAuthor(opts.order)
|
2021-10-23 02:00:44 +00:00
|
|
|
return feeds.Item{
|
2021-10-28 00:21:54 +00:00
|
|
|
Title: title,
|
|
|
|
Author: author,
|
2021-10-25 21:51:36 +00:00
|
|
|
Id: grp[len(grp)-1].Hash,
|
2021-10-28 00:21:54 +00:00
|
|
|
Description: grp.descriptionForFeed(opts.order),
|
2021-10-23 02:00:44 +00:00
|
|
|
Created: grp[len(grp)-1].Time, // earliest revision
|
|
|
|
Updated: grp[0].Time, // latest revision
|
|
|
|
Link: &feeds.Link{Href: cfg.URL + grp[0].bestLink()},
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-10-28 00:21:54 +00:00
|
|
|
// 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)
|
2021-10-25 21:51:36 +00:00
|
|
|
}
|
|
|
|
|
2021-10-28 00:21:54 +00:00
|
|
|
if allAuthorsSame {
|
|
|
|
title += fmt.Sprintf(" by %s", grp[0].Username)
|
|
|
|
author = &feeds.Author{Name: grp[0].Username}
|
2021-10-23 02:00:44 +00:00
|
|
|
} else {
|
2021-10-28 00:21:54 +00:00
|
|
|
author = nil
|
2021-10-23 02:00:44 +00:00
|
|
|
}
|
2021-10-28 00:21:54 +00:00
|
|
|
|
|
|
|
return title, author
|
2021-10-23 02:00:44 +00:00
|
|
|
}
|
|
|
|
|
2021-10-28 00:21:54 +00:00
|
|
|
func (grp revisionGroup) descriptionForFeed(order feedGroupOrder) string {
|
2021-10-23 02:00:44 +00:00
|
|
|
builder := strings.Builder{}
|
2021-10-28 00:21:54 +00:00
|
|
|
for i := 0; i < len(grp); i++ {
|
|
|
|
desc := grp.orderedIndex(i, order).descriptionForFeed()
|
|
|
|
builder.WriteString(desc)
|
2021-10-23 02:00:44 +00:00
|
|
|
}
|
|
|
|
return builder.String()
|
|
|
|
}
|
2021-10-25 21:51:36 +00:00
|
|
|
|
2021-10-28 00:21:54 +00:00
|
|
|
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.
|
2021-10-25 21:51:36 +00:00
|
|
|
type FeedOptions struct {
|
2021-10-28 00:21:54 +00:00
|
|
|
conds []groupingCondition
|
|
|
|
order feedGroupOrder
|
2021-10-25 21:51:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func ParseFeedOptions(query url.Values) (FeedOptions, error) {
|
2021-10-28 00:21:54 +00:00
|
|
|
parser := feedOptionParserState{}
|
|
|
|
|
|
|
|
err := parser.parseFeedGroupingPeriod(query)
|
2021-10-25 21:51:36 +00:00
|
|
|
if err != nil {
|
|
|
|
return FeedOptions{}, err
|
|
|
|
}
|
2021-10-28 00:21:54 +00:00
|
|
|
err = parser.parseFeedGroupingSame(query)
|
|
|
|
if err != nil {
|
|
|
|
return FeedOptions{}, err
|
|
|
|
}
|
|
|
|
err = parser.parseFeedGroupingOrder(query)
|
2021-10-25 21:51:36 +00:00
|
|
|
if err != nil {
|
|
|
|
return FeedOptions{}, err
|
|
|
|
}
|
|
|
|
|
2021-10-28 00:21:54 +00:00
|
|
|
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
|
2021-10-25 21:51:36 +00:00
|
|
|
}
|
|
|
|
|
2021-10-28 00:21:54 +00:00
|
|
|
func (parser *feedOptionParserState) parseFeedGroupingPeriod(query url.Values) error {
|
|
|
|
if query["period"] != nil {
|
|
|
|
parser.isAnythingSet = true
|
2021-10-25 21:51:36 +00:00
|
|
|
period, err := time.ParseDuration(query.Get("period"))
|
|
|
|
if err != nil {
|
2021-10-28 00:21:54 +00:00
|
|
|
return err
|
2021-10-25 21:51:36 +00:00
|
|
|
}
|
2021-10-28 00:21:54 +00:00
|
|
|
parser.conds = append(parser.conds, periodGroupingCondition{period})
|
2021-10-25 21:51:36 +00:00
|
|
|
}
|
2021-10-28 00:21:54 +00:00
|
|
|
return nil
|
2021-10-25 21:51:36 +00:00
|
|
|
}
|
|
|
|
|
2021-10-28 00:21:54 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
}
|
2021-10-25 21:51:36 +00:00
|
|
|
|
2021-10-28 00:21:54 +00:00
|
|
|
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
|
2021-10-25 21:51:36 +00:00
|
|
|
}
|
2021-10-28 00:21:54 +00:00
|
|
|
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
|
2021-10-25 21:51:36 +00:00
|
|
|
}
|
|
|
|
|
2021-10-28 00:21:54 +00:00
|
|
|
type groupingCondition interface {
|
|
|
|
canGroup(grp revisionGroup, rev Revision) bool
|
2021-10-25 21:51:36 +00:00
|
|
|
}
|
|
|
|
|
2021-10-28 00:21:54 +00:00
|
|
|
// periodGroupingCondition will group two revisions if they are within period of each other.
|
|
|
|
type periodGroupingCondition struct {
|
|
|
|
period time.Duration
|
2021-10-25 21:51:36 +00:00
|
|
|
}
|
|
|
|
|
2021-10-28 00:21:54 +00:00
|
|
|
func (cond periodGroupingCondition) canGroup(grp revisionGroup, rev Revision) bool {
|
|
|
|
return grp[len(grp)-1].Time.Sub(rev.Time) < cond.period
|
|
|
|
}
|
2021-10-25 21:51:36 +00:00
|
|
|
|
2021-10-28 00:21:54 +00:00
|
|
|
type sameGroupingCondition struct {
|
|
|
|
author bool
|
|
|
|
message bool
|
|
|
|
}
|
2021-10-25 21:51:36 +00:00
|
|
|
|
2021-10-28 00:21:54 +00:00
|
|
|
func (c sameGroupingCondition) canGroup(grp revisionGroup, rev Revision) bool {
|
|
|
|
return (!c.author || grp[0].Username == rev.Username) &&
|
|
|
|
(!c.message || grp[0].Message == rev.Message)
|
2021-10-25 21:51:36 +00:00
|
|
|
}
|