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) }