1
0
mirror of https://github.com/osmarks/mycorrhiza.git synced 2025-02-07 14:40:16 +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. 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 are linked on the [[/recent-changes | recent changes page]].
These feeds have some customization options: ## 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. These feeds have options to combine related changes into groups:
* **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. * {
**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: 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" "github.com/gorilla/feeds"
) )
const changeGroupMaxSize = 100 const changeGroupMaxSize = 30
func recentChangesFeed(opts FeedOptions) *feeds.Feed { func recentChangesFeed(opts FeedOptions) *feeds.Feed {
feed := &feeds.Feed{ feed := &feeds.Feed{
@ -22,7 +22,7 @@ func recentChangesFeed(opts FeedOptions) *feeds.Feed {
Updated: time.Now(), Updated: time.Now(),
} }
revs := newRecentChangesStream() revs := newRecentChangesStream()
groups := opts.grouping.Group(revs) groups := groupRevisions(revs, opts)
for _, grp := range groups { for _, grp := range groups {
item := grp.feedItem(opts) item := grp.feedItem(opts)
feed.Add(&item) feed.Add(&item)
@ -56,6 +56,18 @@ func (grp *revisionGroup) addRevision(rev Revision) {
*grp = append(*grp, rev) *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) { func groupRevisionsByMonth(revs []Revision) (res []revisionGroup) {
var ( var (
currentYear int currentYear int
@ -73,24 +85,19 @@ func groupRevisionsByMonth(revs []Revision) (res []revisionGroup) {
return res return res
} }
// groupRevisionsByPeriodFromNow groups close-together revisions and returns the first changeGroupMaxSize (30) groups. // groupRevisions groups revisions for a feed.
// If two revisions happened within period of each other, they are put in the same group. // It returns the first changeGroupMaxSize (30) groups.
func groupRevisionsByPeriod(revs recentChangesStream, period time.Duration) (res []revisionGroup) { // The grouping parameter determines when two revisions will be grouped.
func groupRevisions(revs recentChangesStream, opts FeedOptions) (res []revisionGroup) {
nextRev := revs.iterator() nextRev := revs.iterator()
rev, empty := nextRev() rev, empty := nextRev()
if empty { if empty {
return res return res
} }
currTime := rev.Time
currGroup := newRevisionGroup(rev) currGroup := newRevisionGroup(rev)
for { for rev, done := nextRev(); !done; rev, done = nextRev() {
rev, done := nextRev() if opts.canGroup(currGroup, rev) {
if done {
return append(res, currGroup)
}
if currTime.Sub(rev.Time) < period && currGroup[0].Username == rev.Username {
currGroup.addRevision(rev) currGroup.addRevision(rev)
} else { } else {
res = append(res, currGroup) res = append(res, currGroup)
@ -99,118 +106,213 @@ func groupRevisionsByPeriod(revs recentChangesStream, period time.Duration) (res
} }
currGroup = newRevisionGroup(rev) 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 { func (grp revisionGroup) feedItem(opts FeedOptions) feeds.Item {
title, author := grp.titleAndAuthor(opts.order)
return feeds.Item{ return feeds.Item{
Title: grp.title(opts.groupOrder), Title: title,
// groups for feeds should have the same author for all revisions Author: author,
Author: &feeds.Author{Name: grp[0].Username},
Id: grp[len(grp)-1].Hash, Id: grp[len(grp)-1].Hash,
Description: grp.descriptionForFeed(opts.groupOrder), Description: grp.descriptionForFeed(opts.order),
Created: grp[len(grp)-1].Time, // earliest revision Created: grp[len(grp)-1].Time, // earliest revision
Updated: grp[0].Time, // latest revision Updated: grp[0].Time, // latest revision
Link: &feeds.Link{Href: cfg.URL + grp[0].bestLink()}, Link: &feeds.Link{Href: cfg.URL + grp[0].bestLink()},
} }
} }
func (grp revisionGroup) title(order FeedGroupOrder) string { // titleAndAuthor creates a title and author for a feed item.
var message string // If all messages and authors are the same (or there's just one rev), "message by author"
switch order { // If all authors are the same, "num edits (first message, ...) by author"
case NewToOld: // Else (even if all messages are the same), "num edits (first message, ...)"
message = grp[0].Message func (grp revisionGroup) titleAndAuthor(order feedGroupOrder) (title string, author *feeds.Author) {
case OldToNew: allMessagesSame := true
message = grp[len(grp)-1].Message 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 allMessagesSame && allAuthorsSame {
if len(grp) == 1 { title = grp[0].Message
return fmt.Sprintf("%s by %s", message, author)
} else { } 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{} builder := strings.Builder{}
switch order { for i := 0; i < len(grp); i++ {
case NewToOld: desc := grp.orderedIndex(i, order).descriptionForFeed()
for _, rev := range grp { builder.WriteString(desc)
builder.WriteString(rev.descriptionForFeed())
}
case OldToNew:
for i := len(grp) - 1; i >= 0; i-- {
builder.WriteString(grp[i].descriptionForFeed())
}
} }
return builder.String() 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 { type FeedOptions struct {
grouping FeedGrouping conds []groupingCondition
groupOrder FeedGroupOrder order feedGroupOrder
} }
func ParseFeedOptions(query url.Values) (FeedOptions, error) { func ParseFeedOptions(query url.Values) (FeedOptions, error) {
grouping, err := parseFeedGrouping(query) parser := feedOptionParserState{}
err := parser.parseFeedGroupingPeriod(query)
if err != nil { if err != nil {
return FeedOptions{}, err return FeedOptions{}, err
} }
groupOrder, err := parseFeedGroupOrder(query) err = parser.parseFeedGroupingSame(query)
if err != nil {
return FeedOptions{}, err
}
err = parser.parseFeedGroupingOrder(query)
if err != nil { if err != nil {
return FeedOptions{}, err return FeedOptions{}, err
} }
return FeedOptions{grouping, groupOrder}, nil
}
type FeedGrouping interface { var conds []groupingCondition
Group(recentChangesStream) []revisionGroup if parser.isAnythingSet {
} conds = parser.conds
func parseFeedGrouping(query url.Values) (FeedGrouping, error) {
if query.Get("period") == "" {
return NormalFeedGrouping{}, nil
} else { } 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")) period, err := time.ParseDuration(query.Get("period"))
if err != nil { 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{} type feedGroupOrder int
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
const ( const (
NewToOld FeedGroupOrder = iota newToOld feedGroupOrder = iota
OldToNew FeedGroupOrder = iota oldToNew feedGroupOrder = iota
) )
func parseFeedGroupOrder(query url.Values) (FeedGroupOrder, error) { func (parser *feedOptionParserState) parseFeedGroupingOrder(query url.Values) error {
if order := query["order"]; order != nil {
parser.isAnythingSet = true
switch query.Get("order") { switch query.Get("order") {
case "", "old-to-new": case "old-to-new":
return OldToNew, nil parser.order = oldToNew
case "new-to-old": case "new-to-old":
return NewToOld, nil parser.order = newToOld
default:
return errors.New("unknown order option " + query.Get("order"))
} }
return 0, errors.New("unknown 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)
} }