Min 4 år sedan
incheckning
a4717bfb11

+ 299 - 0
main.go

@@ -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))
+
+}

+ 24 - 0
web/README.md

@@ -0,0 +1,24 @@
+# symboref
+
+## Project setup
+```
+npm install
+```
+
+### Compiles and hot-reloads for development
+```
+npm run serve
+```
+
+### Compiles and minifies for production
+```
+npm run build
+```
+
+### Lints and fixes files
+```
+npm run lint
+```
+
+### Customize configuration
+See [Configuration Reference](https://cli.vuejs.org/config/).

+ 58 - 0
web/package.json

@@ -0,0 +1,58 @@
+{
+  "name": "symboref",
+  "version": "0.1.0",
+  "private": true,
+  "scripts": {
+    "serve": "vue-cli-service serve",
+    "build": "vue-cli-service build",
+    "lint": "vue-cli-service lint"
+  },
+  "dependencies": {
+    "@iktakahiro/markdown-it-katex": "^4.0.1",
+    "babel-runtime": "^6.26.0",
+    "bootstrap": "3",
+    "bootstrap-css": "^4.0.0-alpha.5",
+    "bulma": "^0.9.2",
+    "copy-to-clipboard": "^3.3.1",
+    "debounce": "^1.2.0",
+    "diff": "^5.0.0",
+    "flow-bin": "^0.144.0",
+    "flow-webpack-plugin": "^1.2.0",
+    "highlight.js": "^10.6.0",
+    "katex": "^0.12.0",
+    "less": "^4.1.1",
+    "less-loader": "^7.3.0",
+    "markdown-it": "^12.0.4",
+    "markdown-it-abbr": "^1.0.4",
+    "markdown-it-anchor": "^7.0.2",
+    "markdown-it-deflist": "^2.1.0",
+    "markdown-it-emoji": "^2.0.0",
+    "markdown-it-footnote": "^3.0.2",
+    "markdown-it-ins": "^3.0.1",
+    "markdown-it-katex": "^2.0.3",
+    "markdown-it-mark": "^3.0.1",
+    "markdown-it-sub": "^1.0.0",
+    "markdown-it-sup": "^1.0.0",
+    "markdown-it-table-of-contents": "^0.5.2",
+    "markdown-it-task-lists": "^2.1.1",
+    "markdown-it-toc-and-anchor": "^4.2.0",
+    "myers-diff": "^2.0.1",
+    "pug": "^3.0.0",
+    "pug-plain-loader": "^1.1.0",
+    "sass": "^1.32.7",
+    "sass-loader": "10.1.1",
+    "typescript": "^4.1.5",
+    "vue": "^2.6.11",
+    "vue-codemirror": "^4.0.6",
+    "vue-markdown": "^2.2.4",
+    "vue-router": "^3.2.0"
+  },
+  "devDependencies": {
+    "@vue/cli-plugin-eslint": "~4.5.0",
+    "@vue/cli-plugin-router": "~4.5.0",
+    "@vue/cli-service": "~4.5.0",
+    "eslint": "^6.7.2",
+    "eslint-plugin-vue": "^6.2.2",
+    "vue-template-compiler": "^2.6.11"
+  }
+}

BIN
web/public/favicon.png


+ 17 - 0
web/public/index.html

@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html lang="">
+  <head>
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width,initial-scale=1.0">
+    <link rel="icon" href="<%= BASE_URL %>favicon.png">
+    <title><%= htmlWebpackPlugin.options.title %></title>
+  </head>
+  <body>
+    <noscript>
+      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
+    </noscript>
+    <div id="app"></div>
+    <!-- built files will be auto injected -->
+  </body>
+</html>

+ 45 - 0
web/src/App.vue

@@ -0,0 +1,45 @@
+<template lang="pug">
+  #app
+    nav.navbar.navbar-default
+      .container-fluid
+        .navbar-header
+          router-link.navbar-brand(to="/") SymboRef
+        ul.nav.navbar-nav
+          template(v-if="$route.name === 'Document'")
+            li
+              router-link(:to="'/edit/' + $route.params.pathMatch") Edit
+            li
+              router-link.nav-link(to="/") Delete
+          template(v-if="$route.name === 'Edit'")
+            li
+              router-link.nav-link(:to="'/doc/' + $route.params.pathMatch") View
+            li
+              router-link.nav-link(to="/") Delete
+        .navbar-right
+          .navbar-text(v-if="$route.name === 'Edit'")
+            span.badge {{ ws_editors }}
+          p.navbar-text {{ ws_stat }}
+        //.ws {{ ws_stat }}
+    router-view.container-fluid
+</template>
+
+<script>
+export default {
+  name: "App",
+  data() {
+    return {
+      ws_stat: '..',
+      ws_editors: '?'
+    }
+  },
+  methods: {
+  },
+  mounted() {
+    this.$ws.onstatus = (msg) => { this.ws_stat = msg }
+    this.$ws.connect()
+    this.$ws.addCMDListener(5, (msg) => {
+      if(typeof msg.editors !== 'undefined')  this.ws_editors = msg.editors
+    })
+  }
+}
+</script>

+ 59 - 0
web/src/components/Equation.vue

@@ -0,0 +1,59 @@
+<template lang="pug">
+  div
+    .row
+      .col.c10
+        div(v-html="html")
+      .col.c2
+        .copy(@click="copy") Copy
+    .warning(v-show="error") {{ error }}
+
+</template>
+
+<script>
+import copy from 'copy-to-clipboard';
+import katex from 'katex'
+
+export default {
+  name: "Equation",
+  props: {
+    data: String,
+  },
+  data() {
+    return {
+      html: '',
+      error: '',
+    }
+  },
+  methods: {
+    copy() {
+      copy(this.data)
+    },
+    update() {
+      try {
+        this.error = '';
+        this.html = katex.renderToString(this.data, {
+          trust: true,
+        });
+      } catch (e) {
+        this.error = e.message
+      }
+    }
+  },
+  mounted() {
+    this.update()
+  },
+  watch: {
+    data() {
+      this.update()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.copy {
+  color: blue;
+  text-decoration: underline;
+  cursor: pointer;
+}
+</style>

+ 280 - 0
web/src/components/VueMarkdown.js

@@ -0,0 +1,280 @@
+/*
+The MIT License (MIT)
+
+Copyright (c) 2016 Chao Lee
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+Code taken from https://github.com/miaolz123/vue-markdown
+ */
+
+import markdownIt from 'markdown-it'
+import emoji from 'markdown-it-emoji'
+import subscript from 'markdown-it-sub'
+import superscript from 'markdown-it-sup'
+import footnote from 'markdown-it-footnote'
+import deflist from 'markdown-it-deflist'
+import abbreviation from 'markdown-it-abbr'
+import insert from 'markdown-it-ins'
+import mark from 'markdown-it-mark'
+// import toc from 'markdown-it-toc-and-anchor'
+// import katex from 'markdown-it-katex'
+import katex from '@iktakahiro/markdown-it-katex'
+import tasklists from 'markdown-it-task-lists'
+import tableOfContents from 'markdown-it-table-of-contents'
+import anchor from 'markdown-it-anchor'
+
+
+export default {
+  md: new markdownIt(),
+
+  template: '<div><slot></slot></div>',
+
+  data() {
+    return {
+      sourceData: this.source,
+    }
+  },
+
+  props: {
+    watches: {
+      type: Array,
+      default: () => ['source', 'show', 'toc'],
+    },
+    source: {
+      type: String,
+      default: ``,
+    },
+    show: {
+      type: Boolean,
+      default: true,
+    },
+    highlight: {
+      type: Boolean,
+      default: true
+    },
+    html: {
+      type: Boolean,
+      default: true,
+    },
+    xhtmlOut: {
+      type: Boolean,
+      default: true,
+    },
+    breaks: {
+      type: Boolean,
+      default: true,
+    },
+    linkify: {
+      type: Boolean,
+      default: true,
+    },
+    emoji: {
+      type: Boolean,
+      default: true,
+    },
+    typographer: {
+      type: Boolean,
+      default: true,
+    },
+    langPrefix: {
+      type: String,
+      default: 'language-',
+    },
+    quotes: {
+      type: String,
+      default: '“”‘’',
+    },
+    tableClass: {
+      type: String,
+      default: 'table',
+    },
+    taskLists: {
+      type: Boolean,
+      default: true
+    },
+    toc: {
+      type: Boolean,
+      default: false,
+    },
+    tocId: {
+      type: String,
+    },
+    tocClass: {
+      type: String,
+      default: 'table-of-contents',
+    },
+    tocFirstLevel: {
+      type: Number,
+      default: 2,
+    },
+    tocLastLevel: {
+      type: Number,
+    },
+    tocAnchorLink: {
+      type: Boolean,
+      default: true,
+    },
+    tocAnchorClass: {
+      type: String,
+      default: 'toc-anchor',
+    },
+    tocAnchorLinkSymbol: {
+      type: String,
+      default: '#',
+    },
+    tocAnchorLinkSpace: {
+      type: Boolean,
+      default: true,
+    },
+    tocAnchorLinkClass: {
+      type: String,
+      default: 'toc-anchor-link',
+    },
+    anchorAttributes: {
+      type: Object,
+      default: () => ({})
+    },
+    katexMacros: {
+      type: Object,
+      default: () => ({})
+    },
+    prerender: {
+      type: Function,
+      default: (sourceData) => { return sourceData }
+    },
+    postrender: {
+      type: Function,
+      default: (htmlData) => { return htmlData }
+    }
+  },
+
+  computed: {
+    tocLastLevelComputed() {
+      return this.tocLastLevel > this.tocFirstLevel ? this.tocLastLevel : this.tocFirstLevel + 1
+    }
+  },
+
+  render(createElement) {
+    this.md = new markdownIt()
+      .use(anchor)
+      .use(subscript)
+      .use(superscript)
+      .use(footnote)
+      .use(deflist)
+      .use(abbreviation)
+      .use(insert)
+      .use(mark)
+      .use(tableOfContents)
+      .use(katex, {
+        throwOnError: false,
+        macros: this.katexMacros,
+        trust: true
+      })
+      .use(tasklists, { enabled: this.taskLists })
+
+    if (this.emoji) {
+      this.md.use(emoji)
+    }
+
+    this.md.set({
+      html: this.html,
+      xhtmlOut: this.xhtmlOut,
+      breaks: this.breaks,
+      linkify: this.linkify,
+      typographer: this.typographer,
+      langPrefix: this.langPrefix,
+      quotes: this.quotes,
+    })
+    this.md.renderer.rules.table_open = () => `<table class="${this.tableClass}">\n`
+    let defaultLinkRenderer = this.md.renderer.rules.link_open ||
+      function (tokens, idx, options, env, self) {
+        return self.renderToken(tokens, idx, options)
+      }
+    this.md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
+      Object.keys(this.anchorAttributes).map((attribute) => {
+        let aIndex = tokens[idx].attrIndex(attribute)
+        let value = this.anchorAttributes[attribute]
+        if (aIndex < 0) {
+          tokens[idx].attrPush([attribute, value]) // add new attribute
+        } else {
+          tokens[idx].attrs[aIndex][1] = value
+        }
+      })
+      return defaultLinkRenderer(tokens, idx, options, env, self)
+    }
+
+    // if (this.toc) {
+    //   this.md.use(toc, {
+    //     tocClassName: this.tocClass,
+    //     tocFirstLevel: this.tocFirstLevel,
+    //     tocLastLevel: this.tocLastLevelComputed,
+    //     anchorLink: this.tocAnchorLink,
+    //     anchorLinkSymbol: this.tocAnchorLinkSymbol,
+    //     anchorLinkSpace: this.tocAnchorLinkSpace,
+    //     anchorClassName: this.tocAnchorClass,
+    //     anchorLinkSymbolClassName: this.tocAnchorLinkClass,
+    //     tocCallback: (tocMarkdown, tocArray, tocHtml) => {
+    //       if (tocHtml) {
+    //         if (this.tocId && document.getElementById(this.tocId)) {
+    //           document.getElementById(this.tocId).innerHTML = tocHtml
+    //         }
+    //
+    //         this.$emit('toc-rendered', tocHtml)
+    //       }
+    //     },
+    //   })
+    // }
+
+    let outHtml = this.show ?
+      this.md.render(
+        this.prerender(this.sourceData)
+      ) : ''
+    outHtml = this.postrender(outHtml);
+
+    this.$emit('rendered', outHtml)
+    return createElement(
+      'div', {
+        domProps: {
+          innerHTML: outHtml,
+        },
+      },
+    )
+  },
+
+  beforeMount() {
+    if (this.$slots.default) {
+      this.sourceData = ''
+      for (let slot of this.$slots.default) {
+        this.sourceData += slot.text
+      }
+    }
+
+    this.$watch('source', () => {
+      this.sourceData = this.prerender(this.source)
+      this.$forceUpdate()
+    })
+
+    this.watches.forEach((v) => {
+      this.$watch(v, () => {
+        this.$forceUpdate()
+      })
+    })
+  },
+}

+ 43 - 0
web/src/main.js

@@ -0,0 +1,43 @@
+import Vue from 'vue'
+import App from './App.vue'
+import router from './router'
+// import VueHighlightJS from 'vue-highlight.js';
+// import markdown from 'highlight.js/lib/languages/markdown';
+// import 'highlight.js/styles/default.css';
+import ws from './ws'
+
+// Main framework
+import 'bootstrap/less/bootstrap.less'
+import './style.sass'
+// import {buttons, code, navbar, normalize} from 'bootstrap-css'
+// Object.assign(buttons, code, navbar, normalize)
+
+// Katex
+
+import 'katex/dist/katex.min.css'
+import 'katex/dist/contrib/copy-tex.css'
+import 'katex/dist/contrib/copy-tex'
+import 'katex/dist/contrib/mhchem'
+
+// Codemirror
+import 'codemirror/theme/3024-day.css'
+import 'codemirror/lib/codemirror.css'
+import VueCodemirror from 'vue-codemirror'
+
+Vue.use(VueCodemirror, {
+  options: { theme: '3024-day' },
+})
+
+Vue.use(ws)//{uri: 'ws://127.0.0.1:1323/_ws'})
+
+// Vue.use(VueHighlightJS, {
+//   languages: {
+//   }
+// });
+
+Vue.config.productionTip = false
+
+new Vue({
+  router,
+  render: function (h) { return h(App) }
+}).$mount('#app')

+ 39 - 0
web/src/router/index.js

@@ -0,0 +1,39 @@
+import Vue from 'vue'
+import VueRouter from 'vue-router'
+import Home from '../views/Home.vue'
+import Document from '../views/Document.vue'
+import Editor from '../views/Editor.vue'
+
+Vue.use(VueRouter)
+
+const routes = [
+  {
+    path: '/',
+    name: 'Home',
+    component: Home
+  },
+  {
+    path: '/doc/*',
+    name: 'Document',
+    component: Document
+  },
+  {
+    path: '/edit/*',
+    name: 'Edit',
+    component: Editor
+    // route level code-splitting
+    // this generates a separate chunk (about.[hash].js) for this route
+    // which is lazy-loaded when the route is visited.
+    // component: function () {
+    //   return import(/* webpackChunkName: "about" */ '../views/Editor.vue')
+    // }
+  }
+]
+
+const router = new VueRouter({
+  mode: 'history',
+  base: process.env.BASE_URL,
+  routes
+})
+
+export default router

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 87 - 0
web/src/style.sass


+ 45 - 0
web/src/views/Document.vue

@@ -0,0 +1,45 @@
+<template lang="pug">
+  vue-markdown(:source="data")
+</template>
+
+<script>
+import VueMarkdown from "@/components/VueMarkdown"
+export default {
+  name: "Document",
+  components: {VueMarkdown},
+  data() {
+    return {
+      data: ''
+    }
+  },
+  created() {
+    // This ensures to not update twice after editing
+    setTimeout(() => {
+      this.$ws.addCMDListener(6, this.getFile)
+    }, 1000)
+  },
+  beforeDestroy() {
+    this.$ws.removeCMDListener(6, this.getFile)
+  },
+  methods: {
+    getFile(update) {
+      if(update !== this.filename) return
+      fetch("/_doc/" + this.filename)
+          .then(response => response.text())
+          .then(text => this.data = text)
+    }
+  },
+  computed: {
+    filename() {
+      return this.$route.params.pathMatch
+    }
+  },
+  mounted() {
+    this.getFile(this.filename)
+  }
+}
+</script>
+
+<style scoped>
+
+</style>

+ 80 - 0
web/src/views/Editor.vue

@@ -0,0 +1,80 @@
+<template lang="pug">
+div
+  .row
+    .col-sm-6.fit-page
+      codemirror.fit-page.b-right(v-model="code" :options="cmOptions" :readOnly="status" ref="code")
+    .col-sm-6.fit-page
+      Renderer.fit-page(:data="code")
+</template>
+
+
+<script>
+
+import Renderer from "@/views/Renderer";
+import 'codemirror/mode/markdown/markdown'
+import Patcher from 'diff-match-patch'
+import debounce from 'debounce'
+
+
+export default {
+  name: "Editor",
+  components: {Renderer},
+  watch: {
+    code() {
+      this.code_debounce()
+    }
+  },
+  created() {
+    this.$ws.addCMDListener(2, this.onPatch)
+    this.$ws.addCMDListener(-1, this.onStatus)
+
+  },
+  beforeDestroy() {
+    this.$ws.removeCMDListener(2, this.onPatch)
+    this.$ws.removeCMDListener(-1, this.onStatus)
+    this.$ws.send(4, this.$route.params.pathMatch)
+    this.code_debounce.flush()
+  },
+  mounted() {
+    fetch("/_doc/" + this.$route.params.pathMatch)
+        .then(response => response.text())
+        .then(text => this.code_hist = this.code = text)
+    this.$ws.send(3, this.$route.params.pathMatch)
+    this.status = this.$ws.status
+  },
+  methods: {
+    onStatus(stat) {
+      this.status = stat
+    },
+    onPatch(data) {
+      if(data.name !== this.$route.params.pathMatch) return
+      const patches = this.dmp.patch_fromText(data.patch)
+      this.code = this.dmp.patch_apply(patches, this.code)[0]
+      this.code_hist = this.dmp.patch_apply(patches, this.code_hist)[0]
+    },
+    updateHist() {
+      const patches = this.dmp.patch_make(this.code_hist, this.code)
+      console.log("Patches ", patches)
+      if(patches.length === 0) return;
+      this.$ws.send(2, {name: this.$route.params.pathMatch, patch: this.dmp.patch_toText(patches)})
+      this.code_hist = this.code
+    }
+  },
+  data() {
+    return {
+      error: '',
+      cmOptions: {
+        tabSize: 4,
+        mode: 'markdown',
+        lineNumbers: true,
+        line: true,
+      },
+      status: false,
+      dmp: new Patcher(),
+      code_debounce: debounce(this.updateHist, 500),
+      code_hist: `loading..`,
+      code: `loading..`,
+    }
+  }
+}
+</script>

+ 97 - 0
web/src/views/Home.vue

@@ -0,0 +1,97 @@
+<template lang="pug">
+  .home
+    .warning(v-if="error") {{ error }}
+
+    .row
+      .col-sm-9.col-lg-10
+        .row.cols
+          .col-sm-2(v-for="(dir) in dirs")
+            h4 {{ dir || "Root" }}
+            h4(v-for="page in pages[dir]")
+              router-link(:to="'doc/' + page.name") {{ page.name }}
+
+      .col-sm-3.col-lg-2
+        input.form-control(type="text" v-model="createText" placeholder="Create new file")
+        button.btn.btn-default.fw(@click="newFile") Create
+        //.form-group
+          input.form-control.fw(type="text" placeholder="Username")
+          input.form-control.fw(type="password" placeholder="Password")
+          button.btn.btn-default.fw Login
+
+    //.col.c10.b-right
+    //  div(ref="eq")
+    //.col.c2
+    //  .label Your settings
+
+</template>
+
+<script>
+
+// import katex from "katex";
+
+export default {
+  name: 'Home',
+  components: {
+  },
+  data() {
+    return {
+      pages: {},
+      dirs: [],
+      error: '',
+      createText: '',
+    }
+  },
+  created() {
+    this.$ws.addCMDListener(6, this.getFiles)
+  },
+  beforeDestroy() {
+    this.$ws.removeCMDListener(6, this.getFiles)
+  },
+  mounted() {
+    this.getFiles()
+  },
+  methods: {
+    getFiles() {
+      fetch("_doc").then(response => {
+        if(response.status >= 400) throw "Failed to get document list: " + response.statusText
+        return response
+      })
+      .then(response => response.json())
+      .then(pages => {
+        this.pages = {}
+        this.dirs = []
+        Object.keys(pages).forEach(key => {
+          let page = pages[key]
+          let dir = page.name.split('/')
+          dir.pop()
+          dir = dir.join('/')
+          if(!(dir in this.pages)) {
+            this.dirs.push(dir)
+            this.pages[dir] = []
+          }
+          this.pages[dir].push(page)
+        })
+        this.dirs = this.dirs.sort()
+      })
+      .catch(err => {
+        this.error = err
+      })
+    },
+    newFile() {
+      let name = this.createText
+      if(name.toLowerCase().endsWith(".md")) name = name.substring(0, name.length-3)
+      name = name.split(' ').join('_').split('.').join('_').toLowerCase()
+      if(name.length > 0) this.$router.push('/edit/' + name + '.md')
+    }
+  },
+  filters: {
+    nicer(v) {
+      let a = v.split('.')
+      a.pop()
+      v = a.join('.').split('_').join(' ')
+      return v.replace(/^\w/, c => c.toUpperCase());
+    }
+  }
+}
+</script>
+

+ 96 - 0
web/src/views/Renderer.vue

@@ -0,0 +1,96 @@
+<template lang="pug">
+  .render
+    .warning(v-show="error") {{ error }}
+    vue-markdown.document(:source="data")
+</template>
+
+<script>
+// import Equation from "@/components/Equation";
+// const reBegin = /\\begin{(\w+)}/
+import VueMarkdown from "@/components/VueMarkdown"
+
+export default {
+  name: "Renderer",
+  components: {
+    // Equation,
+    VueMarkdown
+  },
+
+  props: {
+    data: String,
+  },
+  data() {
+    return {
+      error: '',
+      // elements: [],
+    }
+  },
+
+  methods: {
+    // addElement(name, value) {
+    //   switch (name) {
+    //     case "text":
+    //       this.elements.push({type: 'text', data: value})
+    //       break
+    //     case "eq":
+    //     case "equation":
+    //       this.elements.push({type: 'eq', data: value})
+    //       break
+    //     default:
+    //       break
+    //   }
+    // },
+    // indexLine(i) {
+    //   let lnum = 0
+    //   let csum = 0
+    //   this.data.split("\n").every(l => {
+    //     lnum++;
+    //     csum += l.length;
+    //     return csum < i
+    //   })
+    //   return lnum
+    // },
+
+    // computeComponents(data) {
+    //   this.error = ''
+    //   let code = data.slice() // copy
+    //   this.elements = []
+    //   let index = 0
+    //   let offset = 0
+    //   while(index !== -1) {
+    //     offset += index
+    //     code = code.slice(index)
+    //     let env = this.getEnv(code)
+    //     if(env === null) {
+    //       // console.log("Adding ", code)
+    //       this.addElement('text', code);
+    //       break
+    //     }
+    //     let name = env[0];
+    //     let sindex = env[1];
+    //     let eindex = env[2];
+    //     this.addElement('text', code.slice(0, sindex));
+    //
+    //     if(env[2] === -1) {
+    //       this.error = `Line ${this.indexLine(sindex + offset)}: environment "${name}" started but has no end`
+    //       index = -1
+    //     } else {
+    //       this.addElement(name, code.slice(sindex + 8 + name.length, eindex - 1));
+    //       index = eindex + 5 + name.length
+    //     }
+    //   }
+    // },
+
+    // getEnv(code) {
+    //   let index = code.search(reBegin)
+    //   if(index === -1) return null
+    //   let name = reBegin.exec(code)
+    //   if(name === null) return null
+    //   name = name[1]
+    //   let index2 = code.search(`\\end{${name}}`)
+    //   return [name, index, index2]
+    // }
+  },
+
+}
+</script>

+ 120 - 0
web/src/ws.js

@@ -0,0 +1,120 @@
+
+class WS {
+
+  constructor(options) {
+    if(options) {
+      this.uri = options.uri
+    }
+    this.status = false
+    this.ws = null
+    this.ws_error = ''
+    this.seq = 1
+    this.queue = {}
+    this.tosend = []
+    this.callbacks = {}
+    this.callback_map = {}
+  }
+
+  onstatus() {}
+
+  send(cmd, msg, seq) {
+    let payload = JSON.stringify({cmd: cmd, seq: seq | 0, data: msg})
+    console.log("WS SEND:", payload)
+    if(this.status) this.ws.send(payload)
+    else this.tosend.push(payload)
+  }
+  ask(cmd, msg, timeout) {
+    if(!timeout) timeout = 1000
+    return new Promise((resolve, reject) => {
+      this.seq++
+      this.send(cmd, msg, this.seq)
+      this.queue[this.seq] = {
+        resolve: resolve,
+        reject: reject,
+        timeout: setTimeout(() => {
+          delete this.queue[this.seq];
+          reject("Timeout")
+        }, timeout)
+      }
+    })
+  }
+
+  addCMDListener(cmd, callback) {
+    if(!(cmd in this.callbacks)) this.callbacks[cmd] = []
+    this.callbacks[cmd].push(callback)
+  }
+
+  removeCMDListener(cmd, callback) {
+    if(cmd in this.callbacks) {
+      let index = this.callbacks[cmd].indexOf(callback)
+      if (index > -1) this.callbacks[cmd].splice(index, 1);
+    }
+  }
+
+  _callback(cmd, data) {
+    if(cmd in this.callbacks) {
+      try {
+        this.callbacks[cmd].forEach(f => f(data))
+      } catch (e) {
+        console.log("WS Callback error", e)
+      }
+    }
+  }
+
+  onrecv(msg_raw) {
+    let msg = JSON.parse(msg_raw.data);
+    console.log("WS RECV", msg)
+    if(msg.seq in this.queue) {
+      clearTimeout(this.queue[msg.seq].timeout)
+      this.queue[msg.seq].resolve(msg.data)
+      delete this.queue[msg.seq]
+    }
+    this._callback(msg.cmd, msg.data)
+  }
+
+  connect() {
+    this.onstatus('Connecting..')
+    let uri = this.uri
+    if(this.uri == null) {
+      let loc = window.location;
+      uri = (loc.protocol === 'https:') ? 'wss:' : 'ws:';
+      uri += '//' + loc.host + '/_ws';
+    }
+    console.log("Connecting to " + uri)
+    this.ws = new WebSocket(uri)
+    this.ws.onmessage = (msg) => this.onrecv(msg);
+    this.ws.onopen = () => {
+      this.ws_error = null
+      this._callback(-1, true)
+      this.status = true
+      this.onstatus('Connected')
+      while (this.tosend.length > 0) {
+        let s = this.tosend.pop()
+        this.ws.send(s)
+      }
+    }
+    this.ws.onerror = (error) => {
+      this.onstatus('Connection error')
+      this.ws_error = error
+      this.ws.close();
+      console.log(error)
+    }
+    this.ws.onclose = () => {
+      this.onstatus('Disconnected')
+      this._callback(-1, false)
+      this.status = false
+      Object.keys(this.queue).forEach(key => {
+        this.queue[key].reject("Disconnected")
+      })
+      setTimeout(() => this.connect(), (this.ws_error === null) ? 5000 : 500)
+    }
+  }
+
+
+}
+
+export default {
+  install:  function (Vue, options) {
+    Vue.prototype.$ws = new WS(options)
+  }
+}

+ 23 - 0
web/vue.config.js

@@ -0,0 +1,23 @@
+const path = require('path');
+
+module.exports = {
+  configureWebpack: {
+    plugins: [
+      // new FlowWebpackPlugin()
+    ]
+  },
+  pluginOptions: {
+    'style-resources-loader': {
+      preProcessor: 'sass',
+      // patterns: [path.resolve(__dirname, './src/style.sass')],
+    },
+  },
+  devServer: {
+    proxy: {
+      "/_doc": {
+        target: "http://127.0.0.1:1323",
+        secure: false
+      }
+    }
+  }
+}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 7940 - 0
web/yarn.lock


+ 264 - 0
ws.go

@@ -0,0 +1,264 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"github.com/gorilla/websocket"
+	"github.com/labstack/echo"
+)
+
+var (
+	upgrader = websocket.Upgrader{ReadBufferSize:  1024, WriteBufferSize: 1024}
+	hub *wsHub
+)
+
+const (
+	wsErrorCMD = iota
+	wsFileInfoCMD
+	wsFilePatchCMD
+	wsSubscribeCMD
+	wsUnsubscribeCMD
+	wsWritersCMD  // Update on file
+	wsFileUpdateCMD  // When new file flushed
+	wsFileCommit
+)
+
+func init() {
+	//upgrader.CheckOrigin = func(r *http.Request) bool {
+	//	return true
+	//}
+	hub = new(wsHub)
+	hub.clients = make(map[*wsCli]bool)
+	hub.register = make(chan *wsCli)
+	hub.unregister = make(chan *wsCli)
+	hub.broadcast = make(chan *wsMessage)
+}
+
+type wsMessage struct {
+	cli  *wsCli
+	hash uint64
+	CMD  int         `json:"cmd"`
+	Seq  int         `json:"seq"`
+	Data interface{} `json:"data"`
+}
+
+type wsFilePatch struct {
+	Name  string `json:"name"`
+	Patch string `json:"patch"`
+}
+
+type wsCli struct {
+	hub *wsHub
+	conn *websocket.Conn
+	buffer chan []byte
+	fileOpen uint64
+}
+
+func (c *wsCli) reader() {
+	defer func() {
+		c.hub.unregister <- c
+		c.conn.Close()
+	}()
+	for {
+		var msg wsMessage
+		_, raw, err := c.conn.ReadMessage()
+		if err != nil {
+			if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
+				e.Logger.Errorf("error: %v", err)
+			}
+			break
+		}
+		err = json.Unmarshal(raw, &msg)
+		if err != nil {
+			return
+		}
+
+		msg.cli = c
+
+		switch msg.CMD {
+		case wsSubscribeCMD:
+			fname, ok := msg.Data.(string)
+			if !ok { goto respEmpty }
+			msg.hash, fname = calcHash(fname)
+			var cached *fileCache
+			if finfo, ok := fileTree[msg.hash]; ok {
+				if cached, ok = fileCaches[msg.hash]; !ok {
+					cached = newFileCache(finfo)
+					fileCaches[msg.hash] = cached
+				}
+			} else {
+				cached = newFileCache(nil)
+				cached.Hash = msg.hash
+				cached.Path = fname
+				fileCaches[msg.hash] = cached
+			}
+			if c.fileOpen != msg.hash {
+				if c.fileOpen > 0 {
+					if cached2, ok := fileCaches[c.fileOpen]; ok {
+						cached2.RemoveEditor(c)
+					}
+				}
+				c.fileOpen = msg.hash
+				cached.AddEditor(c)
+			}
+			msg.CMD = wsWritersCMD
+			msg.Data = cached.GetUpdate()
+			goto respMessage
+		case wsUnsubscribeCMD:
+			if c.fileOpen == 0 {
+				goto respEmpty
+			}
+			if cached, ok := fileCaches[c.fileOpen]; ok {
+				cached.RemoveEditor(c)
+				//brdMsg := wsMessage{
+				//	cli: c,
+				//	hash: 0,
+				//	CMD: wsFileUpdateCMD,
+				//	Data: cached.Name(),
+				//}
+				//hub.broadcast<-&brdMsg
+			}
+			c.fileOpen = 0
+
+		case wsFileInfoCMD:
+			//fname, ok := msg.Data.(string)
+			//if !ok { goto respEmpty }
+			//hash, _ := calcHash(fname)
+			//if finfo, ok := fileTree[hash]; ok {
+			//	c.fileOpen = hash
+			//	if _, ok = fileCaches[hash]; !ok {
+			//		fileCaches[hash] = newFileCache(finfo)
+			//	}
+			//	msg.Data = finfo
+			//	goto respMessage
+			//}
+		case wsFilePatchCMD:
+			msgStruct := struct {
+				Data *wsFilePatch `json:"data"`
+			}{}
+			if err = json.Unmarshal(raw, &msgStruct); err != nil || msgStruct.Data == nil {
+				goto respEmpty
+			}
+			fpatch := msgStruct.Data
+			var fpath string
+			msg.hash, fpath = calcHash(fpatch.Name)
+			var cached *fileCache
+			var ok bool
+			if cached, ok = fileCaches[msg.hash]; !ok {
+				finfo, _ := fileTree[msg.hash]
+				cached = newFileCache(finfo)
+				cached.Hash = msg.hash
+				cached.Path = fpath
+				fileCaches[msg.hash] = cached
+			}
+			if c.fileOpen != msg.hash {
+				if c.fileOpen > 0 {
+					if cached2, ok := fileCaches[c.fileOpen]; ok {
+						cached2.RemoveEditor(c)
+					}
+				}
+				c.fileOpen = msg.hash
+				cached.AddEditor(c)
+			}
+			patches, err := dmp.PatchFromText(fpatch.Patch)
+			if err != nil {
+				msg.Data = err.Error()
+				msg.CMD = wsErrorCMD
+				goto respMessage
+			}
+			cached.File, _ = dmp.PatchApply(patches, cached.File)
+			hub.broadcast<-&msg
+		default:
+			fmt.Printf("Unknown command #%d\n", msg.CMD)
+		}
+
+	respEmpty:
+		if msg.Seq > 0 {
+			msg.Data = nil
+			goto respMessage
+		}
+		continue
+	respMessage:
+		err = c.conn.WriteJSON(msg)
+		if err != nil { return }
+	}
+}
+
+//func (c *wsCli) writer() {
+//	defer func() {
+//		c.conn.Close()
+//	}()
+//	for {
+//		select {
+//		case message, ok := <- c.buffer:
+//			if !ok {
+//				_ = c.conn.WriteMessage(websocket.CloseMessage, []byte{})
+//				return
+//			}
+//			err := c.conn.WriteMessage(websocket.TextMessage, message)
+//			if err != nil { return }
+//		}
+//	}
+//}
+
+
+type wsHub struct {
+	clients map[*wsCli]bool
+	register chan *wsCli
+	unregister chan *wsCli
+	broadcast chan *wsMessage
+}
+
+func (h *wsHub) run()  {
+	for {
+		select {
+		case client := <-h.register:
+			fmt.Println("Connection opened: " + client.conn.RemoteAddr().String())
+			h.clients[client] = true
+		case client := <-h.unregister:
+			if _, ok := h.clients[client]; ok {
+				fmt.Println("Connection closed: " + client.conn.RemoteAddr().String())
+				if client.fileOpen != 0 {
+					if cached, ok := fileCaches[client.fileOpen]; ok {
+						cached.RemoveEditor(client)
+					}
+				}
+				delete(h.clients, client)
+				close(client.buffer)
+			}
+		case message := <-h.broadcast:
+			payload, err := json.Marshal(message)
+			if err != nil {
+				e.Logger.Error("Failed to marshal patch", err)
+				continue
+			}
+			for client := range h.clients {
+				if message.cli != nil && client == message.cli { continue }
+				if client.fileOpen != message.hash { continue }
+				err = client.conn.WriteMessage(websocket.TextMessage, payload)
+				if err != nil {
+					close(client.buffer)
+					delete(h.clients, client)
+				}
+				//select {
+				//case client.buffer <- message:
+				//default:
+				//	close(client.buffer)
+				//	delete(h.clients, client)
+				//}
+			}
+		}
+	}
+}
+
+func handleWS(c echo.Context) error {
+	ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
+	if err != nil {
+		return err
+	}
+	client := &wsCli{hub: hub, conn: ws, buffer: make(chan []byte, 256)}
+	client.hub.register <- client
+	go client.reader()
+	//go client.writer()
+	return nil
+}