1
0
mirror of https://github.com/osmarks/mycorrhiza.git synced 2025-01-18 22:52:50 +00:00

Add the "same" option for feeds.

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.
This commit is contained in:
Elias Bomberger 2021-10-27 20:21:54 -04:00
parent a7701bea0a
commit d8c82855ff
2 changed files with 216 additions and 86 deletions

View File

@ -2,10 +2,38 @@
Mycorrhiza Wiki has RSS, Atom, and JSON feeds to track the latest changes on the wiki.
These feeds are linked on the [[/recent-changes | recent changes page]].
These feeds have some customization options:
* **period** If the period option is set to a length of time (5m, 24h, etc.), edits by the same author that happen within this time of each other will be grouped into one item in the feed.
* **order** Can be set to `old-to-now` (default) or `new-to-old`. This determines what order edits in groups will be shown in in your feed.
## Options
These feeds have options to combine related changes into groups:
* {
**period** Can be set to lengths of time like `5m`, `24h`, etc.
Edits by the same author that happen within this time of each other can be grouped into one item in the feed.
}
* {
**same** Can be set to `author`, `message`, or `none`.
Edits will only be grouped together if they have the same author or message. By default, edits need to have the same author and message. If it is `none`, all edits can be grouped.
}
* {
**order** Can be set to `old-to-now` (default) or `new-to-old`.
This determines what order edits in groups will be shown in in your feed.
}
If none of these options are set, changes will never be grouped.
## Examples
URLs for feeds using these options look like this:
* `/recent-changes-rss?period=1h`
* `/recent-changes-atom?period=1h&order=old-to-new`
* {
`/recent-changes-rss?period=1h`
Changes within one hour of each other with the same author and message will be grouped together.
}
* {
`/recent-changes-atom?period=1h&order=new-to-old`
Same as the last one, but the groups will be shown in the opposite order.
}
* {
`/recent-changes-atom?period=1h&same=none`
Changes within one hour of each other will be grouped together, even with different authors and messages.
}
* {
`/recent-changes-atom?same=author&same=message`
Changes with the same author and message will be grouped together no matter how much time passes between them.
}

View File

@ -12,7 +12,7 @@ import (
"github.com/gorilla/feeds"
)
const changeGroupMaxSize = 100
const changeGroupMaxSize = 30
func recentChangesFeed(opts FeedOptions) *feeds.Feed {
feed := &feeds.Feed{
@ -22,7 +22,7 @@ func recentChangesFeed(opts FeedOptions) *feeds.Feed {
Updated: time.Now(),
}
revs := newRecentChangesStream()
groups := opts.grouping.Group(revs)
groups := groupRevisions(revs, opts)
for _, grp := range groups {
item := grp.feedItem(opts)
feed.Add(&item)
@ -56,6 +56,18 @@ 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
@ -73,24 +85,19 @@ func groupRevisionsByMonth(revs []Revision) (res []revisionGroup) {
return res
}
// groupRevisionsByPeriodFromNow groups close-together revisions and returns the first changeGroupMaxSize (30) groups.
// If two revisions happened within period of each other, they are put in the same group.
func groupRevisionsByPeriod(revs recentChangesStream, period time.Duration) (res []revisionGroup) {
// 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
}
currTime := rev.Time
currGroup := newRevisionGroup(rev)
for {
rev, done := nextRev()
if done {
return append(res, currGroup)
}
if currTime.Sub(rev.Time) < period && currGroup[0].Username == rev.Username {
for rev, done := nextRev(); !done; rev, done = nextRev() {
if opts.canGroup(currGroup, rev) {
currGroup.addRevision(rev)
} else {
res = append(res, currGroup)
@ -99,118 +106,213 @@ func groupRevisionsByPeriod(revs recentChangesStream, period time.Duration) (res
}
currGroup = newRevisionGroup(rev)
}
currTime = rev.Time
}
// 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: grp.title(opts.groupOrder),
// groups for feeds should have the same author for all revisions
Author: &feeds.Author{Name: grp[0].Username},
Title: title,
Author: author,
Id: grp[len(grp)-1].Hash,
Description: grp.descriptionForFeed(opts.groupOrder),
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()},
}
}
func (grp revisionGroup) title(order FeedGroupOrder) string {
var message string
switch order {
case NewToOld:
message = grp[0].Message
case OldToNew:
message = grp[len(grp)-1].Message
// 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
}
}
author := grp[0].Username
if len(grp) == 1 {
return fmt.Sprintf("%s by %s", message, author)
if allMessagesSame && allAuthorsSame {
title = grp[0].Message
} else {
return fmt.Sprintf("%d edits by %s (%s, ...)", len(grp), author, message)
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 {
func (grp revisionGroup) descriptionForFeed(order feedGroupOrder) string {
builder := strings.Builder{}
switch order {
case NewToOld:
for _, rev := range grp {
builder.WriteString(rev.descriptionForFeed())
}
case OldToNew:
for i := len(grp) - 1; i >= 0; i-- {
builder.WriteString(grp[i].descriptionForFeed())
}
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 {
grouping FeedGrouping
groupOrder FeedGroupOrder
conds []groupingCondition
order feedGroupOrder
}
func ParseFeedOptions(query url.Values) (FeedOptions, error) {
grouping, err := parseFeedGrouping(query)
parser := feedOptionParserState{}
err := parser.parseFeedGroupingPeriod(query)
if err != nil {
return FeedOptions{}, err
}
groupOrder, err := parseFeedGroupOrder(query)
err = parser.parseFeedGroupingSame(query)
if err != nil {
return FeedOptions{}, err
}
err = parser.parseFeedGroupingOrder(query)
if err != nil {
return FeedOptions{}, err
}
return FeedOptions{grouping, groupOrder}, nil
}
type FeedGrouping interface {
Group(recentChangesStream) []revisionGroup
}
func parseFeedGrouping(query url.Values) (FeedGrouping, error) {
if query.Get("period") == "" {
return NormalFeedGrouping{}, nil
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 nil, err
return err
}
return PeriodFeedGrouping{Period: period}, nil
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 NormalFeedGrouping struct{}
func (NormalFeedGrouping) Group(revs recentChangesStream) (res []revisionGroup) {
for _, rev := range revs.next(changeGroupMaxSize) {
res = append(res, newRevisionGroup(rev))
}
return res
}
type PeriodFeedGrouping struct {
Period time.Duration
}
func (g PeriodFeedGrouping) Group(revs recentChangesStream) (res []revisionGroup) {
return groupRevisionsByPeriod(revs, g.Period)
}
type FeedGroupOrder int
type feedGroupOrder int
const (
NewToOld FeedGroupOrder = iota
OldToNew FeedGroupOrder = iota
newToOld feedGroupOrder = iota
oldToNew feedGroupOrder = iota
)
func parseFeedGroupOrder(query url.Values) (FeedGroupOrder, error) {
switch query.Get("order") {
case "", "old-to-new":
return OldToNew, nil
case "new-to-old":
return NewToOld, nil
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 0, errors.New("unknown order")
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)
}