diff --git a/help/en/feeds.myco b/help/en/feeds.myco
new file mode 100644
index 0000000..204b2cf
--- /dev/null
+++ b/help/en/feeds.myco
@@ -0,0 +1,39 @@
+# Help: Feeds
+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]].
+
+## 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`
+ 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
new file mode 100644
index 0000000..18b7001
--- /dev/null
+++ b/history/feed.go
@@ -0,0 +1,318 @@
+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)
+}
diff --git a/history/history.go b/history/history.go
index 37ce429..559d9d5 100644
--- a/history/history.go
+++ b/history/history.go
@@ -4,14 +4,10 @@ package history
import (
"bytes"
"fmt"
- "html"
"log"
"os/exec"
"path/filepath"
"regexp"
- "strconv"
- "strings"
- "time"
"github.com/bouncepaw/mycorrhiza/files"
"github.com/bouncepaw/mycorrhiza/util"
@@ -54,131 +50,6 @@ func InitGitRepo() {
}
}
-// Revision represents a revision, duh. Hash is usually short. Username is extracted from email.
-type Revision struct {
- Hash string
- Username string
- Time time.Time
- Message string
- filesAffectedBuf []string
- hyphaeAffectedBuf []string
-}
-
-// filesAffected tells what files have been affected by the revision.
-func (rev *Revision) filesAffected() (filenames []string) {
- if nil != rev.filesAffectedBuf {
- return rev.filesAffectedBuf
- }
- // List of files affected by this revision, one per line.
- out, err := silentGitsh("diff-tree", "--no-commit-id", "--name-only", "-r", rev.Hash)
- // There's an error? Well, whatever, let's just assign an empty slice, who cares.
- if err != nil {
- rev.filesAffectedBuf = []string{}
- } else {
- rev.filesAffectedBuf = strings.Split(out.String(), "\n")
- }
- return rev.filesAffectedBuf
-}
-
-// determine what hyphae were affected by this revision
-func (rev *Revision) hyphaeAffected() (hyphae []string) {
- if nil != rev.hyphaeAffectedBuf {
- return rev.hyphaeAffectedBuf
- }
- hyphae = make([]string, 0)
- var (
- // set is used to determine if a certain hypha has been already noted (hyphae are stored in 2 files at most currently).
- set = make(map[string]bool)
- isNewName = func(hyphaName string) bool {
- if _, present := set[hyphaName]; present {
- return false
- }
- set[hyphaName] = true
- return true
- }
- filesAffected = rev.filesAffected()
- )
- for _, filename := range filesAffected {
- if strings.IndexRune(filename, '.') >= 0 {
- dotPos := strings.LastIndexByte(filename, '.')
- hyphaName := string([]byte(filename)[0:dotPos]) // is it safe?
- if isNewName(hyphaName) {
- hyphae = append(hyphae, hyphaName)
- }
- }
- }
- rev.hyphaeAffectedBuf = hyphae
- return hyphae
-}
-
-// TimeString returns a human readable time representation.
-func (rev Revision) TimeString() string {
- return rev.Time.Format(time.RFC822)
-}
-
-// HyphaeLinksHTML returns a comma-separated list of hyphae that were affected by this revision as HTML string.
-func (rev Revision) HyphaeLinksHTML() (html string) {
- hyphae := rev.hyphaeAffected()
- for i, hyphaName := range hyphae {
- if i > 0 {
- html += `, `
- }
- html += fmt.Sprintf(`%[1]s `, hyphaName)
- }
- return html
-}
-
-// descriptionForFeed generates a good enough HTML contents for a web feed.
-func (rev *Revision) descriptionForFeed() (htmlDesc string) {
- return fmt.Sprintf(
- `
%s
-Hyphae affected: %s
-%s `, rev.Message, rev.HyphaeLinksHTML(), html.EscapeString(rev.textDiff()))
-}
-
-// textDiff generates a good enough diff to display in a web feed. It is not html-escaped.
-func (rev *Revision) textDiff() (diff string) {
- filenames, ok := rev.mycoFiles()
- if !ok {
- return "No text changes"
- }
- for _, filename := range filenames {
- text, err := PrimitiveDiffAtRevision(filename, rev.Hash)
- if err != nil {
- diff += "\nAn error has occurred with " + filename + "\n"
- }
- diff += text + "\n"
- }
- return diff
-}
-
-// mycoFiles returns filenames of .myco file. It is not ok if there are no myco files.
-func (rev *Revision) mycoFiles() (filenames []string, ok bool) {
- filenames = []string{}
- for _, filename := range rev.filesAffected() {
- if strings.HasSuffix(filename, ".myco") {
- filenames = append(filenames, filename)
- }
- }
- return filenames, len(filenames) > 0
-}
-
-// Try and guess what link is the most important by looking at the message.
-func (rev *Revision) bestLink() string {
- var (
- revs = rev.hyphaeAffected()
- renameRes = renameMsgPattern.FindStringSubmatch(rev.Message)
- )
- switch {
- case renameRes != nil:
- return "/hypha/" + renameRes[1]
- case len(revs) == 0:
- return ""
- default:
- return "/hypha/" + revs[0]
- }
-}
-
// I pronounce it as [gɪt͡ʃ].
// gitsh is async-safe, therefore all other git-related functions in this module are too.
func gitsh(args ...string) (out bytes.Buffer, err error) {
@@ -204,16 +75,6 @@ func silentGitsh(args ...string) (out bytes.Buffer, err error) {
return *bytes.NewBuffer(b), err
}
-// Convert a UNIX timestamp as string into a time. If nil is returned, it means that the timestamp could not be converted.
-func unixTimestampAsTime(ts string) *time.Time {
- i, err := strconv.ParseInt(ts, 10, 64)
- if err != nil {
- return nil
- }
- tm := time.Unix(i, 0)
- return &tm
-}
-
// Rename renames from `from` to `to` using `git mv`.
func Rename(from, to string) error {
log.Println(util.ShorterPath(from), util.ShorterPath(to))
diff --git a/history/information.go b/history/information.go
deleted file mode 100644
index 8237c1c..0000000
--- a/history/information.go
+++ /dev/null
@@ -1,199 +0,0 @@
-package history
-
-// information.go
-// Things related to gathering existing information.
-import (
- "fmt"
- "log"
- "regexp"
- "strconv"
- "strings"
- "time"
-
- "github.com/bouncepaw/mycorrhiza/cfg"
- "github.com/bouncepaw/mycorrhiza/files"
-
- "github.com/gorilla/feeds"
-)
-
-func recentChangesFeed() *feeds.Feed {
- feed := &feeds.Feed{
- Title: "Recent changes",
- Link: &feeds.Link{Href: cfg.URL},
- Description: "List of 30 recent changes on the wiki",
- Author: &feeds.Author{Name: "Wikimind", Email: "wikimind@mycorrhiza"},
- Updated: time.Now(),
- }
- var (
- out, err = silentGitsh(
- "log", "--oneline", "--no-merges",
- "--pretty=format:\"%h\t%ae\t%at\t%s\"",
- "--max-count=30",
- )
- revs []Revision
- )
- if err == nil {
- for _, line := range strings.Split(out.String(), "\n") {
- revs = append(revs, parseRevisionLine(line))
- }
- }
- log.Printf("Found %d recent changes", len(revs))
- for _, rev := range revs {
- feed.Add(&feeds.Item{
- Title: rev.Message,
- Author: &feeds.Author{Name: rev.Username},
- Id: rev.Hash,
- Description: rev.descriptionForFeed(),
- Created: rev.Time,
- Updated: rev.Time,
- Link: &feeds.Link{Href: cfg.URL + rev.bestLink()},
- })
- }
- return feed
-}
-
-// RecentChangesRSS creates recent changes feed in RSS format.
-func RecentChangesRSS() (string, error) {
- return recentChangesFeed().ToRss()
-}
-
-// RecentChangesAtom creates recent changes feed in Atom format.
-func RecentChangesAtom() (string, error) {
- return recentChangesFeed().ToAtom()
-}
-
-// RecentChangesJSON creates recent changes feed in JSON format.
-func RecentChangesJSON() (string, error) {
- return recentChangesFeed().ToJSON()
-}
-
-// RecentChanges gathers an arbitrary number of latest changes in form of revisions slice.
-func RecentChanges(n int) []Revision {
- var (
- out, err = silentGitsh(
- "log", "--oneline", "--no-merges",
- "--pretty=format:\"%h\t%ae\t%at\t%s\"",
- "--max-count="+strconv.Itoa(n),
- )
- revs []Revision
- )
- if err == nil {
- for _, line := range strings.Split(out.String(), "\n") {
- revs = append(revs, parseRevisionLine(line))
- }
- }
- log.Printf("Found %d recent changes", len(revs))
- return revs
-}
-
-// FileChanged tells you if the file has been changed.
-func FileChanged(path string) bool {
- _, err := gitsh("diff", "--exit-code", path)
- return err != nil
-}
-
-// Revisions returns slice of revisions for the given hypha name.
-func Revisions(hyphaName string) ([]Revision, error) {
- var (
- out, err = silentGitsh(
- "log", "--oneline", "--no-merges",
- // Hash, author email, author time, commit msg separated by tab
- "--pretty=format:\"%h\t%ae\t%at\t%s\"",
- "--", hyphaName+".*",
- )
- revs []Revision
- )
- if err == nil {
- for _, line := range strings.Split(out.String(), "\n") {
- if line != "" {
- revs = append(revs, parseRevisionLine(line))
- }
- }
- }
- log.Printf("Found %d revisions for ‘%s’\n", len(revs), hyphaName)
- return revs, err
-}
-
-// WithRevisions returns an html representation of `revs` that is meant to be inserted in a history page.
-func WithRevisions(hyphaName string, revs []Revision) (html string) {
- var (
- currentYear int
- currentMonth time.Month
- )
- for i, rev := range revs {
- if rev.Time.Month() != currentMonth || rev.Time.Year() != currentYear {
- currentYear = rev.Time.Year()
- currentMonth = rev.Time.Month()
- if i != 0 {
- html += `
-
-`
- }
- html += fmt.Sprintf(`
-
-
- %[3]s
-
- `,
- currentYear, currentMonth,
- strconv.Itoa(currentYear)+" "+rev.Time.Month().String())
- }
- html += rev.asHistoryEntry(hyphaName)
- }
- return html
-}
-
-func (rev *Revision) asHistoryEntry(hyphaName string) (html string) {
- author := ""
- if rev.Username != "anon" {
- author = fmt.Sprintf(`
- by %[2]s `, cfg.UserHypha, rev.Username)
- }
- return fmt.Sprintf(`
-
-
- %[2]s
- %[3]s
- %[4]s
- %[5]s
-
-`, hyphaName, rev.timeToDisplay(), rev.Hash, rev.Message, author)
-}
-
-// Return time like mm-dd 13:42
-func (rev *Revision) timeToDisplay() string {
- D := rev.Time.Day()
- h, m, _ := rev.Time.Clock()
- return fmt.Sprintf("%02d — %02d:%02d", D, h, m)
-}
-
-// This regex is wrapped in "". For some reason, these quotes appear at some time and we have to get rid of them.
-var revisionLinePattern = regexp.MustCompile("\"(.*)\t(.*)@.*\t(.*)\t(.*)\"")
-
-func parseRevisionLine(line string) Revision {
- results := revisionLinePattern.FindStringSubmatch(line)
- return Revision{
- Hash: results[1],
- Username: results[2],
- Time: *unixTimestampAsTime(results[3]),
- Message: results[4],
- }
-}
-
-// FileAtRevision shows how the file with the given file path looked at the commit with the hash. It may return an error if git fails.
-func FileAtRevision(filepath, hash string) (string, error) {
- out, err := gitsh("show", hash+":"+strings.TrimPrefix(filepath, files.HyphaeDir()+"/"))
- if err != nil {
- return "", err
- }
- return out.String(), err
-}
-
-// PrimitiveDiffAtRevision generates a plain-text diff for the given filepath at the commit with the given hash. It may return an error if git fails.
-func PrimitiveDiffAtRevision(filepath, hash string) (string, error) {
- out, err := silentGitsh("diff", "--unified=1", "--no-color", hash+"~", hash, "--", filepath)
- if err != nil {
- return "", err
- }
- return out.String(), err
-}
diff --git a/history/operations.go b/history/operations.go
index 2e23f82..7a5d080 100644
--- a/history/operations.go
+++ b/history/operations.go
@@ -95,9 +95,7 @@ func (hop *Op) WithFilesRenamed(pairs map[string]string) *Op {
hop.Errs = append(hop.Errs, err)
continue
}
- if err := Rename(from, to); err != nil {
- hop.Errs = append(hop.Errs, err)
- }
+ hop.gitop("mv", "--force", from, to)
}
}
return hop
diff --git a/history/revision.go b/history/revision.go
new file mode 100644
index 0000000..fd9293a
--- /dev/null
+++ b/history/revision.go
@@ -0,0 +1,254 @@
+package history
+
+import (
+ "fmt"
+ "log"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/bouncepaw/mycorrhiza/files"
+)
+
+// Revision represents a revision, duh. Hash is usually short. Username is extracted from email.
+type Revision struct {
+ Hash string
+ Username string
+ Time time.Time
+ Message string
+ filesAffectedBuf []string
+ hyphaeAffectedBuf []string
+}
+
+// gitLog calls `git log` and parses the results.
+func gitLog(args ...string) ([]Revision, error) {
+ args = append([]string{
+ "log", "--abbrev-commit", "--no-merges",
+ "--pretty=format:%h\t%ae\t%at\t%s",
+ }, args...)
+ out, err := silentGitsh(args...)
+ if err != nil {
+ return nil, err
+ }
+
+ outStr := out.String()
+ if outStr == "" {
+ // if there are no commits to return
+ return nil, nil
+ }
+
+ var revs []Revision
+ for _, line := range strings.Split(outStr, "\n") {
+ revs = append(revs, parseRevisionLine(line))
+ }
+ return revs, nil
+}
+
+type recentChangesStream struct {
+ currHash string
+}
+
+func newRecentChangesStream() recentChangesStream {
+ // next returns the next n revisions from the stream, ordered most recent first.
+ // If there are less than n revisions remaining, it will return only those.
+ return recentChangesStream{currHash: ""}
+}
+
+func (stream *recentChangesStream) next(n int) []Revision {
+ args := []string{"--max-count=" + strconv.Itoa(n)}
+ if stream.currHash == "" {
+ args = append(args, "HEAD")
+ } else {
+ // currHash is the last revision from the last call, so skip it
+ args = append(args, "--skip=1", stream.currHash)
+ }
+ // I don't think this can fail, so ignore the error
+ res, _ := gitLog(args...)
+ if len(res) != 0 {
+ stream.currHash = res[len(res)-1].Hash
+ }
+ return res
+}
+
+// recentChangesIterator returns a function that returns successive revisions from the stream.
+// It buffers revisions to avoid calling git every time.
+func (stream recentChangesStream) iterator() func() (Revision, bool) {
+ var buf []Revision
+ return func() (Revision, bool) {
+ if len(buf) == 0 {
+ // no real reason to choose 30, just needs some large number
+ buf = stream.next(30)
+ if len(buf) == 0 {
+ // revs has no revisions left
+ return Revision{}, true
+ }
+ }
+ rev := buf[0]
+ buf = buf[1:]
+ return rev, false
+ }
+}
+
+// RecentChanges gathers an arbitrary number of latest changes in form of revisions slice, ordered most recent first.
+func RecentChanges(n int) []Revision {
+ stream := newRecentChangesStream()
+ revs := stream.next(n)
+ log.Printf("Found %d recent changes", len(revs))
+ return revs
+}
+
+// Revisions returns slice of revisions for the given hypha name, ordered most recent first.
+func Revisions(hyphaName string) ([]Revision, error) {
+ revs, err := gitLog("--", hyphaName+".*")
+ log.Printf("Found %d revisions for ‘%s’\n", len(revs), hyphaName)
+ return revs, err
+}
+
+// FileChanged tells you if the file has been changed since the last commit.
+func FileChanged(path string) bool {
+ _, err := gitsh("diff", "--exit-code", path)
+ return err != nil
+}
+
+// Return time like dd — 13:42
+func (rev *Revision) timeToDisplay() string {
+ D := rev.Time.Day()
+ h, m, _ := rev.Time.Clock()
+ return fmt.Sprintf("%02d — %02d:%02d", D, h, m)
+}
+
+var revisionLinePattern = regexp.MustCompile("(.*)\t(.*)@.*\t(.*)\t(.*)")
+
+// Convert a UNIX timestamp as string into a time. If nil is returned, it means that the timestamp could not be converted.
+func unixTimestampAsTime(ts string) *time.Time {
+ i, err := strconv.ParseInt(ts, 10, 64)
+ if err != nil {
+ return nil
+ }
+ tm := time.Unix(i, 0)
+ return &tm
+}
+
+func parseRevisionLine(line string) Revision {
+ results := revisionLinePattern.FindStringSubmatch(line)
+ return Revision{
+ Hash: results[1],
+ Username: results[2],
+ Time: *unixTimestampAsTime(results[3]),
+ Message: results[4],
+ }
+}
+
+// filesAffected tells what files have been affected by the revision.
+func (rev *Revision) filesAffected() (filenames []string) {
+ if nil != rev.filesAffectedBuf {
+ return rev.filesAffectedBuf
+ }
+ // List of files affected by this revision, one per line.
+ out, err := silentGitsh("diff-tree", "--no-commit-id", "--name-only", "-r", rev.Hash)
+ // There's an error? Well, whatever, let's just assign an empty slice, who cares.
+ if err != nil {
+ rev.filesAffectedBuf = []string{}
+ } else {
+ rev.filesAffectedBuf = strings.Split(out.String(), "\n")
+ }
+ return rev.filesAffectedBuf
+}
+
+// determine what hyphae were affected by this revision
+func (rev *Revision) hyphaeAffected() (hyphae []string) {
+ if nil != rev.hyphaeAffectedBuf {
+ return rev.hyphaeAffectedBuf
+ }
+ hyphae = make([]string, 0)
+ var (
+ // set is used to determine if a certain hypha has been already noted (hyphae are stored in 2 files at most currently).
+ set = make(map[string]bool)
+ isNewName = func(hyphaName string) bool {
+ if _, present := set[hyphaName]; present {
+ return false
+ }
+ set[hyphaName] = true
+ return true
+ }
+ filesAffected = rev.filesAffected()
+ )
+ for _, filename := range filesAffected {
+ if strings.IndexRune(filename, '.') >= 0 {
+ dotPos := strings.LastIndexByte(filename, '.')
+ hyphaName := string([]byte(filename)[0:dotPos]) // is it safe?
+ if isNewName(hyphaName) {
+ hyphae = append(hyphae, hyphaName)
+ }
+ }
+ }
+ rev.hyphaeAffectedBuf = hyphae
+ return hyphae
+}
+
+// TimeString returns a human readable time representation.
+func (rev Revision) TimeString() string {
+ return rev.Time.Format(time.RFC822)
+}
+
+// textDiff generates a good enough diff to display in a web feed. It is not html-escaped.
+func (rev *Revision) textDiff() (diff string) {
+ filenames, ok := rev.mycoFiles()
+ if !ok {
+ return "No text changes"
+ }
+ for _, filename := range filenames {
+ text, err := PrimitiveDiffAtRevision(filename, rev.Hash)
+ if err != nil {
+ diff += "\nAn error has occurred with " + filename + "\n"
+ }
+ diff += text + "\n"
+ }
+ return diff
+}
+
+// mycoFiles returns filenames of .myco file. It is not ok if there are no myco files.
+func (rev *Revision) mycoFiles() (filenames []string, ok bool) {
+ filenames = []string{}
+ for _, filename := range rev.filesAffected() {
+ if strings.HasSuffix(filename, ".myco") {
+ filenames = append(filenames, filename)
+ }
+ }
+ return filenames, len(filenames) > 0
+}
+
+// Try and guess what link is the most important by looking at the message.
+func (rev *Revision) bestLink() string {
+ var (
+ revs = rev.hyphaeAffected()
+ renameRes = renameMsgPattern.FindStringSubmatch(rev.Message)
+ )
+ switch {
+ case renameRes != nil:
+ return "/hypha/" + renameRes[1]
+ case len(revs) == 0:
+ return ""
+ default:
+ return "/hypha/" + revs[0]
+ }
+}
+
+// FileAtRevision shows how the file with the given file path looked at the commit with the hash. It may return an error if git fails.
+func FileAtRevision(filepath, hash string) (string, error) {
+ out, err := gitsh("show", hash+":"+strings.TrimPrefix(filepath, files.HyphaeDir()+"/"))
+ if err != nil {
+ return "", err
+ }
+ return out.String(), err
+}
+
+// PrimitiveDiffAtRevision generates a plain-text diff for the given filepath at the commit with the given hash. It may return an error if git fails.
+func PrimitiveDiffAtRevision(filepath, hash string) (string, error) {
+ out, err := silentGitsh("diff", "--unified=1", "--no-color", hash+"~", hash, "--", filepath)
+ if err != nil {
+ return "", err
+ }
+ return out.String(), err
+}
diff --git a/history/view.qtpl b/history/view.qtpl
new file mode 100644
index 0000000..2a87825
--- /dev/null
+++ b/history/view.qtpl
@@ -0,0 +1,55 @@
+{% import "fmt" %}
+{% import "github.com/bouncepaw/mycorrhiza/cfg" %}
+
+HyphaeLinksHTML returns a comma-separated list of hyphae that were affected by this revision as HTML string.
+{% func (rev Revision) HyphaeLinksHTML() %}
+{% stripspace %}
+ {% for i, hyphaName := range rev.hyphaeAffected() %}
+ {% if i > 0 %}
+ ,
+ {% endif %}
+ {%s hyphaName %}
+ {% endfor %}
+{% endstripspace %}
+{% endfunc %}
+
+descriptionForFeed generates a good enough HTML contents for a web feed.
+{% func (rev *Revision) descriptionForFeed() %}
+{%s rev.Message %} (by {%s rev.Username %} at {%s rev.TimeString() %})
+Hyphae affected: {%= rev.HyphaeLinksHTML() %}
+ {%s rev.textDiff() %}
+{% endfunc %}
+
+WithRevisions returns an html representation of `revs` that is meant to be inserted in a history page.
+{% func WithRevisions(hyphaName string, revs []Revision) %}
+{% for _, grp := range groupRevisionsByMonth(revs) %}
+ {% code
+ currentYear := grp[0].Time.Year()
+ currentMonth := grp[0].Time.Month()
+ sectionId := fmt.Sprintf("%d-%d", currentYear, currentMonth)
+ %}
+
+
+ {%d currentYear %} {%s currentMonth.String() %}
+
+
+ {% for _, rev := range grp %}
+ {%= rev.asHistoryEntry(hyphaName) %}
+ {% endfor %}
+
+
+{% endfor %}
+{% endfunc %}
+
+{% func (rev *Revision) asHistoryEntry(hyphaName string) %}
+
+
+ {%s rev.timeToDisplay() %}
+
+ {%s rev.Hash %}
+ {%s rev.Message %}
+ {% if rev.Username != "anon" %}
+ by {%s rev.Username %}
+ {% endif %}
+
+{% endfunc %}
\ No newline at end of file
diff --git a/history/view.qtpl.go b/history/view.qtpl.go
new file mode 100644
index 0000000..65c5a71
--- /dev/null
+++ b/history/view.qtpl.go
@@ -0,0 +1,330 @@
+// Code generated by qtc from "view.qtpl". DO NOT EDIT.
+// See https://github.com/valyala/quicktemplate for details.
+
+//line history/view.qtpl:1
+package history
+
+//line history/view.qtpl:1
+import "fmt"
+
+//line history/view.qtpl:2
+import "github.com/bouncepaw/mycorrhiza/cfg"
+
+// HyphaeLinksHTML returns a comma-separated list of hyphae that were affected by this revision as HTML string.
+
+//line history/view.qtpl:5
+import (
+ qtio422016 "io"
+
+ qt422016 "github.com/valyala/quicktemplate"
+)
+
+//line history/view.qtpl:5
+var (
+ _ = qtio422016.Copy
+ _ = qt422016.AcquireByteBuffer
+)
+
+//line history/view.qtpl:5
+func (rev Revision) StreamHyphaeLinksHTML(qw422016 *qt422016.Writer) {
+//line history/view.qtpl:5
+ qw422016.N().S(`
+`)
+//line history/view.qtpl:7
+ for i, hyphaName := range rev.hyphaeAffected() {
+//line history/view.qtpl:8
+ if i > 0 {
+//line history/view.qtpl:8
+ qw422016.N().S(`, `)
+//line history/view.qtpl:10
+ }
+//line history/view.qtpl:10
+ qw422016.N().S(``)
+//line history/view.qtpl:11
+ qw422016.E().S(hyphaName)
+//line history/view.qtpl:11
+ qw422016.N().S(` `)
+//line history/view.qtpl:12
+ }
+//line history/view.qtpl:13
+ qw422016.N().S(`
+`)
+//line history/view.qtpl:14
+}
+
+//line history/view.qtpl:14
+func (rev Revision) WriteHyphaeLinksHTML(qq422016 qtio422016.Writer) {
+//line history/view.qtpl:14
+ qw422016 := qt422016.AcquireWriter(qq422016)
+//line history/view.qtpl:14
+ rev.StreamHyphaeLinksHTML(qw422016)
+//line history/view.qtpl:14
+ qt422016.ReleaseWriter(qw422016)
+//line history/view.qtpl:14
+}
+
+//line history/view.qtpl:14
+func (rev Revision) HyphaeLinksHTML() string {
+//line history/view.qtpl:14
+ qb422016 := qt422016.AcquireByteBuffer()
+//line history/view.qtpl:14
+ rev.WriteHyphaeLinksHTML(qb422016)
+//line history/view.qtpl:14
+ qs422016 := string(qb422016.B)
+//line history/view.qtpl:14
+ qt422016.ReleaseByteBuffer(qb422016)
+//line history/view.qtpl:14
+ return qs422016
+//line history/view.qtpl:14
+}
+
+// descriptionForFeed generates a good enough HTML contents for a web feed.
+
+//line history/view.qtpl:17
+func (rev *Revision) streamdescriptionForFeed(qw422016 *qt422016.Writer) {
+//line history/view.qtpl:17
+ qw422016.N().S(`
+`)
+//line history/view.qtpl:18
+ qw422016.E().S(rev.Message)
+//line history/view.qtpl:18
+ qw422016.N().S(` (by `)
+//line history/view.qtpl:18
+ qw422016.E().S(rev.Username)
+//line history/view.qtpl:18
+ qw422016.N().S(` at `)
+//line history/view.qtpl:18
+ qw422016.E().S(rev.TimeString())
+//line history/view.qtpl:18
+ qw422016.N().S(`)
+Hyphae affected: `)
+//line history/view.qtpl:19
+ rev.StreamHyphaeLinksHTML(qw422016)
+//line history/view.qtpl:19
+ qw422016.N().S(`
+`)
+//line history/view.qtpl:20
+ qw422016.E().S(rev.textDiff())
+//line history/view.qtpl:20
+ qw422016.N().S(`
+`)
+//line history/view.qtpl:21
+}
+
+//line history/view.qtpl:21
+func (rev *Revision) writedescriptionForFeed(qq422016 qtio422016.Writer) {
+//line history/view.qtpl:21
+ qw422016 := qt422016.AcquireWriter(qq422016)
+//line history/view.qtpl:21
+ rev.streamdescriptionForFeed(qw422016)
+//line history/view.qtpl:21
+ qt422016.ReleaseWriter(qw422016)
+//line history/view.qtpl:21
+}
+
+//line history/view.qtpl:21
+func (rev *Revision) descriptionForFeed() string {
+//line history/view.qtpl:21
+ qb422016 := qt422016.AcquireByteBuffer()
+//line history/view.qtpl:21
+ rev.writedescriptionForFeed(qb422016)
+//line history/view.qtpl:21
+ qs422016 := string(qb422016.B)
+//line history/view.qtpl:21
+ qt422016.ReleaseByteBuffer(qb422016)
+//line history/view.qtpl:21
+ return qs422016
+//line history/view.qtpl:21
+}
+
+// WithRevisions returns an html representation of `revs` that is meant to be inserted in a history page.
+
+//line history/view.qtpl:24
+func StreamWithRevisions(qw422016 *qt422016.Writer, hyphaName string, revs []Revision) {
+//line history/view.qtpl:24
+ qw422016.N().S(`
+`)
+//line history/view.qtpl:25
+ for _, grp := range groupRevisionsByMonth(revs) {
+//line history/view.qtpl:25
+ qw422016.N().S(`
+ `)
+//line history/view.qtpl:27
+ currentYear := grp[0].Time.Year()
+ currentMonth := grp[0].Time.Month()
+ sectionId := fmt.Sprintf("%d-%d", currentYear, currentMonth)
+
+//line history/view.qtpl:30
+ qw422016.N().S(`
+
+
+ `)
+//line history/view.qtpl:33
+ qw422016.N().D(currentYear)
+//line history/view.qtpl:33
+ qw422016.N().S(` `)
+//line history/view.qtpl:33
+ qw422016.E().S(currentMonth.String())
+//line history/view.qtpl:33
+ qw422016.N().S(`
+
+
+ `)
+//line history/view.qtpl:36
+ for _, rev := range grp {
+//line history/view.qtpl:36
+ qw422016.N().S(`
+ `)
+//line history/view.qtpl:37
+ rev.streamasHistoryEntry(qw422016, hyphaName)
+//line history/view.qtpl:37
+ qw422016.N().S(`
+ `)
+//line history/view.qtpl:38
+ }
+//line history/view.qtpl:38
+ qw422016.N().S(`
+
+
+`)
+//line history/view.qtpl:41
+ }
+//line history/view.qtpl:41
+ qw422016.N().S(`
+`)
+//line history/view.qtpl:42
+}
+
+//line history/view.qtpl:42
+func WriteWithRevisions(qq422016 qtio422016.Writer, hyphaName string, revs []Revision) {
+//line history/view.qtpl:42
+ qw422016 := qt422016.AcquireWriter(qq422016)
+//line history/view.qtpl:42
+ StreamWithRevisions(qw422016, hyphaName, revs)
+//line history/view.qtpl:42
+ qt422016.ReleaseWriter(qw422016)
+//line history/view.qtpl:42
+}
+
+//line history/view.qtpl:42
+func WithRevisions(hyphaName string, revs []Revision) string {
+//line history/view.qtpl:42
+ qb422016 := qt422016.AcquireByteBuffer()
+//line history/view.qtpl:42
+ WriteWithRevisions(qb422016, hyphaName, revs)
+//line history/view.qtpl:42
+ qs422016 := string(qb422016.B)
+//line history/view.qtpl:42
+ qt422016.ReleaseByteBuffer(qb422016)
+//line history/view.qtpl:42
+ return qs422016
+//line history/view.qtpl:42
+}
+
+//line history/view.qtpl:44
+func (rev *Revision) streamasHistoryEntry(qw422016 *qt422016.Writer, hyphaName string) {
+//line history/view.qtpl:44
+ qw422016.N().S(`
+
+
+ `)
+//line history/view.qtpl:47
+ qw422016.E().S(rev.timeToDisplay())
+//line history/view.qtpl:47
+ qw422016.N().S(`
+
+ `)
+//line history/view.qtpl:49
+ qw422016.E().S(rev.Hash)
+//line history/view.qtpl:49
+ qw422016.N().S(`
+ `)
+//line history/view.qtpl:50
+ qw422016.E().S(rev.Message)
+//line history/view.qtpl:50
+ qw422016.N().S(`
+ `)
+//line history/view.qtpl:51
+ if rev.Username != "anon" {
+//line history/view.qtpl:51
+ qw422016.N().S(`
+ by `)
+//line history/view.qtpl:52
+ qw422016.E().S(rev.Username)
+//line history/view.qtpl:52
+ qw422016.N().S(`
+ `)
+//line history/view.qtpl:53
+ }
+//line history/view.qtpl:53
+ qw422016.N().S(`
+
+`)
+//line history/view.qtpl:55
+}
+
+//line history/view.qtpl:55
+func (rev *Revision) writeasHistoryEntry(qq422016 qtio422016.Writer, hyphaName string) {
+//line history/view.qtpl:55
+ qw422016 := qt422016.AcquireWriter(qq422016)
+//line history/view.qtpl:55
+ rev.streamasHistoryEntry(qw422016, hyphaName)
+//line history/view.qtpl:55
+ qt422016.ReleaseWriter(qw422016)
+//line history/view.qtpl:55
+}
+
+//line history/view.qtpl:55
+func (rev *Revision) asHistoryEntry(hyphaName string) string {
+//line history/view.qtpl:55
+ qb422016 := qt422016.AcquireByteBuffer()
+//line history/view.qtpl:55
+ rev.writeasHistoryEntry(qb422016, hyphaName)
+//line history/view.qtpl:55
+ qs422016 := string(qb422016.B)
+//line history/view.qtpl:55
+ qt422016.ReleaseByteBuffer(qb422016)
+//line history/view.qtpl:55
+ return qs422016
+//line history/view.qtpl:55
+}
diff --git a/l18n/l18n.go b/l18n/l18n.go
index 5ad1bd2..e00b611 100644
--- a/l18n/l18n.go
+++ b/l18n/l18n.go
@@ -102,6 +102,7 @@ var localizations = map[string]string{
"en.help.empty_error_link": "contributing",
"en.help.empty_error_title": "This entry does not exist!",
"en.help.entry_not_found": "Entry not found",
+ "en.help.feeds": "Feeds",
"en.help.hypha": "Hypha",
"en.help.interface": "Interface",
"en.help.lock": "Lock",
diff --git a/l18n_src/en/help.json b/l18n_src/en/help.json
index 3a5037c..8944b8c 100644
--- a/l18n_src/en/help.json
+++ b/l18n_src/en/help.json
@@ -18,6 +18,7 @@
"sibling_hyphae": "Sibling hyphae",
"special_pages": "Special pages",
"recent_changes": "Recent changes",
+ "feeds": "Feeds",
"configuration": "Configuration (for administrators)",
"lock": "Lock",
"whitelist": "Whitelist",
diff --git a/main.go b/main.go
index 0d8c443..f11bec8 100644
--- a/main.go
+++ b/main.go
@@ -1,5 +1,6 @@
//go:generate qtc -dir=views
//go:generate qtc -dir=tree
+//go:generate qtc -dir=history
//go:generate go-localize -input l18n_src -output l18n
// Command mycorrhiza is a program that runs a mycorrhiza wiki.
package main
diff --git a/tree/tree.go b/tree/tree.go
index af95a6e..8c567ae 100644
--- a/tree/tree.go
+++ b/tree/tree.go
@@ -96,7 +96,7 @@ func Tree(hyphaName string) (siblingsHTML, childrenHTML, prev, next string) {
wg.Done()
}()
go func() {
- children = figureOutChildren(hyphaName, true).children
+ children = figureOutChildren(hyphaName).children
wg.Done()
}()
wg.Wait()
@@ -124,7 +124,7 @@ type child struct {
children []child
}
-func figureOutChildren(hyphaName string, exists bool) child {
+func figureOutChildren(hyphaName string) child {
var (
descPrefix = hyphaName + "/"
child = child{hyphaName, true, make([]child, 0)}
diff --git a/views/stuff.qtpl b/views/stuff.qtpl
index e7332d3..c5bc8f2 100644
--- a/views/stuff.qtpl
+++ b/views/stuff.qtpl
@@ -176,6 +176,7 @@ It outputs a poorly formatted JSON, but it works and is valid.
{%s lc.GetWithLocale(lang, "help.special_pages") %}
{%s lc.GetWithLocale(lang, "help.configuration") %}
diff --git a/views/stuff.qtpl.go b/views/stuff.qtpl.go
index b2681e0..b54ef0e 100644
--- a/views/stuff.qtpl.go
+++ b/views/stuff.qtpl.go
@@ -707,41 +707,50 @@ func streamhelpTopicsHTML(qw422016 *qt422016.Writer, lang string, lc *l18n.Local
//line views/stuff.qtpl:178
qw422016.E().S(lc.GetWithLocale(lang, "help.recent_changes"))
//line views/stuff.qtpl:178
+ qw422016.N().S(`
+ `)
+//line views/stuff.qtpl:179
+ qw422016.E().S(lc.GetWithLocale(lang, "help.feeds"))
+//line views/stuff.qtpl:179
qw422016.N().S(`
`)
-//line views/stuff.qtpl:181
+//line views/stuff.qtpl:182
qw422016.E().S(lc.GetWithLocale(lang, "help.configuration"))
-//line views/stuff.qtpl:181
+//line views/stuff.qtpl:182
qw422016.N().S(`
@@ -749,91 +758,91 @@ func streamhelpTopicsHTML(qw422016 *qt422016.Writer, lang string, lc *l18n.Local
`)
-//line views/stuff.qtpl:191
+//line views/stuff.qtpl:192
}
-//line views/stuff.qtpl:191
+//line views/stuff.qtpl:192
func writehelpTopicsHTML(qq422016 qtio422016.Writer, lang string, lc *l18n.Localizer) {
-//line views/stuff.qtpl:191
+//line views/stuff.qtpl:192
qw422016 := qt422016.AcquireWriter(qq422016)
-//line views/stuff.qtpl:191
+//line views/stuff.qtpl:192
streamhelpTopicsHTML(qw422016, lang, lc)
-//line views/stuff.qtpl:191
+//line views/stuff.qtpl:192
qt422016.ReleaseWriter(qw422016)
-//line views/stuff.qtpl:191
+//line views/stuff.qtpl:192
}
-//line views/stuff.qtpl:191
+//line views/stuff.qtpl:192
func helpTopicsHTML(lang string, lc *l18n.Localizer) string {
-//line views/stuff.qtpl:191
+//line views/stuff.qtpl:192
qb422016 := qt422016.AcquireByteBuffer()
-//line views/stuff.qtpl:191
+//line views/stuff.qtpl:192
writehelpTopicsHTML(qb422016, lang, lc)
-//line views/stuff.qtpl:191
+//line views/stuff.qtpl:192
qs422016 := string(qb422016.B)
-//line views/stuff.qtpl:191
+//line views/stuff.qtpl:192
qt422016.ReleaseByteBuffer(qb422016)
-//line views/stuff.qtpl:191
+//line views/stuff.qtpl:192
return qs422016
-//line views/stuff.qtpl:191
+//line views/stuff.qtpl:192
}
-//line views/stuff.qtpl:193
+//line views/stuff.qtpl:194
func streamhelpTopicBadgeHTML(qw422016 *qt422016.Writer, lang, topic string) {
-//line views/stuff.qtpl:193
+//line views/stuff.qtpl:194
qw422016.N().S(`
?
`)
-//line views/stuff.qtpl:195
+//line views/stuff.qtpl:196
}
-//line views/stuff.qtpl:195
+//line views/stuff.qtpl:196
func writehelpTopicBadgeHTML(qq422016 qtio422016.Writer, lang, topic string) {
-//line views/stuff.qtpl:195
+//line views/stuff.qtpl:196
qw422016 := qt422016.AcquireWriter(qq422016)
-//line views/stuff.qtpl:195
+//line views/stuff.qtpl:196
streamhelpTopicBadgeHTML(qw422016, lang, topic)
-//line views/stuff.qtpl:195
+//line views/stuff.qtpl:196
qt422016.ReleaseWriter(qw422016)
-//line views/stuff.qtpl:195
+//line views/stuff.qtpl:196
}
-//line views/stuff.qtpl:195
+//line views/stuff.qtpl:196
func helpTopicBadgeHTML(lang, topic string) string {
-//line views/stuff.qtpl:195
+//line views/stuff.qtpl:196
qb422016 := qt422016.AcquireByteBuffer()
-//line views/stuff.qtpl:195
+//line views/stuff.qtpl:196
writehelpTopicBadgeHTML(qb422016, lang, topic)
-//line views/stuff.qtpl:195
+//line views/stuff.qtpl:196
qs422016 := string(qb422016.B)
-//line views/stuff.qtpl:195
+//line views/stuff.qtpl:196
qt422016.ReleaseByteBuffer(qb422016)
-//line views/stuff.qtpl:195
+//line views/stuff.qtpl:196
return qs422016
-//line views/stuff.qtpl:195
+//line views/stuff.qtpl:196
}
-//line views/stuff.qtpl:197
+//line views/stuff.qtpl:198
func StreamUserListHTML(qw422016 *qt422016.Writer, lc *l18n.Localizer) {
-//line views/stuff.qtpl:197
+//line views/stuff.qtpl:198
qw422016.N().S(`
`)
-//line views/stuff.qtpl:200
+//line views/stuff.qtpl:201
qw422016.E().S(lc.Get("ui.users_heading"))
-//line views/stuff.qtpl:200
+//line views/stuff.qtpl:201
qw422016.N().S(`
`)
-//line views/stuff.qtpl:202
+//line views/stuff.qtpl:203
var (
admins = make([]string, 0)
moderators = make([]string, 0)
@@ -853,149 +862,149 @@ func StreamUserListHTML(qw422016 *qt422016.Writer, lc *l18n.Localizer) {
sort.Strings(moderators)
sort.Strings(editors)
-//line views/stuff.qtpl:220
+//line views/stuff.qtpl:221
qw422016.N().S(`
`)
-//line views/stuff.qtpl:241
+//line views/stuff.qtpl:242
}
-//line views/stuff.qtpl:241
+//line views/stuff.qtpl:242
func WriteUserListHTML(qq422016 qtio422016.Writer, lc *l18n.Localizer) {
-//line views/stuff.qtpl:241
+//line views/stuff.qtpl:242
qw422016 := qt422016.AcquireWriter(qq422016)
-//line views/stuff.qtpl:241
+//line views/stuff.qtpl:242
StreamUserListHTML(qw422016, lc)
-//line views/stuff.qtpl:241
+//line views/stuff.qtpl:242
qt422016.ReleaseWriter(qw422016)
-//line views/stuff.qtpl:241
+//line views/stuff.qtpl:242
}
-//line views/stuff.qtpl:241
+//line views/stuff.qtpl:242
func UserListHTML(lc *l18n.Localizer) string {
-//line views/stuff.qtpl:241
+//line views/stuff.qtpl:242
qb422016 := qt422016.AcquireByteBuffer()
-//line views/stuff.qtpl:241
+//line views/stuff.qtpl:242
WriteUserListHTML(qb422016, lc)
-//line views/stuff.qtpl:241
+//line views/stuff.qtpl:242
qs422016 := string(qb422016.B)
-//line views/stuff.qtpl:241
+//line views/stuff.qtpl:242
qt422016.ReleaseByteBuffer(qb422016)
-//line views/stuff.qtpl:241
+//line views/stuff.qtpl:242
return qs422016
-//line views/stuff.qtpl:241
+//line views/stuff.qtpl:242
}
-//line views/stuff.qtpl:243
+//line views/stuff.qtpl:244
func StreamHyphaListHTML(qw422016 *qt422016.Writer, lc *l18n.Localizer) {
-//line views/stuff.qtpl:243
+//line views/stuff.qtpl:244
qw422016.N().S(`
`)
-//line views/stuff.qtpl:246
+//line views/stuff.qtpl:247
qw422016.E().S(lc.Get("ui.list_heading"))
-//line views/stuff.qtpl:246
+//line views/stuff.qtpl:247
qw422016.N().S(`
`)
-//line views/stuff.qtpl:247
+//line views/stuff.qtpl:248
qw422016.E().S(lc.GetPlural("ui.list_desc", hyphae.Count()))
-//line views/stuff.qtpl:247
+//line views/stuff.qtpl:248
qw422016.N().S(`
`)
-//line views/stuff.qtpl:250
+//line views/stuff.qtpl:251
hyphaNames := make(chan string)
sortedHypha := hyphae.PathographicSort(hyphaNames)
for hypha := range hyphae.YieldExistingHyphae() {
@@ -1003,252 +1012,252 @@ func StreamHyphaListHTML(qw422016 *qt422016.Writer, lc *l18n.Localizer) {
}
close(hyphaNames)
-//line views/stuff.qtpl:256
+//line views/stuff.qtpl:257
qw422016.N().S(`
`)
-//line views/stuff.qtpl:257
+//line views/stuff.qtpl:258
for hyphaName := range sortedHypha {
-//line views/stuff.qtpl:257
+//line views/stuff.qtpl:258
qw422016.N().S(`
`)
-//line views/stuff.qtpl:258
+//line views/stuff.qtpl:259
hypha := hyphae.ByName(hyphaName)
-//line views/stuff.qtpl:258
+//line views/stuff.qtpl:259
qw422016.N().S(`
`)
-//line views/stuff.qtpl:260
+//line views/stuff.qtpl:261
qw422016.E().S(util.BeautifulName(hypha.Name))
-//line views/stuff.qtpl:260
+//line views/stuff.qtpl:261
qw422016.N().S(`
`)
-//line views/stuff.qtpl:261
+//line views/stuff.qtpl:262
if hypha.BinaryPath != "" {
-//line views/stuff.qtpl:261
+//line views/stuff.qtpl:262
qw422016.N().S(`
`)
-//line views/stuff.qtpl:262
+//line views/stuff.qtpl:263
qw422016.E().S(filepath.Ext(hypha.BinaryPath)[1:])
-//line views/stuff.qtpl:262
+//line views/stuff.qtpl:263
qw422016.N().S(`
`)
-//line views/stuff.qtpl:263
+//line views/stuff.qtpl:264
}
-//line views/stuff.qtpl:263
+//line views/stuff.qtpl:264
qw422016.N().S(`
`)
-//line views/stuff.qtpl:265
+//line views/stuff.qtpl:266
}
-//line views/stuff.qtpl:265
+//line views/stuff.qtpl:266
qw422016.N().S(`
`)
-//line views/stuff.qtpl:269
+//line views/stuff.qtpl:270
}
-//line views/stuff.qtpl:269
+//line views/stuff.qtpl:270
func WriteHyphaListHTML(qq422016 qtio422016.Writer, lc *l18n.Localizer) {
-//line views/stuff.qtpl:269
+//line views/stuff.qtpl:270
qw422016 := qt422016.AcquireWriter(qq422016)
-//line views/stuff.qtpl:269
+//line views/stuff.qtpl:270
StreamHyphaListHTML(qw422016, lc)
-//line views/stuff.qtpl:269
+//line views/stuff.qtpl:270
qt422016.ReleaseWriter(qw422016)
-//line views/stuff.qtpl:269
+//line views/stuff.qtpl:270
}
-//line views/stuff.qtpl:269
+//line views/stuff.qtpl:270
func HyphaListHTML(lc *l18n.Localizer) string {
-//line views/stuff.qtpl:269
+//line views/stuff.qtpl:270
qb422016 := qt422016.AcquireByteBuffer()
-//line views/stuff.qtpl:269
+//line views/stuff.qtpl:270
WriteHyphaListHTML(qb422016, lc)
-//line views/stuff.qtpl:269
+//line views/stuff.qtpl:270
qs422016 := string(qb422016.B)
-//line views/stuff.qtpl:269
+//line views/stuff.qtpl:270
qt422016.ReleaseByteBuffer(qb422016)
-//line views/stuff.qtpl:269
+//line views/stuff.qtpl:270
return qs422016
-//line views/stuff.qtpl:269
+//line views/stuff.qtpl:270
}
-//line views/stuff.qtpl:271
+//line views/stuff.qtpl:272
func StreamAboutHTML(qw422016 *qt422016.Writer, lc *l18n.Localizer) {
-//line views/stuff.qtpl:271
+//line views/stuff.qtpl:272
qw422016.N().S(`
`)
-//line views/stuff.qtpl:275
+//line views/stuff.qtpl:276
qw422016.E().S(lc.Get("ui.about_title", &l18n.Replacements{"name": cfg.WikiName}))
-//line views/stuff.qtpl:275
+//line views/stuff.qtpl:276
qw422016.N().S(`
`)
-//line views/stuff.qtpl:277
+//line views/stuff.qtpl:278
qw422016.N().S(lc.Get("ui.about_version", &l18n.Replacements{"pre": "", "post": " "}))
-//line views/stuff.qtpl:277
+//line views/stuff.qtpl:278
qw422016.N().S(` 1.5.0
`)
-//line views/stuff.qtpl:278
+//line views/stuff.qtpl:279
if cfg.UseAuth {
-//line views/stuff.qtpl:278
+//line views/stuff.qtpl:279
qw422016.N().S(` `)
-//line views/stuff.qtpl:279
+//line views/stuff.qtpl:280
qw422016.E().S(lc.Get("ui.about_usercount"))
-//line views/stuff.qtpl:279
+//line views/stuff.qtpl:280
qw422016.N().S(` `)
-//line views/stuff.qtpl:279
+//line views/stuff.qtpl:280
qw422016.N().DUL(user.Count())
-//line views/stuff.qtpl:279
+//line views/stuff.qtpl:280
qw422016.N().S(`
`)
-//line views/stuff.qtpl:280
+//line views/stuff.qtpl:281
qw422016.E().S(lc.Get("ui.about_homepage"))
-//line views/stuff.qtpl:280
+//line views/stuff.qtpl:281
qw422016.N().S(` `)
-//line views/stuff.qtpl:280
+//line views/stuff.qtpl:281
qw422016.E().S(cfg.HomeHypha)
-//line views/stuff.qtpl:280
+//line views/stuff.qtpl:281
qw422016.N().S(`
`)
-//line views/stuff.qtpl:281
+//line views/stuff.qtpl:282
qw422016.E().S(lc.Get("ui.about_admins"))
-//line views/stuff.qtpl:281
+//line views/stuff.qtpl:282
qw422016.N().S(` `)
-//line views/stuff.qtpl:281
+//line views/stuff.qtpl:282
for i, username := range user.ListUsersWithGroup("admin") {
-//line views/stuff.qtpl:282
+//line views/stuff.qtpl:283
if i > 0 {
-//line views/stuff.qtpl:282
+//line views/stuff.qtpl:283
qw422016.N().S(`,
`)
-//line views/stuff.qtpl:283
+//line views/stuff.qtpl:284
}
-//line views/stuff.qtpl:283
+//line views/stuff.qtpl:284
qw422016.N().S(` `)
-//line views/stuff.qtpl:284
+//line views/stuff.qtpl:285
qw422016.E().S(username)
-//line views/stuff.qtpl:284
+//line views/stuff.qtpl:285
qw422016.N().S(` `)
-//line views/stuff.qtpl:284
+//line views/stuff.qtpl:285
}
-//line views/stuff.qtpl:284
+//line views/stuff.qtpl:285
qw422016.N().S(`
`)
-//line views/stuff.qtpl:285
+//line views/stuff.qtpl:286
} else {
-//line views/stuff.qtpl:285
+//line views/stuff.qtpl:286
qw422016.N().S(` `)
-//line views/stuff.qtpl:286
+//line views/stuff.qtpl:287
qw422016.E().S(lc.Get("ui.about_noauth"))
-//line views/stuff.qtpl:286
+//line views/stuff.qtpl:287
qw422016.N().S(`
`)
-//line views/stuff.qtpl:287
+//line views/stuff.qtpl:288
}
-//line views/stuff.qtpl:287
+//line views/stuff.qtpl:288
qw422016.N().S(`
`)
-//line views/stuff.qtpl:289
+//line views/stuff.qtpl:290
qw422016.N().S(lc.Get("ui.about_hyphae", &l18n.Replacements{"link": "/list "}))
-//line views/stuff.qtpl:289
+//line views/stuff.qtpl:290
qw422016.N().S(`
`)
-//line views/stuff.qtpl:293
+//line views/stuff.qtpl:294
}
-//line views/stuff.qtpl:293
+//line views/stuff.qtpl:294
func WriteAboutHTML(qq422016 qtio422016.Writer, lc *l18n.Localizer) {
-//line views/stuff.qtpl:293
+//line views/stuff.qtpl:294
qw422016 := qt422016.AcquireWriter(qq422016)
-//line views/stuff.qtpl:293
+//line views/stuff.qtpl:294
StreamAboutHTML(qw422016, lc)
-//line views/stuff.qtpl:293
+//line views/stuff.qtpl:294
qt422016.ReleaseWriter(qw422016)
-//line views/stuff.qtpl:293
+//line views/stuff.qtpl:294
}
-//line views/stuff.qtpl:293
+//line views/stuff.qtpl:294
func AboutHTML(lc *l18n.Localizer) string {
-//line views/stuff.qtpl:293
+//line views/stuff.qtpl:294
qb422016 := qt422016.AcquireByteBuffer()
-//line views/stuff.qtpl:293
+//line views/stuff.qtpl:294
WriteAboutHTML(qb422016, lc)
-//line views/stuff.qtpl:293
+//line views/stuff.qtpl:294
qs422016 := string(qb422016.B)
-//line views/stuff.qtpl:293
+//line views/stuff.qtpl:294
qt422016.ReleaseByteBuffer(qb422016)
-//line views/stuff.qtpl:293
+//line views/stuff.qtpl:294
return qs422016
-//line views/stuff.qtpl:293
+//line views/stuff.qtpl:294
}
-//line views/stuff.qtpl:295
+//line views/stuff.qtpl:296
func StreamCommonScripts(qw422016 *qt422016.Writer) {
-//line views/stuff.qtpl:295
+//line views/stuff.qtpl:296
qw422016.N().S(`
`)
-//line views/stuff.qtpl:296
+//line views/stuff.qtpl:297
for _, scriptPath := range cfg.CommonScripts {
-//line views/stuff.qtpl:296
+//line views/stuff.qtpl:297
qw422016.N().S(`
`)
-//line views/stuff.qtpl:298
+//line views/stuff.qtpl:299
}
-//line views/stuff.qtpl:298
+//line views/stuff.qtpl:299
qw422016.N().S(`
`)
-//line views/stuff.qtpl:299
+//line views/stuff.qtpl:300
}
-//line views/stuff.qtpl:299
+//line views/stuff.qtpl:300
func WriteCommonScripts(qq422016 qtio422016.Writer) {
-//line views/stuff.qtpl:299
+//line views/stuff.qtpl:300
qw422016 := qt422016.AcquireWriter(qq422016)
-//line views/stuff.qtpl:299
+//line views/stuff.qtpl:300
StreamCommonScripts(qw422016)
-//line views/stuff.qtpl:299
+//line views/stuff.qtpl:300
qt422016.ReleaseWriter(qw422016)
-//line views/stuff.qtpl:299
+//line views/stuff.qtpl:300
}
-//line views/stuff.qtpl:299
+//line views/stuff.qtpl:300
func CommonScripts() string {
-//line views/stuff.qtpl:299
+//line views/stuff.qtpl:300
qb422016 := qt422016.AcquireByteBuffer()
-//line views/stuff.qtpl:299
+//line views/stuff.qtpl:300
WriteCommonScripts(qb422016)
-//line views/stuff.qtpl:299
+//line views/stuff.qtpl:300
qs422016 := string(qb422016.B)
-//line views/stuff.qtpl:299
+//line views/stuff.qtpl:300
qt422016.ReleaseByteBuffer(qb422016)
-//line views/stuff.qtpl:299
+//line views/stuff.qtpl:300
return qs422016
-//line views/stuff.qtpl:299
+//line views/stuff.qtpl:300
}
diff --git a/web/history.go b/web/history.go
index 595d19f..9c84b28 100644
--- a/web/history.go
+++ b/web/history.go
@@ -61,8 +61,14 @@ func handlerRecentChanges(w http.ResponseWriter, rq *http.Request) {
}
// genericHandlerOfFeeds is a helper function for the web feed handlers.
-func genericHandlerOfFeeds(w http.ResponseWriter, rq *http.Request, f func() (string, error), name string, contentType string) {
- if content, err := f(); err != nil {
+func genericHandlerOfFeeds(w http.ResponseWriter, rq *http.Request, f func(history.FeedOptions) (string, error), name string, contentType string) {
+ opts, err := history.ParseFeedOptions(rq.URL.Query())
+ var content string
+ if err == nil {
+ content, err = f(opts)
+ }
+
+ if err != nil {
w.Header().Set("Content-Type", "text/plain;charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, "An error while generating "+name+": "+err.Error())