diff --git a/history/feed.go b/history/feed.go
new file mode 100644
index 0000000..b3a0bd4
--- /dev/null
+++ b/history/feed.go
@@ -0,0 +1,84 @@
+package history
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/bouncepaw/mycorrhiza/cfg"
+
+ "github.com/gorilla/feeds"
+)
+
+var groupPeriod, _ = time.ParseDuration("30m")
+
+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(),
+ }
+ revs := RecentChanges(30)
+ groups := groupRevisionsByPeriod(revs, groupPeriod)
+ for _, grp := range groups {
+ item := grp.feedItem()
+ feed.Add(&item)
+ }
+ 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()
+}
+
+func (grp revisionGroup) feedItem() feeds.Item {
+ return feeds.Item{
+ Title: grp.title(),
+ Author: grp.author(),
+ Id: grp[0].Hash,
+ Description: grp.descriptionForFeed(),
+ 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() string {
+ if len(grp) == 1 {
+ return grp[0].Message
+ } else {
+ return fmt.Sprintf("%d edits (%s, ...)", len(grp), grp[0].Message)
+ }
+}
+
+func (grp revisionGroup) author() *feeds.Author {
+ author := grp[0].Username
+ for _, rev := range grp[1:] {
+ // if they don't all have the same author, return nil
+ if rev.Username != author {
+ return nil
+ }
+ }
+ return &feeds.Author{Name: author}
+}
+
+func (grp revisionGroup) descriptionForFeed() string {
+ builder := strings.Builder{}
+ for _, rev := range grp {
+ builder.WriteString(rev.descriptionForFeed())
+ }
+ return builder.String()
+}
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..fd41849
--- /dev/null
+++ b/history/revision.go
@@ -0,0 +1,255 @@
+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
+}
+
+// RecentChanges gathers an arbitrary number of latest changes in form of revisions slice, ordered most recent first.
+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 since the last commit.
+func FileChanged(path string) bool {
+ _, err := gitsh("diff", "--exit-code", path)
+ return err != nil
+}
+
+// Revisions returns slice of revisions for the given hypha name, ordered most recent first.
+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
+}
+
+// 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)
+}
+
+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
+}
+
+type revisionGroup []Revision
+
+func newRevisionGroup(rev Revision) revisionGroup {
+ return revisionGroup([]Revision{rev})
+}
+
+func (grp *revisionGroup) addRevision(rev Revision) {
+ *grp = append(*grp, rev)
+}
+
+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
+}
+
+// groupRevisionsByPeriod groups revisions by how long ago they were.
+// Revisions less than one period ago are placed in one group, revisions between one and two periods are in another, and so on.
+func groupRevisionsByPeriod(revs []Revision, period time.Duration) (res []revisionGroup) {
+ now := time.Now()
+ currentPeriod := -1
+ for _, rev := range revs {
+ newPeriod := int(now.Sub(rev.Time).Seconds() / period.Seconds())
+ if newPeriod != currentPeriod {
+ currentPeriod = newPeriod
+ res = append(res, newRevisionGroup(rev))
+ } else {
+ res[len(res)-1].addRevision(rev)
+ }
+ }
+ return res
+}
diff --git a/history/view.qtpl b/history/view.qtpl
new file mode 100644
index 0000000..b4ec298
--- /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..be51e18
--- /dev/null
+++ b/history/view.qtpl.go
@@ -0,0 +1,326 @@
+// 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/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