diff --git a/help/en/feeds.myco b/help/en/feeds.myco index 11890e9..204b2cf 100644 --- a/help/en/feeds.myco +++ b/help/en/feeds.myco @@ -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` \ No newline at end of file +* { + `/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. +} diff --git a/history/feed.go b/history/feed.go index b935848..18b7001 100644 --- a/history/feed.go +++ b/history/feed.go @@ -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) }