diff --git a/README.md b/README.md
index a733e95..c7b62f0 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,13 @@
-# ๐ MycorrhizaWiki 0.8
+# ๐ MycorrhizaWiki 0.9
A wiki engine.
+## 0.9
+This is a development branch for 0.9 version. Features I want to implement in this release:
+* [x] Recent changes page.
+* [x] Hypha deletion.
+* [x] Hypha renaming.
+* [x] Support async git ops.
+
## Installation
```sh
git clone --recurse-submodules https://github.com/bouncepaw/mycorrhiza
@@ -31,5 +38,4 @@ Help is always needed. We have a [tg chat](https://t.me/mycorrhizadev) where som
* Tagging system
* Authorization
* Better history viewing
-* Recent changes page
* More markups
diff --git a/go.mod b/go.mod
index 17f2ff6..e2ffb7c 100644
--- a/go.mod
+++ b/go.mod
@@ -2,4 +2,4 @@ module github.com/bouncepaw/mycorrhiza
go 1.14
-require github.com/valyala/quicktemplate v1.6.2
+require github.com/valyala/quicktemplate v1.6.3
diff --git a/go.sum b/go.sum
index 344ceb3..a9a43a1 100644
--- a/go.sum
+++ b/go.sum
@@ -1,11 +1,11 @@
github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/klauspost/compress v1.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
-github.com/klauspost/compress v1.10.10/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
+github.com/klauspost/compress v1.11.0/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
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.15.1/go.mod h1:YOKImeEosDdBPnxc0gy7INqi3m1zK6A+xl6TwOBhHCA=
-github.com/valyala/quicktemplate v1.6.2 h1:k0vgK7zlmFzqAoIBIOrhrfmZ6JoTGJlLRPLbkPGr2/M=
-github.com/valyala/quicktemplate v1.6.2/go.mod h1:mtEJpQtUiBV0SHhMX6RtiJtqxncgrfmjcUy5T68X8TM=
+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=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
diff --git a/history/history.go b/history/history.go
index 787b358..c57ef96 100644
--- a/history/history.go
+++ b/history/history.go
@@ -6,6 +6,7 @@ import (
"log"
"os/exec"
"strconv"
+ "strings"
"time"
"github.com/bouncepaw/mycorrhiza/util"
@@ -31,6 +32,58 @@ type Revision struct {
Message string
}
+// TimeString returns a human readable time representation.
+func (rev Revision) TimeString() string {
+ return rev.Time.Format(time.RFC822)
+}
+
+// HyphaeLinks returns a comma-separated list of hyphae that were affected by this revision as HTML string.
+func (rev Revision) HyphaeLinks() (html string) {
+ // diff-tree --no-commit-id --name-only -r
+ var (
+ // List of files affected by this revision, one per line.
+ out, err = gitsh("diff-tree", "--no-commit-id", "--name-only", "-r", rev.Hash)
+ // set is used to determine if a certain hypha has been already noted (hyphae are stored in 2 files at most).
+ set = make(map[string]bool)
+ isNewName = func(hyphaName string) bool {
+ if _, present := set[hyphaName]; present {
+ return false
+ } else {
+ set[hyphaName] = true
+ return true
+ }
+ }
+ )
+ if err != nil {
+ return ""
+ }
+ for _, filename := range strings.Split(out.String(), "\n") {
+ // If filename has an ampersand:
+ if strings.IndexRune(filename, '&') >= 0 {
+ // Remove ampersanded suffix from filename:
+ ampersandPos := strings.LastIndexByte(filename, '&')
+ hyphaName := string([]byte(filename)[0:ampersandPos]) // is it safe?
+ if isNewName(hyphaName) {
+ // Entries are separated by commas
+ if len(set) > 1 {
+ html += `, `
+ }
+ html += fmt.Sprintf(`%[2]s`, rev.Hash, hyphaName)
+ }
+ }
+ }
+ return html
+}
+
+func (rev Revision) RecentChangesEntry() (html string) {
+ return fmt.Sprintf(`
+
+%s
+%s
+%s
+`, rev.TimeString(), rev.Hash, rev.HyphaeLinks(), rev.Message)
+}
+
// Path to git executable. Set at init()
var gitpath string
@@ -46,10 +99,10 @@ func init() {
}
// 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) {
fmt.Printf("$ %v\n", args)
cmd := exec.Command(gitpath, args...)
-
cmd.Dir = util.WikiDir
b, err := cmd.CombinedOutput()
diff --git a/history/information.go b/history/information.go
index ff6e654..469d9ab 100644
--- a/history/information.go
+++ b/history/information.go
@@ -5,10 +5,33 @@ package history
import (
"fmt"
"regexp"
+ "strconv"
"strings"
- "time"
+
+ "github.com/bouncepaw/mycorrhiza/templates"
)
+func RecentChanges(n int) string {
+ var (
+ out, err = gitsh(
+ "log", "--oneline", "--no-merges",
+ "--pretty=format:\"%h\t%ce\t%ct\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))
+ }
+ }
+ entries := make([]string, len(revs))
+ for i, rev := range revs {
+ entries[i] = rev.RecentChangesEntry()
+ }
+ return templates.RecentChangesHTML(entries, n)
+}
+
// Revisions returns slice of revisions for the given hypha name.
func Revisions(hyphaName string) ([]Revision, error) {
var (
@@ -22,7 +45,9 @@ func Revisions(hyphaName string) ([]Revision, error) {
)
if err == nil {
for _, line := range strings.Split(out.String(), "\n") {
- revs = append(revs, parseRevisionLine(line))
+ if line != "" {
+ revs = append(revs, parseRevisionLine(line))
+ }
}
}
return revs, err
@@ -48,7 +73,7 @@ func (rev *Revision) AsHtmlTableRow(hyphaName string) string {
|
%s |
%s |
-`, rev.Time.Format(time.RFC822), rev.Hash, hyphaName, rev.Hash, rev.Message)
+`, rev.TimeString(), rev.Hash, hyphaName, rev.Hash, rev.Message)
}
// See how the file with `filepath` looked at commit with `hash`.
diff --git a/history/operations.go b/history/operations.go
index 3567d78..4388916 100644
--- a/history/operations.go
+++ b/history/operations.go
@@ -4,10 +4,16 @@ package history
import (
"fmt"
+ "os"
+ "path/filepath"
+ "sync"
"github.com/bouncepaw/mycorrhiza/util"
)
+// gitMutex is used for blocking git operations to avoid clashes.
+var gitMutex = sync.Mutex{}
+
// OpType is the type a history operation has. Callers shall set appropriate optypes when creating history operations.
type OpType int
@@ -15,6 +21,8 @@ const (
TypeNone OpType = iota
TypeEditText
TypeEditBinary
+ TypeDeleteHypha
+ TypeRenameHypha
)
// HistoryOp is an object representing a history operation.
@@ -29,6 +37,7 @@ type HistoryOp struct {
// Operation is a constructor of a history operation.
func Operation(opType OpType) *HistoryOp {
+ gitMutex.Lock()
hop := &HistoryOp{
Errs: []error{},
opType: opType,
@@ -46,6 +55,28 @@ func (hop *HistoryOp) gitop(args ...string) *HistoryOp {
return hop
}
+// WithFilesRemoved git-rm-s all passed `paths`. Paths can be rooted or not. Paths that are empty strings are ignored.
+func (hop *HistoryOp) WithFilesRemoved(paths ...string) *HistoryOp {
+ args := []string{"rm", "--quiet", "--"}
+ for _, path := range paths {
+ if path != "" {
+ args = append(args, path)
+ }
+ }
+ return hop.gitop(args...)
+}
+
+// WithFilesRenamed git-mv-s all passed keys of `pairs` to values of `pairs`. Paths can be rooted ot not. Empty keys are ignored.
+func (hop *HistoryOp) WithFilesRenamed(pairs map[string]string) *HistoryOp {
+ for from, to := range pairs {
+ if from != "" {
+ os.MkdirAll(filepath.Dir(to), 0777)
+ hop.gitop(append([]string{"mv"}, from, to)...)
+ }
+ }
+ return hop
+}
+
// WithFiles stages all passed `paths`. Paths can be rooted or not.
func (hop *HistoryOp) WithFiles(paths ...string) *HistoryOp {
for i, path := range paths {
@@ -62,6 +93,7 @@ func (hop *HistoryOp) Apply() *HistoryOp {
"--author='"+hop.name+" <"+hop.email+">'",
"--message="+hop.userMsg,
)
+ gitMutex.Unlock()
return hop
}
diff --git a/http_mutators.go b/http_mutators.go
index e3cd397..31f9915 100644
--- a/http_mutators.go
+++ b/http_mutators.go
@@ -17,6 +17,89 @@ func init() {
http.HandleFunc("/upload-binary/", handlerUploadBinary)
http.HandleFunc("/upload-text/", handlerUploadText)
http.HandleFunc("/edit/", handlerEdit)
+ http.HandleFunc("/delete-ask/", handlerDeleteAsk)
+ http.HandleFunc("/delete-confirm/", handlerDeleteConfirm)
+ http.HandleFunc("/rename-ask/", handlerRenameAsk)
+ http.HandleFunc("/rename-confirm/", handlerRenameConfirm)
+}
+
+func handlerRenameAsk(w http.ResponseWriter, rq *http.Request) {
+ log.Println(rq.URL)
+ var (
+ hyphaName = HyphaNameFromRq(rq, "rename-ask")
+ _, isOld = HyphaStorage[hyphaName]
+ )
+ util.HTTP200Page(w, base("Rename "+hyphaName+"?", templates.RenameAskHTML(hyphaName, isOld)))
+}
+
+func handlerRenameConfirm(w http.ResponseWriter, rq *http.Request) {
+ log.Println(rq.URL)
+ var (
+ hyphaName = HyphaNameFromRq(rq, "rename-confirm")
+ hyphaData, isOld = HyphaStorage[hyphaName]
+ newName = CanonicalName(rq.PostFormValue("new-name"))
+ _, newNameIsUsed = HyphaStorage[newName]
+ recursive bool
+ )
+ if rq.PostFormValue("recursive") == "true" {
+ recursive = true
+ }
+ switch {
+ case newNameIsUsed:
+ HttpErr(w, http.StatusBadRequest, hyphaName, "Error: hypha exists",
+ fmt.Sprintf("Hypha named %s already exists.", hyphaName, hyphaName))
+ case newName == "":
+ HttpErr(w, http.StatusBadRequest, hyphaName, "Error: no name",
+ "No new name is given.")
+ case !isOld:
+ HttpErr(w, http.StatusBadRequest, hyphaName, "Error: no such hypha",
+ "Cannot rename a hypha that does not exist yet.")
+ case !HyphaPattern.MatchString(newName):
+ HttpErr(w, http.StatusBadRequest, hyphaName, "Error: invalid name",
+ "Invalid new name. Names cannot contain characters ^?!:#@><*|\"\\'&%
")
+ default:
+ if hop := hyphaData.RenameHypha(hyphaName, newName, recursive); len(hop.Errs) == 0 {
+ http.Redirect(w, rq, "/page/"+newName, http.StatusSeeOther)
+ } else {
+ HttpErr(w, http.StatusInternalServerError, hyphaName,
+ "Error: could not rename hypha",
+ fmt.Sprintf("Could not rename this hypha due to an internal error. Server errors: %v
", hop.Errs))
+ }
+ }
+}
+
+// handlerDeleteAsk shows a delete dialog.
+func handlerDeleteAsk(w http.ResponseWriter, rq *http.Request) {
+ log.Println(rq.URL)
+ var (
+ hyphaName = HyphaNameFromRq(rq, "delete-ask")
+ _, isOld = HyphaStorage[hyphaName]
+ )
+ util.HTTP200Page(w, base("Delete "+hyphaName+"?", templates.DeleteAskHTML(hyphaName, isOld)))
+}
+
+// handlerDeleteConfirm deletes a hypha for sure
+func handlerDeleteConfirm(w http.ResponseWriter, rq *http.Request) {
+ log.Println(rq.URL)
+ var (
+ hyphaName = HyphaNameFromRq(rq, "delete-confirm")
+ hyphaData, isOld = HyphaStorage[hyphaName]
+ )
+ if isOld {
+ // If deleted successfully
+ if hop := hyphaData.DeleteHypha(hyphaName); len(hop.Errs) == 0 {
+ http.Redirect(w, rq, "/page/"+hyphaName, http.StatusSeeOther)
+ } else {
+ HttpErr(w, http.StatusInternalServerError, hyphaName,
+ "Error: could not delete hypha",
+ fmt.Sprintf("Could not delete this hypha due to an internal error. Server errors: %v
", hop.Errs))
+ }
+ } else {
+ // The precondition is to have the hypha in the first place.
+ HttpErr(w, http.StatusPreconditionFailed, hyphaName,
+ "Error: no such hypha",
+ "Could not delete this hypha because it does not exist.")
+ }
}
// handlerEdit shows the edit form. It doesn't edit anything actually.
diff --git a/http_readers.go b/http_readers.go
index f288210..5843e4d 100644
--- a/http_readers.go
+++ b/http_readers.go
@@ -6,7 +6,6 @@ import (
"log"
"net/http"
"os"
- "path"
"strings"
"github.com/bouncepaw/mycorrhiza/gemtext"
@@ -29,8 +28,9 @@ func handlerRevision(w http.ResponseWriter, rq *http.Request) {
log.Println(rq.URL)
var (
shorterUrl = strings.TrimPrefix(rq.URL.Path, "/rev/")
- revHash = path.Dir(shorterUrl)
- hyphaName = CanonicalName(strings.TrimPrefix(shorterUrl, revHash+"/"))
+ firstSlashIndex = strings.IndexRune(shorterUrl, '/')
+ revHash = shorterUrl[:firstSlashIndex]
+ hyphaName = CanonicalName(shorterUrl[firstSlashIndex+1:])
contents = fmt.Sprintf(`This hypha had no text at this revision.
`)
textPath = hyphaName + "&.gmi"
textContents, err = history.FileAtRevision(textPath, revHash)
@@ -55,15 +55,15 @@ func handlerHistory(w http.ResponseWriter, rq *http.Request) {
log.Println(rq.URL)
hyphaName := HyphaNameFromRq(rq, "history")
var tbody string
- if _, ok := HyphaStorage[hyphaName]; ok {
- revs, err := history.Revisions(hyphaName)
- if err == nil {
- for _, rev := range revs {
- tbody += rev.AsHtmlTableRow(hyphaName)
- }
+
+ // History can be found for files that do not exist anymore.
+ revs, err := history.Revisions(hyphaName)
+ if err == nil {
+ for _, rev := range revs {
+ tbody += rev.AsHtmlTableRow(hyphaName)
}
- log.Println(revs)
}
+ log.Println("Found", len(revs), "revisions for", hyphaName)
util.HTTP200Page(w,
base(hyphaName, templates.HistoryHTML(hyphaName, tbody)))
diff --git a/hypha.go b/hypha.go
index d3be116..74a8685 100644
--- a/hypha.go
+++ b/hypha.go
@@ -7,8 +7,11 @@ import (
"log"
"os"
"path/filepath"
+ "strings"
"github.com/bouncepaw/mycorrhiza/gemtext"
+ "github.com/bouncepaw/mycorrhiza/history"
+ "github.com/bouncepaw/mycorrhiza/util"
)
func init() {
@@ -37,6 +40,78 @@ type HyphaData struct {
binaryType BinaryType
}
+// DeleteHypha deletes hypha and makes a history record about that.
+func (hd *HyphaData) DeleteHypha(hyphaName string) *history.HistoryOp {
+ hop := history.Operation(history.TypeDeleteHypha).
+ WithFilesRemoved(hd.textPath, hd.binaryPath).
+ WithMsg(fmt.Sprintf("Delete โ%sโ", hyphaName)).
+ WithSignature("anon").
+ Apply()
+ if len(hop.Errs) == 0 {
+ delete(HyphaStorage, hyphaName)
+ }
+ return hop
+}
+
+func findHyphaeToRename(hyphaName string, recursive bool) []string {
+ hyphae := []string{hyphaName}
+ if recursive {
+ hyphae = append(hyphae, util.FindSubhyphae(hyphaName, IterateHyphaNamesWith)...)
+ }
+ return hyphae
+}
+
+func renamingPairs(hyphaNames []string, replaceName func(string) string) map[string]string {
+ renameMap := make(map[string]string)
+ for _, hn := range hyphaNames {
+ if hd, ok := HyphaStorage[hn]; ok {
+ if hd.textPath != "" {
+ renameMap[hd.textPath] = replaceName(hd.textPath)
+ }
+ if hd.binaryPath != "" {
+ renameMap[hd.binaryPath] = replaceName(hd.binaryPath)
+ }
+ }
+ }
+ return renameMap
+}
+
+// word Data is plural here
+func relocateHyphaData(hyphaNames []string, replaceName func(string) string) {
+ for _, hyphaName := range hyphaNames {
+ if hd, ok := HyphaStorage[hyphaName]; ok {
+ hd.textPath = replaceName(hd.textPath)
+ hd.binaryPath = replaceName(hd.binaryPath)
+ HyphaStorage[replaceName(hyphaName)] = hd
+ delete(HyphaStorage, hyphaName)
+ }
+ }
+}
+
+// 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 (hd *HyphaData) RenameHypha(hyphaName, newName string, recursive bool) *history.HistoryOp {
+ var (
+ replaceName = func(str string) string {
+ return strings.Replace(str, hyphaName, newName, 1)
+ }
+ hyphaNames = findHyphaeToRename(hyphaName, recursive)
+ renameMap = renamingPairs(hyphaNames, replaceName)
+ renameMsg = "Rename โ%sโ to โ%sโ"
+ hop = history.Operation(history.TypeRenameHypha)
+ )
+ if recursive {
+ renameMsg += " recursively"
+ }
+ hop.WithFilesRenamed(renameMap).
+ WithMsg(fmt.Sprintf(renameMsg, hyphaName, newName)).
+ WithSignature("anon").
+ Apply()
+ if len(hop.Errs) == 0 {
+ relocateHyphaData(hyphaNames, replaceName)
+ }
+ return hop
+}
+
// binaryHtmlBlock creates an html block for binary part of the hypha.
func binaryHtmlBlock(hyphaName string, d *HyphaData) string {
switch d.binaryType {
diff --git a/main.go b/main.go
index cb9588f..7956270 100644
--- a/main.go
+++ b/main.go
@@ -10,6 +10,8 @@ import (
"os"
"path/filepath"
"regexp"
+ "strconv"
+ "strings"
"github.com/bouncepaw/mycorrhiza/history"
"github.com/bouncepaw/mycorrhiza/templates"
@@ -19,7 +21,7 @@ import (
// WikiDir is a rooted path to the wiki storage directory.
var WikiDir string
-// HyphaPattern is a pattern which all hyphae must match. Not used currently.
+// HyphaPattern is a pattern which all hyphae must match.
var HyphaPattern = regexp.MustCompile(`[^?!:#@><*|"\'&%]+`)
// HyphaStorage is a mapping between canonical hypha names and their meta information.
@@ -83,6 +85,20 @@ func handlerRandom(w http.ResponseWriter, rq *http.Request) {
http.Redirect(w, rq, "/page/"+randomHyphaName, http.StatusSeeOther)
}
+// Recent changes
+func handlerRecentChanges(w http.ResponseWriter, rq *http.Request) {
+ log.Println(rq.URL)
+ var (
+ noPrefix = strings.TrimPrefix(rq.URL.String(), "/recent-changes/")
+ n, err = strconv.Atoi(noPrefix)
+ )
+ if err == nil {
+ util.HTTP200Page(w, base(strconv.Itoa(n)+" recent changes", history.RecentChanges(n)))
+ } else {
+ http.Redirect(w, rq, "/recent-changes/20", http.StatusSeeOther)
+ }
+}
+
func main() {
log.Println("Running MycorrhizaWiki ฮฒ")
@@ -104,10 +120,11 @@ func main() {
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(WikiDir+"/static"))))
// See http_readers.go for /page/, /text/, /binary/, /history/.
- // See http_mutators.go for /upload-binary/, /upload-text/, /edit/.
+ // See http_mutators.go for /upload-binary/, /upload-text/, /edit/, /delete-ask/, /delete-confirm/, /rename-ask/, /rename-confirm/.
http.HandleFunc("/list", handlerList)
http.HandleFunc("/reindex", handlerReindex)
http.HandleFunc("/random", handlerRandom)
+ http.HandleFunc("/recent-changes/", handlerRecentChanges)
http.HandleFunc("/favicon.ico", func(w http.ResponseWriter, rq *http.Request) {
http.ServeFile(w, rq, WikiDir+"/static/favicon.ico")
})
diff --git a/metarrhiza b/metarrhiza
index bdaaab6..2c0e431 160000
--- a/metarrhiza
+++ b/metarrhiza
@@ -1 +1 @@
-Subproject commit bdaaab62574023487610d608d1e9f2f351707a7f
+Subproject commit 2c0e43199ed28f7022a38463a0eec3af3ecb03c9
diff --git a/templates/common.qtpl b/templates/common.qtpl
new file mode 100644
index 0000000..c199fa2
--- /dev/null
+++ b/templates/common.qtpl
@@ -0,0 +1,32 @@
+This is the