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