|
|
@@ -0,0 +1,299 @@
|
|
|
+package main
|
|
|
+
|
|
|
+import (
|
|
|
+ "flag"
|
|
|
+ "fmt"
|
|
|
+ "github.com/fsnotify/fsnotify"
|
|
|
+ "github.com/labstack/echo"
|
|
|
+ "github.com/labstack/echo/middleware"
|
|
|
+ "github.com/sergi/go-diff/diffmatchpatch"
|
|
|
+ "github.com/zeebo/xxh3"
|
|
|
+ "io/ioutil"
|
|
|
+ "log"
|
|
|
+ "net/http"
|
|
|
+ "os"
|
|
|
+ "path/filepath"
|
|
|
+ "strings"
|
|
|
+ "time"
|
|
|
+)
|
|
|
+
|
|
|
+type fileInfo struct {
|
|
|
+ Hash uint64 `json:"hash"`
|
|
|
+ Path string `json:"-"`
|
|
|
+ Name string `json:"name"`
|
|
|
+ Size int64 `json:"size"`
|
|
|
+ ModTime time.Time `json:"mod_time"`
|
|
|
+}
|
|
|
+
|
|
|
+func newFileInfo(name string) *fileInfo {
|
|
|
+ finfo := new(fileInfo)
|
|
|
+ finfo.Hash = xxh3.HashString(name)
|
|
|
+ finfo.Path = name
|
|
|
+ finfo.Name, _ = filepath.Rel(docDir, name)
|
|
|
+ return finfo
|
|
|
+}
|
|
|
+
|
|
|
+func (f *fileInfo) update(info *os.FileInfo) {
|
|
|
+ f.Size = (*info).Size()
|
|
|
+ f.ModTime = (*info).ModTime()
|
|
|
+}
|
|
|
+
|
|
|
+type fileCache struct {
|
|
|
+ Path string `json:"-"`
|
|
|
+ Hash uint64 `json:"-"`
|
|
|
+ Editing map[*wsCli]bool `json:"-"`
|
|
|
+ File string `json:"-"`
|
|
|
+}
|
|
|
+
|
|
|
+func newFileCache(info *fileInfo) *fileCache {
|
|
|
+ c := new(fileCache)
|
|
|
+ c.Editing = make(map[*wsCli]bool)
|
|
|
+ if info != nil {
|
|
|
+ data, _ := ioutil.ReadFile(info.Path)
|
|
|
+ c.File = string(data)
|
|
|
+ c.Path = info.Path
|
|
|
+ c.Hash = info.Hash
|
|
|
+ }
|
|
|
+ return c
|
|
|
+}
|
|
|
+
|
|
|
+func (c *fileCache) Name() string {
|
|
|
+ name, _ := filepath.Rel(docDir, c.Path)
|
|
|
+ return name
|
|
|
+}
|
|
|
+
|
|
|
+func (c *fileCache) AddEditor(cli *wsCli) {
|
|
|
+ c.Editing[cli] = true
|
|
|
+ c.SendWriters(cli, map[string]interface{}{"editors": len(c.Editing)})
|
|
|
+}
|
|
|
+
|
|
|
+func (c *fileCache) SendWriters(cli *wsCli, data interface{}) {
|
|
|
+ msg := wsMessage{
|
|
|
+ cli: cli,
|
|
|
+ hash: c.Hash,
|
|
|
+ CMD: wsWritersCMD,
|
|
|
+ Seq: 0,
|
|
|
+ Data: data,
|
|
|
+ }
|
|
|
+ hub.broadcast<-&msg
|
|
|
+}
|
|
|
+
|
|
|
+func (c *fileCache) GetUpdate() map[string]interface{} {
|
|
|
+ return map[string]interface{}{
|
|
|
+ "editors": len(c.Editing),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func (c *fileCache) RemoveEditor(cli *wsCli) {
|
|
|
+ delete(c.Editing, cli)
|
|
|
+ if len(c.Editing) == 0 {
|
|
|
+ c.Flush()
|
|
|
+ delete(fileCaches, c.Hash)
|
|
|
+ } else {
|
|
|
+ c.SendWriters(nil, map[string]interface{}{"editors": len(c.Editing)})
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func (c *fileCache) Flush() {
|
|
|
+ var err error
|
|
|
+ if len(strings.Trim(c.File, " \n\r\t")) == 0 {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ fmt.Printf("Flushing file: %s\n", c.Path)
|
|
|
+ var f *os.File
|
|
|
+ err = os.MkdirAll(filepath.Dir(c.Path), 0775)
|
|
|
+ if err != nil {
|
|
|
+ e.Logger.Errorf("Failed to create directory" + err.Error())
|
|
|
+ return
|
|
|
+ }
|
|
|
+ _ = watcher.Add(filepath.Dir(c.Path))
|
|
|
+ f, err = os.OpenFile(c.Path, os.O_CREATE|os.O_WRONLY, os.ModePerm)
|
|
|
+ if err != nil {
|
|
|
+ e.Logger.Errorf("Failed to create/open file" + err.Error())
|
|
|
+ return
|
|
|
+ }
|
|
|
+ _, err = f.Write([]byte(c.File))
|
|
|
+ if err != nil {
|
|
|
+ _ = f.Close()
|
|
|
+ e.Logger.Errorf("Failed to create/open file" + err.Error())
|
|
|
+ return
|
|
|
+ }
|
|
|
+ //if _, ok = fileTree[c.Hash]; !ok {
|
|
|
+ // finfo := newFileInfo(c.Path)
|
|
|
+ // fileTree[c.Hash] = finfo
|
|
|
+ // if info, err := f.Stat(); err != nil {
|
|
|
+ // finfo.update(&info)
|
|
|
+ // }
|
|
|
+ //}
|
|
|
+ _ = f.Close()
|
|
|
+}
|
|
|
+
|
|
|
+var (
|
|
|
+ e *echo.Echo
|
|
|
+ watcher *fsnotify.Watcher
|
|
|
+ fileTree map[uint64]*fileInfo
|
|
|
+ docDir string
|
|
|
+ fileCaches map[uint64]*fileCache
|
|
|
+ dmp = diffmatchpatch.New()
|
|
|
+)
|
|
|
+
|
|
|
+func init() {
|
|
|
+ fileTree = make(map[uint64]*fileInfo)
|
|
|
+ fileCaches = make(map[uint64]*fileCache)
|
|
|
+}
|
|
|
+
|
|
|
+func updateFileTree() {
|
|
|
+ _ = filepath.Walk(docDir, func(path string, info os.FileInfo, err error) error {
|
|
|
+ if err != nil { return err }
|
|
|
+ if info.IsDir() {
|
|
|
+ _ = watcher.Add(path)
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ if filepath.Ext(path) != ".md" { return nil }
|
|
|
+ var finfo *fileInfo
|
|
|
+ var ok bool
|
|
|
+ hash := xxh3.HashString(path)
|
|
|
+ if finfo, ok = fileTree[hash]; !ok {
|
|
|
+ finfo = newFileInfo(path)
|
|
|
+ fileTree[hash] = finfo
|
|
|
+ }
|
|
|
+ finfo.update(&info)
|
|
|
+ return nil
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+func fsWatcher() {
|
|
|
+ for {
|
|
|
+ select {
|
|
|
+ case event, ok := <-watcher.Events:
|
|
|
+ if !ok {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if filepath.Ext(event.Name) != ".md" { continue }
|
|
|
+ hash := xxh3.HashString(event.Name)
|
|
|
+ var finfo *fileInfo
|
|
|
+ var name string
|
|
|
+ if event.Op&fsnotify.Remove == fsnotify.Remove || event.Op&fsnotify.Rename == fsnotify.Rename {
|
|
|
+ if finfo, ok = fileTree[hash]; ok {
|
|
|
+ name = finfo.Name
|
|
|
+ delete(fileTree, hash)
|
|
|
+ }
|
|
|
+ goto annouceUpdate
|
|
|
+ }
|
|
|
+ // on create or write
|
|
|
+ if finfo, ok = fileTree[hash]; !ok {
|
|
|
+ finfo = newFileInfo(event.Name)
|
|
|
+ fileTree[hash] = finfo
|
|
|
+ }
|
|
|
+ name = finfo.Name
|
|
|
+ if event.Op&fsnotify.Write == fsnotify.Write {
|
|
|
+ info, err := os.Stat(event.Name)
|
|
|
+ if err != nil { continue }
|
|
|
+ finfo.update(&info)
|
|
|
+ }
|
|
|
+ annouceUpdate:
|
|
|
+ if len(hub.clients) > 0 {
|
|
|
+ msg := wsMessage{
|
|
|
+ CMD: wsFileUpdateCMD,
|
|
|
+ Data: name,
|
|
|
+ }
|
|
|
+ hub.broadcast<-&msg
|
|
|
+ }
|
|
|
+ case err, ok := <-watcher.Errors:
|
|
|
+ if !ok {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ log.Println("error:", err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func fixName(name string) string {
|
|
|
+ name = strings.ToLower(name)
|
|
|
+ if strings.HasSuffix(name, ".md") {
|
|
|
+ name = name[:len(name)-3]
|
|
|
+ }
|
|
|
+ name = strings.ReplaceAll(name, " ", "_")
|
|
|
+ name = strings.ReplaceAll(name, ".", "_")
|
|
|
+ return name + ".md"
|
|
|
+}
|
|
|
+
|
|
|
+func calcHash(name string) (uint64, string) {
|
|
|
+ path := filepath.Join(docDir, fixName(name))
|
|
|
+ hash := xxh3.HashString(path)
|
|
|
+ return hash, path
|
|
|
+}
|
|
|
+
|
|
|
+func main() {
|
|
|
+ adderss := flag.String("b", "127.0.0.1:8288", "Bind address")
|
|
|
+ flag.StringVar(&docDir, "d", "documents", "Document directory")
|
|
|
+ distDir := flag.String("w", "dist", "Web dist directory")
|
|
|
+ flag.Parse()
|
|
|
+ var err error
|
|
|
+ e = echo.New()
|
|
|
+ //e.Use(middleware.Logger())
|
|
|
+ e.Use(middleware.Recover())
|
|
|
+
|
|
|
+ defer func() {
|
|
|
+ for _, cached := range fileCaches {
|
|
|
+ cached.Flush()
|
|
|
+ }
|
|
|
+ }()
|
|
|
+ watcher, err = fsnotify.NewWatcher()
|
|
|
+ if err != nil {
|
|
|
+ e.Logger.Fatal(err)
|
|
|
+ }
|
|
|
+ defer watcher.Close()
|
|
|
+ go fsWatcher()
|
|
|
+ err = watcher.Add(docDir)
|
|
|
+ if err != nil {
|
|
|
+ e.Logger.Fatal(err)
|
|
|
+ }
|
|
|
+ updateFileTree()
|
|
|
+ e.Use(middleware.Recover())
|
|
|
+
|
|
|
+ e.GET("/_doc", func(c echo.Context) error {
|
|
|
+ return c.JSON(http.StatusOK, fileTree)
|
|
|
+ })
|
|
|
+
|
|
|
+ e.GET("/_ws", handleWS)
|
|
|
+
|
|
|
+ docs := e.Group("/_doc/")
|
|
|
+ docs.Use(middleware.GzipWithConfig(middleware.GzipConfig{Level: 5}))
|
|
|
+ docs.GET("*", func(c echo.Context) error {
|
|
|
+ hash, fname := calcHash(c.Param("*"))
|
|
|
+ if cached, ok := fileCaches[hash]; ok {
|
|
|
+ fmt.Println("Serving cached: " + fname)
|
|
|
+ return c.Blob(http.StatusOK, "text/markdown", []byte(cached.File))
|
|
|
+ }
|
|
|
+ if _, ok := fileTree[hash]; !ok {
|
|
|
+ return c.Blob(http.StatusOK, "text/markdown", nil)
|
|
|
+ }
|
|
|
+ fmt.Println("Serving from file: " + fname)
|
|
|
+ return c.File(fname)
|
|
|
+ })
|
|
|
+ docs.DELETE("*", func(c echo.Context) error {
|
|
|
+ hash, fname := calcHash(c.Param("*"))
|
|
|
+ if _, ok := fileTree[hash]; ok {
|
|
|
+ err = os.Remove(fname)
|
|
|
+ if err != nil {
|
|
|
+ e.Logger.Errorf("Failed to remove file %s: %v", fname, err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return c.JSON(http.StatusOK, true)
|
|
|
+ })
|
|
|
+
|
|
|
+ e.Static("/js", filepath.Join(*distDir, "js"))
|
|
|
+ e.Static("/css", filepath.Join(*distDir, "css"))
|
|
|
+ e.Static("/fonts", filepath.Join(*distDir, "fonts"))
|
|
|
+ e.Static("/img", filepath.Join(*distDir, "img"))
|
|
|
+ e.File("/favicon.png", filepath.Join(*distDir, "favicon.png"))
|
|
|
+ e.File("/favicon.ico", filepath.Join(*distDir, "favicon.png"))
|
|
|
+ e.GET("/*", func(c echo.Context) error {
|
|
|
+ return c.File(filepath.Join(*distDir, "index.html"))
|
|
|
+ })
|
|
|
+
|
|
|
+ go hub.run()
|
|
|
+ e.Logger.Fatal(e.Start(*adderss))
|
|
|
+
|
|
|
+}
|