diff --git a/.gitignore b/.gitignore
index c450f48..245ad98 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
mycorrhiza
+config.mk
diff --git a/Makefile b/Makefile
index 4605e60..f4936cf 100644
--- a/Makefile
+++ b/Makefile
@@ -1,13 +1,15 @@
-WIKI=~/src/example-wiki
+.POSIX:
+include config.example.mk
+-include config.mk
run: build
- ./mycorrhiza ${WIKI}
+ ./mycorrhiza ${WIKIPATH}
config_run: build
- ./mycorrhiza ${WIKI}
+ ./mycorrhiza ${WIKIPATH}
devconfig_run: build
- ./mycorrhiza ${WIKI}
+ ./mycorrhiza ${WIKIPATH}
build:
go generate
diff --git a/README.md b/README.md
index 1852580..99e3ff0 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,8 @@
# 🍄 Mycorrhiza Wiki
+**Mycorrhiza Wiki** is a lightweight file-system wiki engine that uses Git for keeping history. [Main wiki](https://mycorrhiza.wiki)
-[![Go Report Card](https://goreportcard.com/badge/github.com/bouncepaw/mycorrhiza)](https://goreportcard.com/report/github.com/bouncepaw/mycorrhiza)
-
-**Mycorrhiza Wiki** is a lightweight file-system wiki engine that uses Git for keeping history.
-
-[👉 Main wiki](https://mycorrhiza.wiki)
-
## Features
* **No database required.** Everything is stored in plain files. It makes installation super easy, and you can modify the content directly by yourself.
diff --git a/cfg/config.go b/cfg/config.go
index 45f910f..9aef87d 100644
--- a/cfg/config.go
+++ b/cfg/config.go
@@ -97,8 +97,8 @@ type Authorization struct {
// Telegram is the section of Config that sets Telegram authorization.
type Telegram struct {
- TelegramBotToken string `comment:"Token of your bot.`
- TelegramBotName string `comment:"Username of your bot, sans @.`
+ TelegramBotToken string `comment:"Token of your bot."`
+ TelegramBotName string `comment:"Username of your bot, sans @."`
}
// ReadConfigFile reads a config on the given path and stores the
diff --git a/config.example.mk b/config.example.mk
new file mode 100644
index 0000000..2f65bdc
--- /dev/null
+++ b/config.example.mk
@@ -0,0 +1 @@
+WIKIPATH=~/src/example-wiki
diff --git a/flag.go b/flag.go
index fa0d4b2..a4cae5d 100644
--- a/flag.go
+++ b/flag.go
@@ -74,7 +74,7 @@ func createAdminCommand(name string) {
user.InitUserDatabase()
log.SetOutput(wr)
- handle := int(syscall.Stdin)
+ handle := syscall.Stdin
if !term.IsTerminal(handle) {
log.Fatal("error: not a terminal")
}
diff --git a/go.mod b/go.mod
index a3b27f1..3b57b11 100644
--- a/go.mod
+++ b/go.mod
@@ -9,7 +9,7 @@ require (
github.com/gorilla/mux v1.8.0
github.com/kr/pretty v0.2.1 // indirect
github.com/smartystreets/goconvey v1.6.4 // indirect
- github.com/valyala/quicktemplate v1.6.3
+ github.com/valyala/quicktemplate v1.7.0
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b
diff --git a/go.sum b/go.sum
index 2259ca7..b56bc00 100644
--- a/go.sum
+++ b/go.sum
@@ -3,6 +3,7 @@ github.com/bouncepaw/mycomarkup/v3 v3.2.1 h1:Gtot9+3Ds0rqQ+T9BvBIMYgYn13vL9L1yoz
github.com/bouncepaw/mycomarkup/v3 v3.2.1/go.mod h1:BpiGUVsYCgRZCDxF0iIdc08LJokm/Ab36S/Hif0J6D0=
github.com/go-ini/ini v1.62.0 h1:7VJT/ZXjzqSrvtraFp4ONq80hTcRQth1c9ZnQ3uNQvU=
github.com/go-ini/ini v1.62.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
+github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY=
@@ -11,8 +12,8 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
-github.com/klauspost/compress v1.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
-github.com/klauspost/compress v1.11.0/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
+github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
+github.com/klauspost/compress v1.13.5/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@@ -24,20 +25,21 @@ github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIK
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasthttp v1.16.0/go.mod h1:YOKImeEosDdBPnxc0gy7INqi3m1zK6A+xl6TwOBhHCA=
-github.com/valyala/quicktemplate v1.6.3 h1:O7EuMwuH7Q94U2CXD6sOX8AYHqQqWtmIk690IhmpkKA=
-github.com/valyala/quicktemplate v1.6.3/go.mod h1:fwPzK2fHuYEODzJ9pkw0ipCPNHZ2tD5KW4lOuSdPKzY=
-github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
+github.com/valyala/fasthttp v1.30.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD3K/7o2Cus=
+github.com/valyala/quicktemplate v1.7.0 h1:LUPTJmlVcb46OOUY3IeD9DojFpAVbsG+5WFTcjMJzCM=
+github.com/valyala/quicktemplate v1.7.0/go.mod h1:sqKJnoaOF88V07vkO+9FL8fb9uZg/VPSJnLYn+LmLk8=
+github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 h1:/UOmuWzQfxxo9UtlXMwuQU8CMgg1eZXqTRwkSQJWKOI=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -46,9 +48,14 @@ golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b h1:9zKuko04nR4gjZ4+DNjHqRlA
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU=
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo=
+gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/help/en.myco b/help/en.myco
index 1eb081d..fd84239 100644
--- a/help/en.myco
+++ b/help/en.myco
@@ -1,6 +1,6 @@
# Help
-This is documentation for **Mycorrhiza Wiki** version **1.5** ([[https://mycorrhiza.wiki | official wiki]]).
+This is documentation for **Mycorrhiza Wiki** version **1.6** ([[https://mycorrhiza.wiki | official wiki]]).
**Choose a topic from the list.**
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/help/ru.myco b/help/ru.myco
index aead2f4..5b1420f 100644
--- a/help/ru.myco
+++ b/help/ru.myco
@@ -1,6 +1,6 @@
# Справка
-Это документация к **вики-движку Микориза** версии **1.5** ([[https://mycorrhiza.wiki | официальная вики]]).
+Это документация к **вики-движку Микориза** версии **1.6** ([[https://mycorrhiza.wiki | официальная вики]]).
**Выберите тему из списка.**
diff --git a/help/ru/feeds.myco b/help/ru/feeds.myco
new file mode 100644
index 0000000..b71ae6f
--- /dev/null
+++ b/help/ru/feeds.myco
@@ -0,0 +1,41 @@
+# Справка: Ленты
+В Микоризе можно подписаться на недавние изменения на вики через RSS, Atom или JSON-ленту.
+
+Ссылки на все три ленты есть на [[/recent-changes | странице недавних изменений]].
+
+## Опции
+Можно настроить группировку правок в лентах при помощи опций в URL:
+* {
+ **period** — период, например: `5m`, `24h`...
+
+ Правки от одного и того же автора, которые совершены в течение этого времени будут обьединены в одну запись в ленте.
+}
+* {
+ **same** — свойство, по которому обьединяются правки в ленте: `author`, `message` или `none`.
+
+ По умолчанию, у правок должны быть одинаковые авторы и описания. Вы можете сделать так, чтобы обьединялись только по автору (`author`) или только по описанию (`message`). Если задать `none`, все правки могут быть сгруппированы.
+}
+* {
+ **order** — порядок: от старого к новому `old-to-now` (по умолчанию) или от нового к старому `new-to-old`.
+}
+
+Если ничего не настраивать, то правки не будут группироваться.
+
+## Примеры
+URL для лент с использованием этих опций выглядят так:
+* {
+ `/recent-changes-rss?period=1h`
+ Правки за час от одного автора с одинаковым описанием будут сгруппированы.
+}
+* {
+ `/recent-changes-atom?period=1h&order=new-to-old`
+ То же самое, но в обратном порядке.
+}
+* {
+ `/recent-changes-atom?period=1h&same=none`
+ Любые правки в течение одного часа будут обьединены.
+}
+* {
+ `/recent-changes-atom?same=author&same=message`
+ Правки от одного автора и с одинаковым описаниемм будут группироваться независимо от времени между ними.
+}
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..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/hyphae/files.go b/hyphae/files.go
index 88d0525..cae0113 100644
--- a/hyphae/files.go
+++ b/hyphae/files.go
@@ -6,7 +6,6 @@ import (
"path/filepath"
"github.com/bouncepaw/mycorrhiza/mimetype"
- "github.com/bouncepaw/mycorrhiza/util"
)
// Index finds all hypha files in the full `path` and saves them to the hypha storage.
@@ -20,7 +19,7 @@ func Index(path string) {
}(ch)
for h := range ch {
- // At this time it is safe to ignore the mutex, because there is only one worker.
+ // It's safe to ignore the mutex because there is a single worker right now.
if oh := ByName(h.Name); oh.Exists {
oh.MergeIn(h)
} else {
@@ -32,7 +31,9 @@ func Index(path string) {
log.Println("Indexed", Count(), "hyphae")
}
-// indexHelper finds all hypha files in the full `path` and sends them to the channel. Handling of duplicate entries and attachment and counting them is up to the caller.
+// indexHelper finds all hypha files in the full `path` and sends them to the
+// channel. Handling of duplicate entries and attachment and counting them is
+// up to the caller.
func indexHelper(path string, nestLevel uint, ch chan *Hypha) {
nodes, err := os.ReadDir(path)
if err != nil {
@@ -40,10 +41,10 @@ func indexHelper(path string, nestLevel uint, ch chan *Hypha) {
}
for _, node := range nodes {
- // If this hypha looks like it can be a hypha path, go deeper. Do not touch the .git and static folders for they have an administrative importance!
- if node.IsDir() &&
- util.IsCanonicalName(node.Name()) &&
- node.Name() != ".git" &&
+ // If this hypha looks like it can be a hypha path, go deeper. Do not
+ // touch the .git and static folders for they have an administrative
+ // importance!
+ if node.IsDir() && IsValidName(node.Name()) && node.Name() != ".git" &&
!(nestLevel == 0 && node.Name() == "static") {
indexHelper(filepath.Join(path, node.Name()), nestLevel+1, ch)
continue
diff --git a/hyphae/hyphae.go b/hyphae/hyphae.go
index d7a1875..e31788a 100644
--- a/hyphae/hyphae.go
+++ b/hyphae/hyphae.go
@@ -2,16 +2,31 @@
package hyphae
import (
- "github.com/bouncepaw/mycorrhiza/files"
"log"
"path/filepath"
"regexp"
+ "strings"
"sync"
+
+ "github.com/bouncepaw/mycorrhiza/files"
)
-// HyphaPattern is a pattern which all hyphae must match.
+// HyphaPattern is a pattern which all hyphae names must match.
var HyphaPattern = regexp.MustCompile(`[^?!:#@><*|"'&%{}]+`)
+// IsValidName checks for invalid characters and path traversals.
+func IsValidName(hyphaName string) bool {
+ if !HyphaPattern.MatchString(hyphaName) {
+ return false
+ }
+ for _, segment := range strings.Split(hyphaName, "/") {
+ if segment == ".git" || segment == ".." {
+ return false
+ }
+ }
+ return true
+}
+
// Hypha keeps vital information about a hypha
type Hypha struct {
sync.RWMutex
diff --git a/hyphae/iterators.go b/hyphae/iterators.go
index e2d6749..ca89d08 100644
--- a/hyphae/iterators.go
+++ b/hyphae/iterators.go
@@ -79,7 +79,7 @@ func PathographicSort(src chan string) <-chan string {
// Subhyphae returns slice of subhyphae.
func (h *Hypha) Subhyphae() []*Hypha {
- hyphae := []*Hypha{}
+ var hyphae []*Hypha
for subh := range YieldExistingHyphae() {
if strings.HasPrefix(subh.Name, h.Name+"/") {
hyphae = append(hyphae, subh)
diff --git a/l18n/l18n.go b/l18n/l18n.go
index 53f82cf..dd94822 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",
@@ -123,6 +124,18 @@ var localizations = map[string]string{
"en.ui.about_title": "About {{.name}}",
"en.ui.about_usercount": "User count:",
"en.ui.about_version": "{{.pre}}Mycorrhiza Wiki{{.post}} version:",
+ "en.ui.act_noattachment": "No attachment",
+ "en.ui.act_noattachment_tip": "Cannot unattach this hypha because it has no attachment",
+ "en.ui.act_norights": "Not enough rights",
+ "en.ui.act_norights_attach": "You must be an editor to attach a hypha",
+ "en.ui.act_norights_delete": "Not enough rights to delete, you must be a moderator",
+ "en.ui.act_norights_edit": "You must be an editor to edit a hypha",
+ "en.ui.act_norights_rename": "Not enough rights to rename, you must be a trusted editor",
+ "en.ui.act_norights_unattach": "Not enough rights to unattach, you must be a trusted editor",
+ "en.ui.act_notexist": "Does not exist",
+ "en.ui.act_notexist_delete": "Cannot delete this hypha because it does not exist",
+ "en.ui.act_notexist_rename": "Cannot rename this hypha because it does not exist",
+ "en.ui.act_notexist_unattach": "Cannot unattach this hypha because it does not exist",
"en.ui.admin_panel": "Admin panel",
"en.ui.ask_delete": "Delete %s?",
"en.ui.ask_delete_tip": "In this version of Mycorrhiza Wiki you cannot undelete a deleted hypha but the history can still be accessed.",
@@ -205,8 +218,14 @@ var localizations = map[string]string{
"en.ui.recent_title+other": "s",
"en.ui.register": "Register",
"en.ui.reindex_no_rights": "You must be an admin to reindex hyphae.",
+ "en.ui.rename_badname": "Invalid name",
+ "en.ui.rename_badname_tip": "Invalid new name. Names cannot contain characters {{.chars}}.",
"en.ui.rename_link": "Rename",
+ "en.ui.rename_noname": "No name given",
+ "en.ui.rename_noname_tip": "No new name is given",
"en.ui.rename_recurse": "Rename subhyphae too",
+ "en.ui.rename_taken": "Name taken",
+ "en.ui.rename_taken_tip": "Hypha named {{.name}} already exists, cannot rename",
"en.ui.rename_tip": "If you rename this hypha, all incoming links and all relative outcoming links will break. You will also lose all history for the new name. Rename carefully.",
"en.ui.rename_to": "New name",
"en.ui.revision_link": "Get Mycomarkup source of this revision",
@@ -317,6 +336,7 @@ var localizations = map[string]string{
"ru.help.empty_error_link": "репозитории",
"ru.help.empty_error_title": "Этой страницы не существует!",
"ru.help.entry_not_found": "Запись не найдена",
+ "ru.help.feeds": "Ленты",
"ru.help.hypha": "Гифа",
"ru.help.interface": "Интерфейс",
"ru.help.lock": "Блокировка",
@@ -338,6 +358,18 @@ var localizations = map[string]string{
"ru.ui.about_title": "О вики «{{.name}}»",
"ru.ui.about_usercount": "Число пользователей:",
"ru.ui.about_version": "Версия {{.pre}}Микоризы{{.post}}:",
+ "ru.ui.act_noattachment": "Нет вложения",
+ "ru.ui.act_noattachment_tip": "Не могу открепить гифу, потому что в ней нет вложения",
+ "ru.ui.act_norights": "Недостаточно прав",
+ "ru.ui.act_norights_attach": "Вы должны быть редактором, чтобы прикреплять гифы",
+ "ru.ui.act_norights_delete": "Недостаточно прав для удаления, вы должны быть модератором",
+ "ru.ui.act_norights_edit": "Вы должны быть редактором, чтобы редактировать гифы",
+ "ru.ui.act_norights_rename": "Недостаточно прав для переименования, вы должны быть доверенным редактором",
+ "ru.ui.act_norights_unattach": "Недостаточно прав для открепления, вы должны быть доверенным редактором",
+ "ru.ui.act_notexist": "Не существует",
+ "ru.ui.act_notexist_delete": "Нельзя удалить эту гифу, потому что она не существует",
+ "ru.ui.act_notexist_rename": "Нельзя переименовать эту гифу, потому что она не существует",
+ "ru.ui.act_notexist_unattach": "Нельзя открепить эту гифу, потому что она не существует",
"ru.ui.admin_panel": "Администрирование",
"ru.ui.ask_delete": "Удалить «%s»?",
"ru.ui.ask_delete_tip": "В этой версии Микоризы нельзя отменить удаление гифы, но её история останется доступной.",
@@ -424,8 +456,14 @@ var localizations = map[string]string{
"ru.ui.recent_title+one": "недавнее изменение",
"ru.ui.register": "Регистрация",
"ru.ui.reindex_no_rights": "Вы должны быть администратором, чтобы переиндексировать гифы.",
+ "ru.ui.rename_badname": "Некорректное название",
+ "ru.ui.rename_badname_tip": "Новое название некорректно. Названия не могут содержать символы {{.chars}}.",
"ru.ui.rename_link": "Переименовать",
+ "ru.ui.rename_noname": "Нет названия",
+ "ru.ui.rename_noname_tip": "Не задано новое название",
"ru.ui.rename_recurse": "Также переименовать подгифы",
+ "ru.ui.rename_taken": "Название занято",
+ "ru.ui.rename_taken_tip": "Гифа {{.name}} уже существует, не могу переименовать",
"ru.ui.rename_tip": "Если вы переименуете эту гифу, сломаются все ссылки, ведущие на неё, а также исходящие относительные ссылки. Также вы потеряете всю текущую историю для нового названия. Переименовывайте аккуратно.",
"ru.ui.rename_to": "Новое название",
"ru.ui.revision_link": "Посмотреть код микоразметки для этой ревизии",
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/l18n_src/en/ui.json b/l18n_src/en/ui.json
index 7126c98..ac59aff 100644
--- a/l18n_src/en/ui.json
+++ b/l18n_src/en/ui.json
@@ -43,6 +43,25 @@
"rename_to": "New name",
"rename_recurse": "Rename subhyphae too",
"rename_tip": "If you rename this hypha, all incoming links and all relative outcoming links will break. You will also lose all history for the new name. Rename carefully.",
+ "rename_taken": "Name taken",
+ "rename_taken_tip": "Hypha named {{.name}} already exists, cannot rename",
+ "rename_noname": "No name given",
+ "rename_noname_tip": "No new name is given",
+ "rename_badname": "Invalid name",
+ "rename_badname_tip": "Invalid new name. Names cannot contain characters {{.chars}}.",
+
+ "act_noattachment": "No attachment",
+ "act_noattachment_tip": "Cannot unattach this hypha because it has no attachment",
+ "act_norights": "Not enough rights",
+ "act_notexist": "Does not exist",
+ "act_norights_delete": "Not enough rights to delete, you must be a moderator",
+ "act_notexist_delete": "Cannot delete this hypha because it does not exist",
+ "act_norights_rename": "Not enough rights to rename, you must be a trusted editor",
+ "act_notexist_rename": "Cannot rename this hypha because it does not exist",
+ "act_norights_unattach": "Not enough rights to unattach, you must be a trusted editor",
+ "act_notexist_unattach": "Cannot unattach this hypha because it does not exist",
+ "act_norights_edit": "You must be an editor to edit a hypha",
+ "act_norights_attach": "You must be an editor to attach a hypha",
"ask_delete": "Delete %s?",
"ask_delete_tip": "In this version of Mycorrhiza Wiki you cannot undelete a deleted hypha but the history can still be accessed.",
diff --git a/l18n_src/ru/help.json b/l18n_src/ru/help.json
index 5f24784..3508333 100644
--- a/l18n_src/ru/help.json
+++ b/l18n_src/ru/help.json
@@ -18,6 +18,7 @@
"sibling_hyphae": "Гифы-сиблинги",
"special_pages": "Специальные страницы",
"recent_changes": "Недавние изменения",
+ "feeds": "Ленты",
"configuration": "Конфигурация (для администраторов)",
"lock": "Блокировка",
"whitelist": "Белый список",
diff --git a/l18n_src/ru/ui.json b/l18n_src/ru/ui.json
index 949e2e5..27a4555 100644
--- a/l18n_src/ru/ui.json
+++ b/l18n_src/ru/ui.json
@@ -45,6 +45,25 @@
"rename_to": "Новое название",
"rename_recurse": "Также переименовать подгифы",
"rename_tip": "Если вы переименуете эту гифу, сломаются все ссылки, ведущие на неё, а также исходящие относительные ссылки. Также вы потеряете всю текущую историю для нового названия. Переименовывайте аккуратно.",
+ "rename_taken": "Название занято",
+ "rename_taken_tip": "Гифа {{.name}} уже существует, не могу переименовать",
+ "rename_noname": "Нет названия",
+ "rename_noname_tip": "Не задано новое название",
+ "rename_badname": "Некорректное название",
+ "rename_badname_tip": "Новое название некорректно. Названия не могут содержать символы {{.chars}}.",
+
+ "act_noattachment": "Нет вложения",
+ "act_noattachment_tip": "Не могу открепить гифу, потому что в ней нет вложения",
+ "act_norights": "Недостаточно прав",
+ "act_notexist": "Не существует",
+ "act_norights_delete": "Недостаточно прав для удаления, вы должны быть модератором",
+ "act_norights_rename": "Недостаточно прав для переименования, вы должны быть доверенным редактором",
+ "act_norights_unattach": "Недостаточно прав для открепления, вы должны быть доверенным редактором",
+ "act_norights_edit": "Вы должны быть редактором, чтобы редактировать гифы",
+ "act_norights_attach": "Вы должны быть редактором, чтобы прикреплять гифы",
+ "act_notexist_delete": "Нельзя удалить эту гифу, потому что она не существует",
+ "act_notexist_rename": "Нельзя переименовать эту гифу, потому что она не существует",
+ "act_notexist_unattach": "Нельзя открепить эту гифу, потому что она не существует",
"ask_delete": "Удалить «%s»?",
"ask_delete_tip": "В этой версии Микоризы нельзя отменить удаление гифы, но её история останется доступной.",
diff --git a/main.go b/main.go
index 62466ff..5cc568e 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/shroom/can.go b/shroom/can.go
index 0cf61db..7db6430 100644
--- a/shroom/can.go
+++ b/shroom/can.go
@@ -4,32 +4,33 @@ import (
"errors"
"github.com/bouncepaw/mycorrhiza/hyphae"
+ "github.com/bouncepaw/mycorrhiza/l18n"
"github.com/bouncepaw/mycorrhiza/user"
)
func canFactory(
rejectLogger func(*hyphae.Hypha, *user.User, string),
action string,
- dispatcher func(*hyphae.Hypha, *user.User) (string, string),
+ dispatcher func(*hyphae.Hypha, *user.User, *l18n.Localizer) (string, string),
noRightsMsg string,
notExistsMsg string,
- careAboutExistence bool,
-) func(*user.User, *hyphae.Hypha) (string, error) {
- return func(u *user.User, h *hyphae.Hypha) (string, error) {
+ mustExist bool,
+) func(*user.User, *hyphae.Hypha, *l18n.Localizer) (string, error) {
+ return func(u *user.User, h *hyphae.Hypha, lc *l18n.Localizer) (string, error) {
if !u.CanProceed(action) {
rejectLogger(h, u, "no rights")
- return "Not enough rights", errors.New(noRightsMsg)
+ return lc.Get("ui.act_no_rights"), errors.New(lc.Get(noRightsMsg))
}
- if careAboutExistence && !h.Exists {
+ if mustExist && !h.Exists {
rejectLogger(h, u, "does not exist")
- return "Does not exist", errors.New(notExistsMsg)
+ return lc.Get("ui.act_notexist"), errors.New(lc.Get(notExistsMsg))
}
if dispatcher == nil {
return "", nil
}
- errmsg, errtitle := dispatcher(h, u)
+ errmsg, errtitle := dispatcher(h, u, lc)
if errtitle == "" {
return "", nil
}
@@ -43,8 +44,8 @@ var (
rejectDeleteLog,
"delete-confirm",
nil,
- "Not enough rights to delete, you must be a moderator",
- "Cannot delete this hypha because it does not exist",
+ "ui.act_norights_delete",
+ "ui.act_notexist_delete",
true,
)
@@ -52,24 +53,24 @@ var (
rejectRenameLog,
"rename-confirm",
nil,
- "Not enough rights to rename, you must be a trusted editor",
- "Cannot rename this hypha because it does not exist",
+ "ui.act_norights_rename",
+ "ui.act_notexist_rename",
true,
)
CanUnattach = canFactory(
rejectUnattachLog,
"unattach-confirm",
- func(h *hyphae.Hypha, u *user.User) (errmsg, errtitle string) {
+ func(h *hyphae.Hypha, u *user.User, lc *l18n.Localizer) (errmsg, errtitle string) {
if h.BinaryPath == "" {
rejectUnattachLog(h, u, "no amnt")
- return "Cannot unattach this hypha because it has no attachment", "No attachment"
+ return lc.Get("ui.act_noattachment_tip"), lc.Get("ui.act_noattachment")
}
return "", ""
},
- "Not enough rights to unattach, you must be a trusted editor",
- "Cannot unattach this hypha because it does not exist",
+ "ui.act_norights_unattach",
+ "ui.act_notexist_unattach",
true,
)
@@ -77,7 +78,7 @@ var (
rejectEditLog,
"upload-text",
nil,
- "You must be an editor to edit a hypha",
+ "ui.act_norights_edit",
"You cannot edit a hypha that does not exist",
false,
)
@@ -86,8 +87,10 @@ var (
rejectAttachLog,
"upload-binary",
nil,
- "You must be an editor to attach a hypha",
+ "ui.act_norights_attach",
"You cannot attach a hypha that does not exist",
false,
)
)
+
+/* I've left 'not exists' messages for edit and attach out of translation as they are not used -- chekoopa */
diff --git a/shroom/delete.go b/shroom/delete.go
index 05c5c68..fcacb7a 100644
--- a/shroom/delete.go
+++ b/shroom/delete.go
@@ -5,14 +5,15 @@ import (
"github.com/bouncepaw/mycorrhiza/history"
"github.com/bouncepaw/mycorrhiza/hyphae"
+ "github.com/bouncepaw/mycorrhiza/l18n"
"github.com/bouncepaw/mycorrhiza/user"
)
// DeleteHypha deletes hypha and makes a history record about that.
-func DeleteHypha(u *user.User, h *hyphae.Hypha) (hop *history.Op, errtitle string) {
+func DeleteHypha(u *user.User, h *hyphae.Hypha, lc *l18n.Localizer) (hop *history.Op, errtitle string) {
hop = history.Operation(history.TypeDeleteHypha)
- if errtitle, err := CanDelete(u, h); errtitle != "" {
+ if errtitle, err := CanDelete(u, h, lc); errtitle != "" {
hop.WithErrAbort(err)
return hop, errtitle
}
diff --git a/shroom/rename.go b/shroom/rename.go
index 823ca99..d68897d 100644
--- a/shroom/rename.go
+++ b/shroom/rename.go
@@ -7,40 +7,41 @@ import (
"github.com/bouncepaw/mycorrhiza/history"
"github.com/bouncepaw/mycorrhiza/hyphae"
+ "github.com/bouncepaw/mycorrhiza/l18n"
"github.com/bouncepaw/mycorrhiza/user"
"github.com/bouncepaw/mycorrhiza/util"
)
-func canRenameThisToThat(oh *hyphae.Hypha, nh *hyphae.Hypha, u *user.User) (errtitle string, err error) {
+func canRenameThisToThat(oh *hyphae.Hypha, nh *hyphae.Hypha, u *user.User, lc *l18n.Localizer) (errtitle string, err error) {
if nh.Exists {
rejectRenameLog(oh, u, fmt.Sprintf("name ‘%s’ taken already", nh.Name))
- return "Name taken", fmt.Errorf("Hypha named %[1]s already exists, cannot rename", nh.Name)
+ return lc.Get("ui.rename_taken"), fmt.Errorf(lc.Get("ui.rename_taken_tip", &l18n.Replacements{"name": "%[1]s "}), nh.Name)
}
if nh.Name == "" {
rejectRenameLog(oh, u, "no new name given")
- return "No name given", errors.New("No new name is given")
+ return lc.Get("ui.rename_noname"), errors.New(lc.Get("ui.rename_noname_tip"))
}
- if !hyphae.HyphaPattern.MatchString(nh.Name) {
+ if !hyphae.IsValidName(nh.Name) {
rejectRenameLog(oh, u, fmt.Sprintf("new name ‘%s’ invalid", nh.Name))
- return "Invalid name", errors.New("Invalid new name. Names cannot contain characters ^?!:#@><*|\"\\'&%
")
+ return lc.Get("ui.rename_badname"), errors.New(lc.Get("ui.rename_badname_tip", &l18n.Replacements{"chars": "^?!:#@><*|\"\\'&%
"}))
}
return "", nil
}
// RenameHypha renames hypha from old name `hyphaName` to `newName` and makes a history record about that. If `recursive` is `true`, its subhyphae will be renamed the same way.
-func RenameHypha(h *hyphae.Hypha, newHypha *hyphae.Hypha, recursive bool, u *user.User) (hop *history.Op, errtitle string) {
+func RenameHypha(h *hyphae.Hypha, newHypha *hyphae.Hypha, recursive bool, u *user.User, lc *l18n.Localizer) (hop *history.Op, errtitle string) {
newHypha.Lock()
defer newHypha.Unlock()
hop = history.Operation(history.TypeRenameHypha)
- if errtitle, err := CanRename(u, h); errtitle != "" {
+ if errtitle, err := CanRename(u, h, lc); errtitle != "" {
hop.WithErrAbort(err)
return hop, errtitle
}
- if errtitle, err := canRenameThisToThat(h, newHypha, u); errtitle != "" {
+ if errtitle, err := canRenameThisToThat(h, newHypha, u, lc); errtitle != "" {
hop.WithErrAbort(err)
return hop, errtitle
}
diff --git a/shroom/unattach.go b/shroom/unattach.go
index 4514e9b..02bd9a7 100644
--- a/shroom/unattach.go
+++ b/shroom/unattach.go
@@ -5,14 +5,15 @@ import (
"github.com/bouncepaw/mycorrhiza/history"
"github.com/bouncepaw/mycorrhiza/hyphae"
+ "github.com/bouncepaw/mycorrhiza/l18n"
"github.com/bouncepaw/mycorrhiza/user"
)
// UnattachHypha unattaches hypha and makes a history record about that.
-func UnattachHypha(u *user.User, h *hyphae.Hypha) (hop *history.Op, errtitle string) {
+func UnattachHypha(u *user.User, h *hyphae.Hypha, lc *l18n.Localizer) (hop *history.Op, errtitle string) {
hop = history.Operation(history.TypeUnattachHypha)
- if errtitle, err := CanUnattach(u, h); errtitle != "" {
+ if errtitle, err := CanUnattach(u, h, lc); errtitle != "" {
hop.WithErrAbort(err)
return hop, errtitle
}
diff --git a/shroom/upload.go b/shroom/upload.go
index ed8b4f5..176eb49 100644
--- a/shroom/upload.go
+++ b/shroom/upload.go
@@ -14,12 +14,13 @@ import (
"github.com/bouncepaw/mycorrhiza/files"
"github.com/bouncepaw/mycorrhiza/history"
"github.com/bouncepaw/mycorrhiza/hyphae"
+ "github.com/bouncepaw/mycorrhiza/l18n"
"github.com/bouncepaw/mycorrhiza/mimetype"
"github.com/bouncepaw/mycorrhiza/user"
)
// UploadText edits a hypha' text part and makes a history record about that.
-func UploadText(h *hyphae.Hypha, data []byte, message string, u *user.User) (hop *history.Op, errtitle string) {
+func UploadText(h *hyphae.Hypha, data []byte, message string, u *user.User, lc *l18n.Localizer) (hop *history.Op, errtitle string) {
hop = history.Operation(history.TypeEditText)
var action string
if h.Exists {
@@ -34,7 +35,7 @@ func UploadText(h *hyphae.Hypha, data []byte, message string, u *user.User) (hop
hop.WithMsg(fmt.Sprintf("%s ‘%s’: %s", action, h.Name, message))
}
- if errtitle, err := CanEdit(u, h); err != nil {
+ if errtitle, err := CanEdit(u, h, lc); err != nil {
return hop.WithErrAbort(err), errtitle
}
if len(bytes.TrimSpace(data)) == 0 && h.BinaryPath == "" {
@@ -45,7 +46,7 @@ func UploadText(h *hyphae.Hypha, data []byte, message string, u *user.User) (hop
}
// UploadBinary edits a hypha' attachment and makes a history record about that.
-func UploadBinary(h *hyphae.Hypha, mime string, file multipart.File, u *user.User) (*history.Op, string) {
+func UploadBinary(h *hyphae.Hypha, mime string, file multipart.File, u *user.User, lc *l18n.Localizer) (*history.Op, string) {
var (
hop = history.Operation(history.TypeEditBinary).WithMsg(fmt.Sprintf("Upload attachment for ‘%s’ with type ‘%s’", h.Name, mime))
data, err = io.ReadAll(file)
@@ -54,7 +55,7 @@ func UploadBinary(h *hyphae.Hypha, mime string, file multipart.File, u *user.Use
if err != nil {
return hop.WithErrAbort(err), err.Error()
}
- if errtitle, err := CanAttach(u, h); err != nil {
+ if errtitle, err := CanAttach(u, h, lc); err != nil {
return hop.WithErrAbort(err), errtitle
}
if len(data) == 0 {
@@ -71,8 +72,7 @@ func uploadHelp(h *hyphae.Hypha, hop *history.Op, ext string, data []byte, u *us
originalFullPath = &h.TextPath
originalText = "" // for backlink update
)
- // Reject if the path is outside the hyphae dir
- if !strings.HasPrefix(fullPath, files.HyphaeDir()) {
+ if !isValidPath(fullPath) || !hyphae.IsValidName(h.Name) {
err := errors.New("bad path")
return hop.WithErrAbort(err), err.Error()
}
@@ -109,3 +109,7 @@ func uploadHelp(h *hyphae.Hypha, hop *history.Op, ext string, data []byte, u *us
}
return hop.WithFiles(fullPath).WithUser(u).Apply(), ""
}
+
+func isValidPath(pathname string) bool {
+ return strings.HasPrefix(pathname, files.HyphaeDir())
+}
diff --git a/static/default.css b/static/default.css
index 7c8ffeb..156fffe 100644
--- a/static/default.css
+++ b/static/default.css
@@ -1,6 +1,9 @@
-.non-existent-hypha { }
-.non-existent-hypha__ways { display: flex; flex-direction: column; width: 100%; margin: 0 0 1rem 0;}
-.non-existent-hypha__way { border: 1px #999 solid; border-radius: .25rem; padding: .25rem; }
+.non-existent-hypha__way {
+ margin-top: 0.5rem;
+ margin-bottom: 2rem;
+ padding-right: .25rem;
+}
+
.non-existent-hypha__title { margin-bottom: 1rem; }
.non-existent-hypha__subtitle { margin: 0; }
@@ -32,9 +35,16 @@ header { width: 100%; margin-bottom: 1rem; }
}
@media screen and (min-width: 500px) {
- .non-existent-hypha__way { flex: 1; margin-right: .5rem; }
- .non-existent-hypha__ways { flex-direction: row; }
- .non-existent-hypha__way:last-child { margin-right: 0; }
+ .non-existent-hypha__way {
+ float: left;
+ width: 50%;
+ }
+
+ .non-existent-hypha__ways::after {
+ content: '';
+ display: block;
+ clear: both;
+ }
}
/* No longer a phone but still small screen: center main */
@@ -80,7 +90,7 @@ textarea {font-size:16px; font-family: inherit; line-height: 150%; }
.edit { min-height: 80vh; }
.edit__title { margin-top: 0; }
-.edit__preview { border: 2px dashed #ddd; }
+.edit__preview { border: 2px dashed #ddd; padding: 10px; }
.edit-form__textarea { width: 100%; height: calc(100% - 8rem); min-height: 4rem; }
.edit-form__message { width: 100%; margin: 0.25em 0; }
.edit-form__save { font-weight: bold; }
@@ -250,6 +260,13 @@ table { border: 0; background-color: #444444; color: #ddd; }
mark { background: rgba(130, 80, 30, 5); color: inherit; }
}
+/*
+ * Shortcuts
+ */
+template {
+ display: none;
+}
+
kbd {
display: inline-block;
min-width: 1.5ch;
diff --git a/static/editor.js b/static/editor.js
index 79f8973..ca0e70e 100644
--- a/static/editor.js
+++ b/static/editor.js
@@ -1,21 +1,19 @@
-(function () {
- window.hyphaChanged = false;
- let textarea = document.querySelector('.edit-form__textarea');
- let form = document.querySelector('.edit-form');
+window.hyphaChanged = false;
+let textarea = document.querySelector('.edit-form__textarea');
+let form = document.querySelector('.edit-form');
- let warnBeforeClosing = function (ev) {
- if (!window.hyphaChanged) return;
- ev.preventDefault();
- return ev.returnValue = 'Are you sure you want to exit? You have unsaved changes.';
- };
+let warnBeforeClosing = function (ev) {
+ if (!window.hyphaChanged) return;
+ ev.preventDefault();
+ return ev.returnValue = 'Are you sure you want to exit? You have unsaved changes.';
+};
- textarea.addEventListener('input', function () {
- window.hyphaChanged = true;
- });
+textarea.addEventListener('input', function () {
+ window.hyphaChanged = true;
+});
- form.addEventListener('submit', function () {
- window.hyphaChanged = false;
- });
+form.addEventListener('submit', function () {
+ window.hyphaChanged = false;
+});
- window.addEventListener('beforeunload', warnBeforeClosing);
-})();
+window.addEventListener('beforeunload', warnBeforeClosing);
diff --git a/static/shortcuts.js b/static/shortcuts.js
index 6b18ff4..8b4a385 100644
--- a/static/shortcuts.js
+++ b/static/shortcuts.js
@@ -1,367 +1,367 @@
-(() => {
- const $ = document.querySelector.bind(document);
- const $$ = (...args) => Array.prototype.slice.call(document.querySelectorAll(...args));
+const $ = document.querySelector.bind(document);
+const $$ = (...args) => Array.prototype.slice.call(document.querySelectorAll(...args));
- const isMac = /Macintosh/.test(window.navigator.userAgent);
+const isMac = /Macintosh/.test(window.navigator.userAgent);
- function keyEventToShortcut(event) {
- let elideShift = event.key.toUpperCase() === event.key && event.shiftKey;
- return (event.ctrlKey ? 'Ctrl+' : '') +
- (event.altKey ? 'Alt+' : '') +
- (event.metaKey ? 'Meta+' : '') +
- (!elideShift && event.shiftKey ? 'Shift+' : '') +
- (event.key === ',' ? 'Comma' : event.key === ' ' ? 'Space' : event.key);
+function keyEventToShortcut(event) {
+ let elideShift = event.key.toUpperCase() === event.key && event.shiftKey;
+ return (event.ctrlKey ? 'Ctrl+' : '') +
+ (event.altKey ? 'Alt+' : '') +
+ (event.metaKey ? 'Meta+' : '') +
+ (!elideShift && event.shiftKey ? 'Shift+' : '') +
+ (event.key === ',' ? 'Comma' : event.key === ' ' ? 'Space' : event.key);
+}
+
+function prettifyShortcut(shortcut) {
+ let keys = shortcut.split('+');
+
+ if (isMac) {
+ let cmdIdx = keys.indexOf('Meta');
+ if (cmdIdx !== -1 && keys.length - cmdIdx > 2) {
+ let tmp = keys[cmdIdx + 1];
+ keys[cmdIdx + 1] = 'Meta';
+ keys[cmdIdx] = tmp;
+ }
}
- function prettifyShortcut(shortcut) {
- let keys = shortcut.split('+');
+ let lastKey = keys[keys.length - 1];
+ if (!keys.includes('Shift') && lastKey.toUpperCase() === lastKey && lastKey.toLowerCase() !== lastKey) {
+ keys.splice(keys.length - 1, 0, 'Shift');
+ }
+ for (let i = 0; i < keys.length; i++) {
if (isMac) {
- let cmdIdx = keys.indexOf('Meta');
- if (cmdIdx !== -1 && keys.length - cmdIdx > 2) {
- let tmp = keys[cmdIdx + 1];
- keys[cmdIdx + 1] = 'Meta';
- keys[cmdIdx] = tmp;
- }
- }
-
- let lastKey = keys[keys.length - 1];
- if (!keys.includes('Shift') && lastKey.toUpperCase() === lastKey && lastKey.toLowerCase() !== lastKey) {
- keys.splice(keys.length - 1, 0, 'Shift');
- }
-
- for (let i = 0; i < keys.length; i++) {
- if (isMac) {
- switch (keys[i]) {
- case 'Ctrl': keys[i] = '⌃'; break;
- case 'Alt': keys[i] = '⌥'; break;
- case 'Shift': keys[i] = '⇧'; break;
- case 'Meta': keys[i] = '⌘'; break;
- }
- }
-
- if (i === keys.length - 1 && i > 0 && keys[i].length === 1) {
- keys[i] = keys[i].toUpperCase();
- }
-
switch (keys[i]) {
- case 'ArrowLeft': keys[i] = '←'; break;
- case 'ArrowRight': keys[i] = '→'; break;
- case 'ArrowUp': keys[i] = '↑'; break;
- case 'ArrowDown': keys[i] = '↓'; break;
- case 'Comma': keys[i] = ','; break;
- case 'Enter': keys[i] = '↩'; break;
- case ' ': keys[i] = 'Space'; break;
+ case 'Ctrl': keys[i] = '⌃'; break;
+ case 'Alt': keys[i] = '⌥'; break;
+ case 'Shift': keys[i] = '⇧'; break;
+ case 'Meta': keys[i] = '⌘'; break;
}
-
- keys[i] = `${keys[i]} `;
}
- return keys.join(isMac ? '' : ' + ');
+ if (i === keys.length - 1 && i > 0 && keys[i].length === 1) {
+ keys[i] = keys[i].toUpperCase();
+ }
+
+ switch (keys[i]) {
+ case 'ArrowLeft': keys[i] = '←'; break;
+ case 'ArrowRight': keys[i] = '→'; break;
+ case 'ArrowUp': keys[i] = '↑'; break;
+ case 'ArrowDown': keys[i] = '↓'; break;
+ case 'Comma': keys[i] = ','; break;
+ case 'Enter': keys[i] = '↩'; break;
+ case ' ': keys[i] = 'Space'; break;
+ }
+
+ keys[i] = `${keys[i]} `;
}
- function isTextField(element) {
- let name = element.nodeName.toLowerCase();
- return name === 'textarea' ||
- name === 'select' ||
- (name === 'input' && !['submit', 'reset', 'checkbox', 'radio'].includes(element.type)) ||
- element.isContentEditable;
+ return keys.join(isMac ? '' : ' + ');
+}
+
+function isTextField(element) {
+ let name = element.nodeName.toLowerCase();
+ return name === 'textarea' ||
+ name === 'select' ||
+ (name === 'input' && !['submit', 'reset', 'checkbox', 'radio'].includes(element.type)) ||
+ element.isContentEditable;
+}
+
+let notTextField = event => !(event.target instanceof Node && isTextField(event.target));
+
+let allShortcuts = [];
+let shortcutsGroup = null;
+
+class ShortcutHandler {
+ constructor(element, override, filter = () => true) {
+ this.element = element;
+ this.map = {};
+ this.active = this.map;
+ this.override = override;
+ this.filter = filter;
+ this.timeout = null;
+
+ this.handleKeyDown = this.handleKeyDown.bind(this);
+ this.resetActive = this.resetActive.bind(this);
+ this.addEventListeners();
}
- let notTextField = event => !(event.target instanceof Node && isTextField(event.target));
+ addEventListeners() {
+ this.element.addEventListener('keydown', this.handleKeyDown);
+ }
- let allShortcuts = [];
- let shortcutsGroup = null;
+ add(text, action, description = null, shownInHelp = true) {
+ let shortcuts = text.trim().split(',').map(shortcut => shortcut.trim().split(' '));
- class ShortcutHandler {
- constructor(element, override, filter = () => true) {
- this.element = element;
- this.map = {};
- this.active = this.map;
- this.override = override;
- this.filter = filter;
+ if (shortcutsGroup && shownInHelp) {
+ shortcutsGroup.push({
+ action,
+ shortcut: text,
+ description,
+ })
+ }
+
+ for (let shortcut of shortcuts) {
+ let node = this.map;
+ for (let key of shortcut) {
+ if (!node[key]) {
+ node[key] = {};
+ }
+ node = node[key];
+ if (node.action) {
+ delete node.action;
+ delete node.shortcut;
+ delete node.description;
+ }
+ }
+
+ node.action = action;
+ node.shortcut = shortcut;
+ node.description = description;
+ }
+ }
+
+ group(...args) {
+ if (typeof args[0] === 'string') this.fakeItem(args.shift());
+ shortcutsGroup = [];
+
+ args[0].bind(this)();
+
+ if (shortcutsGroup && shortcutsGroup.length) allShortcuts.push(shortcutsGroup);
+ shortcutsGroup = null;
+ }
+
+ bindElement(shortcut, element, ...other) {
+ element = typeof element === 'string' ? $(element) : element;
+ if (!element) return;
+ this.add(shortcut, () => {
+ if (isTextField(element)) {
+ element.focus();
+ } else {
+ element.click();
+ }
+ }, ...other);
+ }
+
+ bindLink(shortcut, link, ...other) {
+ this.add(shortcut, () => window.location.href = link, ...other);
+ }
+
+ bindCollection(prefix, elements, collectionDescription, itemDescription) {
+ this.fakeItem(prefix + ' 1 – 9', collectionDescription);
+
+ if (typeof elements === 'string') {
+ elements = $$(elements);
+ } else if (Array.isArray(elements)) {
+ elements = elements.map(el => typeof el === 'string' ? $(el) : el);
+ }
+
+ for (let i = 1; i <= elements.length && i < 10; i++) {
+ this.bindElement(`${prefix} ${i}`, elements[i - 1], `${itemDescription} #${i}`, false);
+ }
+ }
+
+ fakeItem(shortcut, description = null) {
+ let list = shortcutsGroup || allShortcuts;
+ list.push({
+ shortcut: description ? shortcut : null,
+ description: description || shortcut,
+ });
+ }
+
+ handleKeyDown(event) {
+ if (event.defaultPrevented) return;
+ if (['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)) return;
+ if (!this.filter(event)) return;
+
+ let shortcut = keyEventToShortcut(event);
+
+ if (!this.active[shortcut]) {
+ this.resetActive();
+ return;
+ }
+
+ this.active = this.active[shortcut];
+ if (this.active.action) {
+ event.stopPropagation();
+ this.active.action(event);
+ if (this.override) event.preventDefault();
+ this.resetActive();
+ return;
+ }
+
+ if (this.timeout) clearTimeout(this.timeout);
+ this.timeout = window.setTimeout(this.resetActive, 1500);
+ }
+
+ resetActive() {
+ this.active = this.map;
+ if (this.timeout) {
+ clearTimeout(this.timeout)
this.timeout = null;
-
- this.handleKeyDown = this.handleKeyDown.bind(this);
- this.resetActive = this.resetActive.bind(this);
- this.addEventListeners();
- }
-
- addEventListeners() {
- this.element.addEventListener('keydown', this.handleKeyDown);
- }
-
- add(text, action, description = null, shownInHelp = true) {
- let shortcuts = text.trim().split(',').map(shortcut => shortcut.trim().split(' '));
-
- if (shortcutsGroup && shownInHelp) {
- shortcutsGroup.push({
- action,
- shortcut: text,
- description,
- })
- }
-
- for (let shortcut of shortcuts) {
- let node = this.map;
- for (let key of shortcut) {
- if (!node[key]) {
- node[key] = {};
- }
- node = node[key];
- if (node.action) {
- delete node.action;
- delete node.shortcut;
- delete node.description;
- }
- }
-
- node.action = action;
- node.shortcut = shortcut;
- node.description = description;
- }
- }
-
- group(...args) {
- if (typeof args[0] === 'string') this.fakeItem(args.shift());
- shortcutsGroup = [];
-
- args[0].bind(this)();
-
- if (shortcutsGroup && shortcutsGroup.length) allShortcuts.push(shortcutsGroup);
- shortcutsGroup = null;
- }
-
- bindElement(shortcut, element, ...other) {
- element = typeof element === 'string' ? $(element) : element;
- if (!element) return;
- this.add(shortcut, () => {
- if (isTextField(element)) {
- element.focus();
- } else {
- element.click();
- }
- }, ...other);
- }
-
- bindLink(shortcut, link, ...other) {
- this.add(shortcut, () => window.location.href = link, ...other);
- }
-
- bindCollection(prefix, elements, collectionDescription, itemDescription) {
- this.fakeItem(prefix + ' 1 – 9', collectionDescription);
-
- if (typeof elements === 'string') {
- elements = $$(elements);
- } else if (Array.isArray(elements)) {
- elements = elements.map(el => typeof el === 'string' ? $(el) : el);
- }
-
- for (let i = 1; i <= elements.length && i < 10; i++) {
- this.bindElement(`${prefix} ${i}`, elements[i-1], `${itemDescription} #${i}`, false);
- }
- }
-
- fakeItem(shortcut, description = null) {
- let list = shortcutsGroup || allShortcuts;
- list.push({
- shortcut: description ? shortcut : null,
- description: description || shortcut,
- });
- }
-
- handleKeyDown(event) {
- if (event.defaultPrevented) return;
- if (['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)) return;
- if (!this.filter(event)) return;
-
- let shortcut = keyEventToShortcut(event);
-
- if (!this.active[shortcut]) {
- this.resetActive();
- return;
- }
-
- this.active = this.active[shortcut];
- if (this.active.action) {
- event.stopPropagation();
- this.active.action(event);
- if (this.override) event.preventDefault();
- this.resetActive();
- return;
- }
-
- if (this.timeout) clearTimeout(this.timeout);
- this.timeout = window.setTimeout(this.resetActive, 1500);
- }
-
- resetActive() {
- this.active = this.map;
- if (this.timeout) {
- clearTimeout(this.timeout)
- this.timeout = null;
- }
}
}
+}
- class ShortcutsHelpDialog {
- constructor() {
- let template = $('#dialog-template');
- let clonedTemplate = template.content.cloneNode(true);
- this.backdrop = clonedTemplate.children[0];
- this.dialog = clonedTemplate.children[1];
+class ShortcutsHelpDialog {
+ constructor() {
+ let template = $('#dialog-template');
+ let clonedTemplate = template.content.cloneNode(true);
+ this.backdrop = clonedTemplate.children[0];
+ this.dialog = clonedTemplate.children[1];
- this.dialog.classList.add('shortcuts-help');
- this.dialog.hidden = true;
- this.backdrop.hidden = true;
+ this.dialog.classList.add('shortcuts-help');
+ this.dialog.hidden = true;
+ this.backdrop.hidden = true;
- document.body.appendChild(this.backdrop);
- document.body.appendChild(this.dialog);
+ document.body.appendChild(this.backdrop);
+ document.body.appendChild(this.dialog);
- this.close = this.close.bind(this);
+ this.close = this.close.bind(this);
- this.dialog.querySelector('.dialog__title').textContent = 'List of shortcuts';
- this.dialog.querySelector('.dialog__close-button').addEventListener('click', this.close);
- this.backdrop.addEventListener('click', this.close);
+ this.dialog.querySelector('.dialog__title').textContent = 'List of shortcuts';
+ this.dialog.querySelector('.dialog__close-button').addEventListener('click', this.close);
+ this.backdrop.addEventListener('click', this.close);
- this.shortcuts = new ShortcutHandler(this.dialog, false);
- this.shortcuts.add('Escape', this.close, null, false);
+ this.shortcuts = new ShortcutHandler(this.dialog, false);
+ this.shortcuts.add('Escape', this.close, null, false);
- let shortcutsGroup;
- let shortcutsGroupTemplate = document.createElement('div');
- shortcutsGroupTemplate.className = 'shortcuts-group';
+ let shortcutsGroup;
+ let shortcutsGroupTemplate = document.createElement('div');
+ shortcutsGroupTemplate.className = 'shortcuts-group';
- for (let item of allShortcuts) {
- if (item.description && !item.shortcut) {
- shortcutsGroup = shortcutsGroupTemplate.cloneNode();
- this.dialog.querySelector('.dialog__content').appendChild(shortcutsGroup);
+ for (let item of allShortcuts) {
+ if (item.description && !item.shortcut) {
+ shortcutsGroup = shortcutsGroupTemplate.cloneNode();
+ this.dialog.querySelector('.dialog__content').appendChild(shortcutsGroup);
- let heading = document.createElement('h2');
- heading.className = 'shortcuts-group-heading';
- heading.textContent = item.description;
- shortcutsGroup.appendChild(heading);
+ let heading = document.createElement('h2');
+ heading.className = 'shortcuts-group-heading';
+ heading.textContent = item.description;
+ shortcutsGroup.appendChild(heading);
- } else {
- let list = document.createElement('ul');
- list.className = 'shortcuts-list';
+ } else {
+ let list = document.createElement('ul');
+ list.className = 'shortcuts-list';
- for (let shortcut of item) {
- let listItem = document.createElement('li');
- listItem.className = 'shortcut-row';
- list.appendChild(listItem);
+ for (let shortcut of item) {
+ let listItem = document.createElement('li');
+ listItem.className = 'shortcut-row';
+ list.appendChild(listItem);
- let descriptionColumn = document.createElement('div')
- descriptionColumn.className = 'shortcut-row__description';
- descriptionColumn.textContent = shortcut.description;
- listItem.appendChild(descriptionColumn);
+ let descriptionColumn = document.createElement('div')
+ descriptionColumn.className = 'shortcut-row__description';
+ descriptionColumn.textContent = shortcut.description;
+ listItem.appendChild(descriptionColumn);
- let shortcutColumn = document.createElement('div');
- shortcutColumn.className = 'shortcut-row__keys';
- shortcutColumn.innerHTML = shortcut.shortcut.split(',')
- .map(shortcuts => shortcuts.trim().split(' ').map(prettifyShortcut).join(' '))
- .join(' or ');
- listItem.appendChild(shortcutColumn);
- }
+ let shortcutColumn = document.createElement('div');
+ shortcutColumn.className = 'shortcut-row__keys';
+ shortcutColumn.innerHTML = shortcut.shortcut.split(',')
+ .map(shortcuts => shortcuts.trim().split(' ').map(prettifyShortcut).join(' '))
+ .join(' or ');
+ listItem.appendChild(shortcutColumn);
+ }
+ if (shortcutsGroup) {
shortcutsGroup.appendChild(list);
}
}
}
-
- open() {
- this.prevActiveElement = document.activeElement;
-
- document.body.overflow = 'hidden';
- this.backdrop.hidden = false;
- this.dialog.hidden = false;
- this.dialog.focus();
- }
-
- close() {
- document.body.overflow = '';
- this.backdrop.hidden = true;
- this.dialog.hidden = true;
-
- if (this.prevActiveElement) {
- this.prevActiveElement.focus();
- this.prevActiveElement = null;
- }
- }
}
- window.addEventListener('load', () => {
- let helpDialog = null;
- let openHelp = () => {
- if (!helpDialog) helpDialog = new ShortcutsHelpDialog();
- helpDialog.open();
- };
+ open() {
+ this.prevActiveElement = document.activeElement;
- let onEditPage = typeof editTextarea !== 'undefined';
+ document.body.overflow = 'hidden';
+ this.backdrop.hidden = false;
+ this.dialog.hidden = false;
+ this.dialog.focus();
+ }
- // Global shortcuts work everywhere.
- let globalShortcuts = new ShortcutHandler(document, false);
- globalShortcuts.add(isMac ? 'Meta+/' : 'Ctrl+/', openHelp);
+ close() {
+ document.body.overflow = '';
+ this.backdrop.hidden = true;
+ this.dialog.hidden = true;
- // Page shortcuts work everywhere except on text fields.
- let pageShortcuts = new ShortcutHandler(document, false, notTextField);
- pageShortcuts.add('?', openHelp, null, false);
+ if (this.prevActiveElement) {
+ this.prevActiveElement.focus();
+ this.prevActiveElement = null;
+ }
+ }
+}
- // Common shortcuts
- pageShortcuts.group('Common', function () {
- this.bindCollection('g', '.top-bar__highlight-link', 'First 9 header links', 'Header link');
- this.bindLink('g h', '/', 'Home');
- this.bindLink('g l', '/list/', 'List of hyphae');
- this.bindLink('g r', '/recent-changes/', 'Recent changes');
- this.bindElement('g u', '.auth-links__user-link', 'Your profile′s hypha');
+window.addEventListener('load', () => {
+ let helpDialog = null;
+ let openHelp = () => {
+ if (!helpDialog) helpDialog = new ShortcutsHelpDialog();
+ helpDialog.open();
+ };
+
+ let onEditPage = typeof editTextarea !== 'undefined';
+
+ // Global shortcuts work everywhere.
+ let globalShortcuts = new ShortcutHandler(document, false);
+ globalShortcuts.add(isMac ? 'Meta+/' : 'Ctrl+/', openHelp);
+
+ // Page shortcuts work everywhere except on text fields.
+ let pageShortcuts = new ShortcutHandler(document, false, notTextField);
+ pageShortcuts.add('?', openHelp, null, false);
+
+ // Common shortcuts
+ pageShortcuts.group('Common', function () {
+ this.bindCollection('g', '.top-bar__highlight-link', 'First 9 header links', 'Header link');
+ this.bindLink('g h', '/', 'Home');
+ this.bindLink('g l', '/list/', 'List of hyphae');
+ this.bindLink('g r', '/recent-changes/', 'Recent changes');
+ this.bindElement('g u', '.auth-links__user-link', 'Your profile′s hypha');
+ });
+
+ if (!onEditPage) {
+ // Hypha shortcuts
+ pageShortcuts.group('Hypha', function () {
+ this.bindCollection('', 'article .wikilink', 'First 9 hypha′s links', 'Hypha link');
+ this.bindElement('p, Alt+ArrowLeft, Ctrl+Alt+ArrowLeft', '.prevnext__prev', 'Next hypha');
+ this.bindElement('n, Alt+ArrowRight, Ctrl+Alt+ArrowRight', '.prevnext__next', 'Previous hypha');
+ this.bindElement('s, Alt+ArrowUp, Ctrl+Alt+ArrowUp', $$('.navi-title a').slice(1, -1).slice(-1)[0], 'Parent hypha');
+ this.bindElement('c, Alt+ArrowDown, Ctrl+Alt+ArrowDown', '.subhyphae__link', 'First child hypha');
+
+ this.bindElement('v', '.hypha-info__link[href^="/hypha/"]', 'Go to hypha′s page');
+ this.bindElement('e, ' + (isMac ? "Meta+Enter" : "Ctrl+Enter"), '.edit-btn__link[href^="/edit/"]', 'Edit this hypha');
+ this.bindElement('a', '.hypha-info__link[href^="/attachment/"]', 'Go to attachment');
+ this.bindElement('h', '.hypha-info__link[href^="/history/"]', 'Go to history');
+ this.bindElement('r', '.hypha-info__link[href^="/rename-ask/"]', 'Rename this hypha');
});
- if (!onEditPage) {
- // Hypha shortcuts
- pageShortcuts.group('Hypha', function () {
- this.bindCollection('', 'article .wikilink', 'First 9 hypha′s links', 'Hypha link');
- this.bindElement('p, Alt+ArrowLeft, Ctrl+Alt+ArrowLeft', '.prevnext__prev', 'Next hypha');
- this.bindElement('n, Alt+ArrowRight, Ctrl+Alt+ArrowRight', '.prevnext__next', 'Previous hypha');
- this.bindElement('s, Alt+ArrowUp, Ctrl+Alt+ArrowUp', $$('.navi-title a').slice(1, -1).slice(-1)[0], 'Parent hypha');
- this.bindElement('c, Alt+ArrowDown, Ctrl+Alt+ArrowDown', '.subhyphae__link', 'First child hypha');
+ } else {
+ // Hypha editor shortcuts. These work only on editor's text area.
+ let editorShortcuts = new ShortcutHandler(editTextarea, true);
- this.bindElement('v', '.hypha-info__link[href^="/hypha/"]', 'Go to hypha′s page');
- this.bindElement('e, ' + (isMac ? "Meta+Enter" : "Ctrl+Enter"), '.edit-btn__link[href^="/edit/"]', 'Edit this hypha');
- this.bindElement('a', '.hypha-info__link[href^="/attachment/"]', 'Go to attachment');
- this.bindElement('h', '.hypha-info__link[href^="/history/"]', 'Go to history');
- this.bindElement('r', '.hypha-info__link[href^="/rename-ask/"]', 'Rename this hypha');
- });
+ let shortcuts = [
+ // Win+Linux Mac Action Description
+ ['Ctrl+b', 'Meta+b', wrapBold, 'Format: Bold'],
+ ['Ctrl+i', 'Meta+i', wrapItalic, 'Format: Italic'],
+ ['Ctrl+M', 'Meta+Shift+m', wrapMonospace, 'Format: Monospaced'],
+ ['Ctrl+I', 'Meta+Shift+i', wrapHighlighted, 'Format: Highlight'],
+ ['Ctrl+.', 'Meta+.', wrapLifted, 'Format: Superscript'],
+ ['Ctrl+Comma', 'Meta+Comma', wrapLowered, 'Format: Subscript'],
+ ['Ctrl+X', 'Meta+Shift+x', wrapStrikethrough, 'Format: Strikethrough'],
+ ['Ctrl+k', 'Meta+k', wrapLink, 'Format: Inline link'],
+ // Apparently, ⌘; conflicts with a Safari's hotkey. Whatever.
+ ['Ctrl+;', 'Meta+;', insertDate, 'Insert date UTC'],
+ ];
- } else {
- // Hypha editor shortcuts. These work only on editor's text area.
- let editorShortcuts = new ShortcutHandler(editTextarea, true);
-
- let shortcuts = [
- // Win+Linux Mac Action Description
- ['Ctrl+b', 'Meta+b', wrapBold, 'Format: Bold'],
- ['Ctrl+i', 'Meta+i', wrapItalic, 'Format: Italic'],
- ['Ctrl+M', 'Meta+Shift+m', wrapMonospace, 'Format: Monospaced'],
- ['Ctrl+I', 'Meta+Shift+i', wrapHighlighted, 'Format: Highlight'],
- ['Ctrl+.', 'Meta+.', wrapLifted, 'Format: Superscript'],
- ['Ctrl+Comma', 'Meta+Comma', wrapLowered, 'Format: Subscript'],
- ['Ctrl+X', 'Meta+Shift+x', wrapStrikethrough, 'Format: Strikethrough'],
- ['Ctrl+k', 'Meta+k', wrapLink, 'Format: Inline link'],
- // Apparently, ⌘; conflicts with a Safari's hotkey. Whatever.
- ['Ctrl+;', 'Meta+;', insertDate, 'Insert date UTC'],
- ];
-
- editorShortcuts.group('Editor', function () {
- for (let shortcut of shortcuts) {
- if (isMac) {
- this.add(shortcut[1], ...shortcut.slice(2))
- } else {
- this.add(shortcut[0], ...shortcut.slice(2))
- }
+ editorShortcuts.group('Editor', function () {
+ for (let shortcut of shortcuts) {
+ if (isMac) {
+ this.add(shortcut[1], ...shortcut.slice(2))
+ } else {
+ this.add(shortcut[0], ...shortcut.slice(2))
}
- });
+ }
+ });
- editorShortcuts.group(function () {
- this.bindElement(isMac ? 'Meta+Enter' : 'Ctrl+Enter', $('.edit-form__save'), 'Save changes');
- });
- }
- });
-})();
+ editorShortcuts.group(function () {
+ this.bindElement(isMac ? 'Meta+Enter' : 'Ctrl+Enter', $('.edit-form__save'), 'Save changes');
+ });
+ }
+});
diff --git a/static/toolbar.js b/static/toolbar.js
index 5984a10..6f2f9b7 100644
--- a/static/toolbar.js
+++ b/static/toolbar.js
@@ -44,8 +44,8 @@ function selectionWrapper(cursorPosition, prefix, postfix = null, el = editTexta
// selection is decorated, so we just cut it
removing = true
result = text.substring(cursorPosition, text.length - cursorPosition)
- } else if ( (prefix == el.value.slice(start-cursorPosition, start)) &&
- (postfix == el.value.slice(end, end+cursorPosition)) ) {
+ } else if ( (prefix === el.value.slice(start-cursorPosition, start)) &&
+ (postfix === el.value.slice(end, end+cursorPosition)) ) {
// selection is surrounded by decorations
removing = true
result = text
diff --git a/tools.go b/tools.go
new file mode 100644
index 0000000..e4f9bb6
--- /dev/null
+++ b/tools.go
@@ -0,0 +1,9 @@
+//go:build tools
+// +build tools
+
+package tools
+
+import (
+ _ "github.com/chekoopa/go-localize"
+ _ "github.com/valyala/quicktemplate/qtc"
+)
diff --git a/tree/tree.go b/tree/tree.go
index 244d808..2647bcd 100644
--- a/tree/tree.go
+++ b/tree/tree.go
@@ -11,22 +11,27 @@ import (
"github.com/bouncepaw/mycorrhiza/util"
)
-func findSiblingsAndDescendants(hyphaName string) ([]*sibling, map[string]bool) {
- hyphaDir := ""
+func findSiblings(hyphaName string) []*sibling {
+ parentHyphaName := ""
if hyphaRawDir := path.Dir(hyphaName); hyphaRawDir != "." {
- hyphaDir = hyphaRawDir
+ parentHyphaName = hyphaRawDir
}
var (
siblingsMap = make(map[string]bool)
siblingCheck = func(h *hyphae.Hypha) hyphae.CheckResult {
- // I don't like this double comparison, but it is only the way to circumvent some flickups
- if strings.HasPrefix(h.Name, hyphaDir) && h.Name != hyphaDir && h.Name != hyphaName {
+ switch {
+ case h.Name == hyphaName, // Hypha is no sibling of itself
+ h.Name == parentHyphaName: // Parent hypha is no sibling of its child
+ return hyphae.CheckContinue
+ }
+ if (parentHyphaName != "" && strings.HasPrefix(h.Name, parentHyphaName+"/")) ||
+ (parentHyphaName == "") {
var (
- rawSubPath = strings.TrimPrefix(h.Name, hyphaDir)[1:]
+ rawSubPath = strings.TrimPrefix(h.Name, parentHyphaName)[1:]
slashIdx = strings.IndexRune(rawSubPath, '/')
)
if slashIdx > -1 {
- var sibPath = h.Name[:slashIdx+len(hyphaDir)+1]
+ var sibPath = h.Name[:slashIdx+len(parentHyphaName)+1]
if _, exists := siblingsMap[sibPath]; !exists {
siblingsMap[sibPath] = false
}
@@ -37,20 +42,11 @@ func findSiblingsAndDescendants(hyphaName string) ([]*sibling, map[string]bool)
return hyphae.CheckContinue
}
- descendantsPool = make(map[string]bool, 0)
- descendantCheck = func(h *hyphae.Hypha) hyphae.CheckResult {
- if strings.HasPrefix(h.Name, hyphaName+"/") {
- descendantsPool[h.Name] = true
- }
- return hyphae.CheckContinue
- }
-
i7n = hyphae.NewIteration()
)
siblingsMap[hyphaName] = true
i7n.AddCheck(siblingCheck)
- i7n.AddCheck(descendantCheck)
i7n.Ignite()
siblings := make([]*sibling, len(siblingsMap))
@@ -62,7 +58,7 @@ func findSiblingsAndDescendants(hyphaName string) ([]*sibling, map[string]bool)
sort.Slice(siblings, func(i, j int) bool {
return siblings[i].name < siblings[j].name
})
- return siblings, descendantsPool
+ return siblings
}
func countSubhyphae(siblings []*sibling) {
@@ -90,21 +86,22 @@ func Tree(hyphaName string) (siblingsHTML, childrenHTML, prev, next string) {
children := make([]child, 0)
I := 0
// The tree is generated in two iterations of hyphae storage:
- // 1. Find all siblings (sorted) and descendants' names
+ // 1. Find all siblings (sorted)
// 2. Count how many subhyphae siblings have
//
// We also have to figure out what is going on with the descendants: who is a child of whom. We do that in parallel with (2) because we can.
// One of the siblings is the hypha with name `hyphaName`
- siblings, descendantsPool := findSiblingsAndDescendants(hyphaName)
+ var siblings []*sibling
wg := sync.WaitGroup{}
wg.Add(2)
go func() {
+ siblings = findSiblings(hyphaName)
countSubhyphae(siblings)
wg.Done()
}()
go func() {
- children = figureOutChildren(hyphaName, descendantsPool, true).children
+ children = figureOutChildren(hyphaName).children
wg.Done()
}()
wg.Wait()
@@ -132,32 +129,55 @@ type child struct {
children []child
}
-func figureOutChildren(hyphaName string, subhyphaePool map[string]bool, exists bool) child {
+func figureOutChildren(hyphaName string) child {
var (
- nestLevel = strings.Count(hyphaName, "/")
- adopted = make([]child, 0)
+ descPrefix = hyphaName + "/"
+ child = child{hyphaName, true, make([]child, 0)}
)
- for subhyphaName := range subhyphaePool {
- subnestLevel := strings.Count(subhyphaName, "/")
- if subnestLevel-1 == nestLevel && path.Dir(subhyphaName) == hyphaName {
- delete(subhyphaePool, subhyphaName)
- adopted = append(adopted, figureOutChildren(subhyphaName, subhyphaePool, true))
- }
- }
- for descName := range subhyphaePool {
- if strings.HasPrefix(descName, hyphaName) {
- var (
- rawSubPath = strings.TrimPrefix(descName, hyphaName)[1:]
- slashIdx = strings.IndexRune(rawSubPath, '/')
- )
- if slashIdx > -1 {
- var sibPath = descName[:slashIdx+len(hyphaName)+1]
- adopted = append(adopted, figureOutChildren(sibPath, subhyphaePool, false))
- } // `else` never happens?
+
+ for desc := range hyphae.YieldExistingHyphae() {
+ var descName = desc.Name
+ if strings.HasPrefix(descName, descPrefix) {
+ var subPath = strings.TrimPrefix(descName, descPrefix)
+ addHyphaToChild(descName, subPath, &child)
}
}
- return child{hyphaName, exists, adopted}
+ return child
+}
+
+func addHyphaToChild(hyphaName, subPath string, child *child) {
+ // when hyphaName = "root/a/b", subPath = "a/b", and child.name = "root"
+ // addHyphaToChild("root/a/b", "b", child{"root/a"})
+ // when hyphaName = "root/a/b", subPath = "b", and child.name = "root/a"
+ // set .exists=true for "root/a/b", and create it if it isn't there already
+ var exists = !strings.Contains(subPath, "/")
+ if exists {
+ var subchild = findOrCreateSubchild(subPath, child)
+ subchild.exists = true
+ } else {
+ var (
+ firstSlash = strings.IndexRune(subPath, '/')
+ firstDir = subPath[:firstSlash]
+ restOfPath = subPath[firstSlash+1:]
+ subchild = findOrCreateSubchild(firstDir, child)
+ )
+ addHyphaToChild(hyphaName, restOfPath, subchild)
+ }
+}
+
+func findOrCreateSubchild(name string, baseChild *child) *child {
+ // when name = "a", and baseChild.name = "root"
+ // if baseChild.children contains "root/a", return it
+ // else create it and return that
+ var fullName = baseChild.name + "/" + name
+ for i := range baseChild.children {
+ if baseChild.children[i].name == fullName {
+ return &baseChild.children[i]
+ }
+ }
+ baseChild.children = append(baseChild.children, child{fullName, false, make([]child, 0)})
+ return &baseChild.children[len(baseChild.children)-1]
}
type sibling struct {
diff --git a/tree/view.qtpl b/tree/view.qtpl
index 6d047f0..4d85ae0 100644
--- a/tree/view.qtpl
+++ b/tree/view.qtpl
@@ -2,9 +2,6 @@
{% import "path" %}
{% import "github.com/bouncepaw/mycorrhiza/util" %}
-{% func TreeHTML() %}
-{% endfunc %}
-
Subhyphae links are recursive. It may end up looking like that if drawn with
pseudographics:
╔══════════════╗
diff --git a/tree/view.qtpl.go b/tree/view.qtpl.go
index f005c67..b8627c5 100644
--- a/tree/view.qtpl.go
+++ b/tree/view.qtpl.go
@@ -13,53 +13,6 @@ import "path"
//line tree/view.qtpl:3
import "github.com/bouncepaw/mycorrhiza/util"
-//line tree/view.qtpl:5
-import (
- qtio422016 "io"
-
- qt422016 "github.com/valyala/quicktemplate"
-)
-
-//line tree/view.qtpl:5
-var (
- _ = qtio422016.Copy
- _ = qt422016.AcquireByteBuffer
-)
-
-//line tree/view.qtpl:5
-func StreamTreeHTML(qw422016 *qt422016.Writer) {
-//line tree/view.qtpl:5
- qw422016.N().S(`
-`)
-//line tree/view.qtpl:6
-}
-
-//line tree/view.qtpl:6
-func WriteTreeHTML(qq422016 qtio422016.Writer) {
-//line tree/view.qtpl:6
- qw422016 := qt422016.AcquireWriter(qq422016)
-//line tree/view.qtpl:6
- StreamTreeHTML(qw422016)
-//line tree/view.qtpl:6
- qt422016.ReleaseWriter(qw422016)
-//line tree/view.qtpl:6
-}
-
-//line tree/view.qtpl:6
-func TreeHTML() string {
-//line tree/view.qtpl:6
- qb422016 := qt422016.AcquireByteBuffer()
-//line tree/view.qtpl:6
- WriteTreeHTML(qb422016)
-//line tree/view.qtpl:6
- qs422016 := string(qb422016.B)
-//line tree/view.qtpl:6
- qt422016.ReleaseByteBuffer(qb422016)
-//line tree/view.qtpl:6
- return qs422016
-//line tree/view.qtpl:6
-}
-
// Subhyphae links are recursive. It may end up looking like that if drawn with
// pseudographics:
// ╔══════════════╗
@@ -69,183 +22,196 @@ func TreeHTML() string {
// ║╚════════════╝║
// ╚══════════════╝
-//line tree/view.qtpl:16
+//line tree/view.qtpl:13
+import (
+ qtio422016 "io"
+
+ qt422016 "github.com/valyala/quicktemplate"
+)
+
+//line tree/view.qtpl:13
+var (
+ _ = qtio422016.Copy
+ _ = qt422016.AcquireByteBuffer
+)
+
+//line tree/view.qtpl:13
func streamchildHTML(qw422016 *qt422016.Writer, c *child) {
-//line tree/view.qtpl:16
+//line tree/view.qtpl:13
qw422016.N().S(`
`)
-//line tree/view.qtpl:18
+//line tree/view.qtpl:15
sort.Slice(c.children, func(i, j int) bool {
return c.children[i].name < c.children[j].name
})
-//line tree/view.qtpl:21
+//line tree/view.qtpl:18
qw422016.N().S(`
`)
-//line tree/view.qtpl:24
+//line tree/view.qtpl:21
qw422016.E().S(util.BeautifulName(path.Base(c.name)))
-//line tree/view.qtpl:24
+//line tree/view.qtpl:21
qw422016.N().S(`
`)
-//line tree/view.qtpl:26
+//line tree/view.qtpl:23
if len(c.children) > 0 {
-//line tree/view.qtpl:26
+//line tree/view.qtpl:23
qw422016.N().S(`
`)
-//line tree/view.qtpl:28
+//line tree/view.qtpl:25
for _, child := range c.children {
-//line tree/view.qtpl:28
+//line tree/view.qtpl:25
qw422016.N().S(`
`)
-//line tree/view.qtpl:29
+//line tree/view.qtpl:26
qw422016.N().S(childHTML(&child))
-//line tree/view.qtpl:29
+//line tree/view.qtpl:26
qw422016.N().S(`
`)
-//line tree/view.qtpl:30
+//line tree/view.qtpl:27
}
-//line tree/view.qtpl:30
+//line tree/view.qtpl:27
qw422016.N().S(`
`)
-//line tree/view.qtpl:32
+//line tree/view.qtpl:29
}
-//line tree/view.qtpl:32
+//line tree/view.qtpl:29
qw422016.N().S(`
`)
-//line tree/view.qtpl:34
+//line tree/view.qtpl:31
}
-//line tree/view.qtpl:34
+//line tree/view.qtpl:31
func writechildHTML(qq422016 qtio422016.Writer, c *child) {
-//line tree/view.qtpl:34
+//line tree/view.qtpl:31
qw422016 := qt422016.AcquireWriter(qq422016)
-//line tree/view.qtpl:34
+//line tree/view.qtpl:31
streamchildHTML(qw422016, c)
-//line tree/view.qtpl:34
+//line tree/view.qtpl:31
qt422016.ReleaseWriter(qw422016)
-//line tree/view.qtpl:34
+//line tree/view.qtpl:31
}
-//line tree/view.qtpl:34
+//line tree/view.qtpl:31
func childHTML(c *child) string {
-//line tree/view.qtpl:34
+//line tree/view.qtpl:31
qb422016 := qt422016.AcquireByteBuffer()
-//line tree/view.qtpl:34
+//line tree/view.qtpl:31
writechildHTML(qb422016, c)
-//line tree/view.qtpl:34
+//line tree/view.qtpl:31
qs422016 := string(qb422016.B)
-//line tree/view.qtpl:34
+//line tree/view.qtpl:31
qt422016.ReleaseByteBuffer(qb422016)
-//line tree/view.qtpl:34
+//line tree/view.qtpl:31
return qs422016
-//line tree/view.qtpl:34
+//line tree/view.qtpl:31
}
-//line tree/view.qtpl:37
+//line tree/view.qtpl:34
func streamsiblingHTML(qw422016 *qt422016.Writer, s *sibling) {
-//line tree/view.qtpl:37
+//line tree/view.qtpl:34
qw422016.N().S(`
`)
-//line tree/view.qtpl:40
+//line tree/view.qtpl:37
qw422016.E().S(util.BeautifulName(path.Base(s.name)))
-//line tree/view.qtpl:40
+//line tree/view.qtpl:37
qw422016.N().S(`
`)
-//line tree/view.qtpl:42
+//line tree/view.qtpl:39
if s.directSubhyphaeCount > 0 {
-//line tree/view.qtpl:42
+//line tree/view.qtpl:39
qw422016.N().S(`
`)
-//line tree/view.qtpl:44
+//line tree/view.qtpl:41
qw422016.N().D(s.directSubhyphaeCount)
-//line tree/view.qtpl:44
+//line tree/view.qtpl:41
qw422016.N().S(`
`)
-//line tree/view.qtpl:46
+//line tree/view.qtpl:43
}
-//line tree/view.qtpl:46
+//line tree/view.qtpl:43
qw422016.N().S(`
`)
-//line tree/view.qtpl:47
+//line tree/view.qtpl:44
if s.indirectSubhyphaeCount > 0 {
-//line tree/view.qtpl:47
+//line tree/view.qtpl:44
qw422016.N().S(`
(`)
-//line tree/view.qtpl:49
+//line tree/view.qtpl:46
qw422016.N().D(s.indirectSubhyphaeCount)
-//line tree/view.qtpl:49
+//line tree/view.qtpl:46
qw422016.N().S(`)
`)
-//line tree/view.qtpl:51
+//line tree/view.qtpl:48
}
-//line tree/view.qtpl:51
+//line tree/view.qtpl:48
qw422016.N().S(`
`)
-//line tree/view.qtpl:55
+//line tree/view.qtpl:52
}
-//line tree/view.qtpl:55
+//line tree/view.qtpl:52
func writesiblingHTML(qq422016 qtio422016.Writer, s *sibling) {
-//line tree/view.qtpl:55
+//line tree/view.qtpl:52
qw422016 := qt422016.AcquireWriter(qq422016)
-//line tree/view.qtpl:55
+//line tree/view.qtpl:52
streamsiblingHTML(qw422016, s)
-//line tree/view.qtpl:55
+//line tree/view.qtpl:52
qt422016.ReleaseWriter(qw422016)
-//line tree/view.qtpl:55
+//line tree/view.qtpl:52
}
-//line tree/view.qtpl:55
+//line tree/view.qtpl:52
func siblingHTML(s *sibling) string {
-//line tree/view.qtpl:55
+//line tree/view.qtpl:52
qb422016 := qt422016.AcquireByteBuffer()
-//line tree/view.qtpl:55
+//line tree/view.qtpl:52
writesiblingHTML(qb422016, s)
-//line tree/view.qtpl:55
+//line tree/view.qtpl:52
qs422016 := string(qb422016.B)
-//line tree/view.qtpl:55
+//line tree/view.qtpl:52
qt422016.ReleaseByteBuffer(qb422016)
-//line tree/view.qtpl:55
+//line tree/view.qtpl:52
return qs422016
-//line tree/view.qtpl:55
+//line tree/view.qtpl:52
}
diff --git a/user/files.go b/user/files.go
index 2f98207..1ad45d7 100644
--- a/user/files.go
+++ b/user/files.go
@@ -82,7 +82,7 @@ func SaveUserDatabase() error {
}
func dumpUserCredentials() error {
- userList := []*User{}
+ var userList []*User
// TODO: lock the map during saving to prevent corruption
for u := range YieldUsers() {
@@ -119,5 +119,8 @@ func dumpTokens() {
log.Println(err)
return
}
- os.WriteFile(files.TokensJSON(), blob, 0666)
+ err = os.WriteFile(files.TokensJSON(), blob, 0666)
+ if err != nil {
+ log.Println("an error occurred in dumpTokens function:", err)
+ }
}
diff --git a/user/net.go b/user/net.go
index b439cb9..831c722 100644
--- a/user/net.go
+++ b/user/net.go
@@ -44,7 +44,7 @@ func Register(username, password, group, source string, force bool) error {
username = util.CanonicalName(username)
switch {
- case !util.IsPossibleUsername(username):
+ case !IsValidUsername(username):
return fmt.Errorf("illegal username ‘%s’", username)
case !ValidGroup(group):
return fmt.Errorf("invalid group ‘%s’", group)
diff --git a/user/user.go b/user/user.go
index e0522bd..443f451 100644
--- a/user/user.go
+++ b/user/user.go
@@ -2,6 +2,8 @@ package user
import (
"net/http"
+ "regexp"
+ "strings"
"sync"
"time"
@@ -9,7 +11,9 @@ import (
"golang.org/x/crypto/bcrypt"
)
-// User is a user (duh).
+var usernamePattern = regexp.MustCompile(`[^?!:#@><*|"'&%{}/]+`)
+
+// User contains information about a given user required for identification.
type User struct {
// Name is a username. It must follow hypha naming rules.
Name string `json:"name"`
@@ -93,12 +97,10 @@ func (user *User) CanProceed(route string) bool {
user.RLock()
defer user.RUnlock()
- right, _ := groupRight[user.Group]
- minimalRight, _ := minimalRights[route]
- if right >= minimalRight {
- return true
- }
- return false
+ right := groupRight[user.Group]
+ minimalRight := minimalRights[route]
+
+ return right >= minimalRight
}
func (user *User) isCorrectPassword(password string) bool {
@@ -117,3 +119,22 @@ func (user *User) ShowLockMaybe(w http.ResponseWriter, rq *http.Request) bool {
}
return false
}
+
+// IsValidUsername checks if the given username is valid.
+func IsValidUsername(username string) bool {
+ return username != "anon" && username != "wikimind" &&
+ usernamePattern.MatchString(strings.TrimSpace(username)) &&
+ usernameIsWhiteListed(username)
+}
+
+func usernameIsWhiteListed(username string) bool {
+ if !cfg.UseWhiteList {
+ return true
+ }
+ for _, allowedUsername := range cfg.WhiteList {
+ if allowedUsername == username {
+ return true
+ }
+ }
+ return false
+}
diff --git a/user/users.go b/user/users.go
index 07fe2a9..fadc18a 100644
--- a/user/users.go
+++ b/user/users.go
@@ -20,7 +20,7 @@ func YieldUsers() chan *User {
// ListUsersWithGroup returns a slice with users of desired group.
func ListUsersWithGroup(group string) []string {
- filtered := []string{}
+ var filtered []string
for u := range YieldUsers() {
if u.Group == group {
filtered = append(filtered, u.Name)
diff --git a/util/util.go b/util/util.go
index b84e1b6..5759a49 100644
--- a/util/util.go
+++ b/util/util.go
@@ -6,7 +6,6 @@ import (
"github.com/bouncepaw/mycorrhiza/files"
"log"
"net/http"
- "regexp"
"strings"
"github.com/bouncepaw/mycomarkup/v3/util"
@@ -65,33 +64,6 @@ func CanonicalName(name string) string {
return util.CanonicalName(name)
}
-// hyphaPattern is a pattern which all hypha names must match.
-var hyphaPattern = regexp.MustCompile(`[^?!:#@><*|"'&%{}]+`)
-
-var usernamePattern = regexp.MustCompile(`[^?!:#@><*|"'&%{}/]+`)
-
-// IsCanonicalName checks if the `name` is canonical.
-func IsCanonicalName(name string) bool {
- return hyphaPattern.MatchString(name)
-}
-
-// IsPossibleUsername is true if the given username is ok. Same as IsCanonicalName, but cannot have / in it and cannot be equal to "anon" or "wikimind"
-func IsPossibleUsername(username string) bool {
- return username != "anon" && username != "wikimind" && usernameIsWhiteListed(username) && usernamePattern.MatchString(strings.TrimSpace(username))
-}
-
-func usernameIsWhiteListed(username string) bool {
- if !cfg.UseWhiteList {
- return true
- }
- for _, allowedUsername := range cfg.WhiteList {
- if allowedUsername == username {
- return true
- }
- }
- return false
-}
-
// HyphaNameFromRq extracts hypha name from http request. You have to also pass the action which is embedded in the url or several actions. For url /hypha/hypha, the action would be "hypha".
func HyphaNameFromRq(rq *http.Request, actions ...string) string {
p := rq.URL.Path
diff --git a/views/stuff.qtpl b/views/stuff.qtpl
index f461686..49d2451 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 4efaa97..3bf3e73 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
qw422016.N().S(` 1.6.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())
diff --git a/web/mutators.go b/web/mutators.go
index e7c4ad2..39b4275 100644
--- a/web/mutators.go
+++ b/web/mutators.go
@@ -35,7 +35,7 @@ func initMutators(r *mux.Router) {
func factoryHandlerAsker(
actionPath string,
- asker func(*user.User, *hyphae.Hypha) (string, error),
+ asker func(*user.User, *hyphae.Hypha, *l18n.Localizer) (string, error),
succTitleKey string,
succPageTemplate func(*http.Request, string, bool) string,
) func(http.ResponseWriter, *http.Request) {
@@ -47,7 +47,7 @@ func factoryHandlerAsker(
u = user.FromRequest(rq)
lc = l18n.FromRequest(rq)
)
- if errtitle, err := asker(u, h); err != nil {
+ if errtitle, err := asker(u, h, lc); err != nil {
httpErr(
w,
lc,
@@ -112,15 +112,15 @@ func factoryHandlerConfirmer(
var handlerUnattachConfirm = factoryHandlerConfirmer(
"unattach-confirm",
- func(h *hyphae.Hypha, u *user.User, _ *http.Request) (*history.Op, string) {
- return shroom.UnattachHypha(u, h)
+ func(h *hyphae.Hypha, u *user.User, rq *http.Request) (*history.Op, string) {
+ return shroom.UnattachHypha(u, h, l18n.FromRequest(rq))
},
)
var handlerDeleteConfirm = factoryHandlerConfirmer(
"delete-confirm",
- func(h *hyphae.Hypha, u *user.User, _ *http.Request) (*history.Op, string) {
- return shroom.DeleteHypha(u, h)
+ func(h *hyphae.Hypha, u *user.User, rq *http.Request) (*history.Op, string) {
+ return shroom.DeleteHypha(u, h, l18n.FromRequest(rq))
},
)
@@ -136,7 +136,7 @@ func handlerRenameConfirm(w http.ResponseWriter, rq *http.Request) {
newHypha = hyphae.ByName(newName)
recursive = rq.PostFormValue("recursive") == "true"
)
- hop, errtitle := shroom.RenameHypha(oldHypha, newHypha, recursive, u)
+ hop, errtitle := shroom.RenameHypha(oldHypha, newHypha, recursive, u, lc)
if hop.HasErrors() {
httpErr(w, lc, http.StatusInternalServerError, hyphaName,
errtitle,
@@ -158,7 +158,7 @@ func handlerEdit(w http.ResponseWriter, rq *http.Request) {
u = user.FromRequest(rq)
lc = l18n.FromRequest(rq)
)
- if errtitle, err := shroom.CanEdit(u, h); err != nil {
+ if errtitle, err := shroom.CanEdit(u, h, lc); err != nil {
httpErr(w, lc, http.StatusInternalServerError, hyphaName,
errtitle,
err.Error())
@@ -201,7 +201,7 @@ func handlerUploadText(w http.ResponseWriter, rq *http.Request) {
)
if action != "Preview" {
- hop, errtitle = shroom.UploadText(h, []byte(textData), message, u)
+ hop, errtitle = shroom.UploadText(h, []byte(textData), message, u, lc)
if hop.HasErrors() {
httpErr(w, lc, http.StatusForbidden, hyphaName,
errtitle,
@@ -247,7 +247,7 @@ func handlerUploadBinary(w http.ResponseWriter, rq *http.Request) {
lc.Get("ui.error"),
err.Error())
}
- if errtitle, err := shroom.CanAttach(u, h); err != nil {
+ if errtitle, err := shroom.CanAttach(u, h, lc); err != nil {
httpErr(w, lc, http.StatusInternalServerError, hyphaName,
errtitle,
err.Error())
@@ -264,7 +264,7 @@ func handlerUploadBinary(w http.ResponseWriter, rq *http.Request) {
}
var (
mime = handler.Header.Get("Content-Type")
- hop, errtitle = shroom.UploadBinary(h, mime, file, u)
+ hop, errtitle = shroom.UploadBinary(h, mime, file, u, lc)
)
if hop.HasErrors() {