Merge branch 'main' into split-dialog-and-card-detail
This commit is contained in:
commit
83bc16184a
72 changed files with 4573 additions and 905 deletions
29
.editorconfig
Normal file
29
.editorconfig
Normal file
|
@ -0,0 +1,29 @@
|
|||
# http://editorconfig.org/
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
charset = utf-8
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
|
||||
[*.{js,jsx,json,html}]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[{package.json,.eslintrc.json}]
|
||||
indent_size = 2
|
||||
|
||||
[i18n/**.json]
|
||||
indent_size = 2
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
|
||||
[*.scss]
|
||||
indent_style = space
|
||||
indent_size = 4
|
196
.eslintrc.json
Normal file
196
.eslintrc.json
Normal file
|
@ -0,0 +1,196 @@
|
|||
{
|
||||
"extends": [
|
||||
"plugin:mattermost/react",
|
||||
"plugin:cypress/recommended",
|
||||
"plugin:jquery/deprecated"
|
||||
],
|
||||
"plugins": [
|
||||
"babel",
|
||||
"mattermost",
|
||||
"import",
|
||||
"cypress",
|
||||
"jquery",
|
||||
"no-only-tests"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"env": {
|
||||
"jest": true,
|
||||
"cypress/globals": true
|
||||
},
|
||||
"settings": {
|
||||
"import/resolver": "webpack",
|
||||
"react": {
|
||||
"pragma": "React",
|
||||
"version": "detect"
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"no-unused-expressions": 0,
|
||||
"babel/no-unused-expressions": 2,
|
||||
"eol-last": ["error", "always"],
|
||||
"import/no-unresolved": 2,
|
||||
"import/order": [
|
||||
2,
|
||||
{
|
||||
"newlines-between": "always-and-inside-groups",
|
||||
"groups": [
|
||||
"builtin",
|
||||
"external",
|
||||
[
|
||||
"internal",
|
||||
"parent"
|
||||
],
|
||||
"sibling",
|
||||
"index"
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-undefined": 0,
|
||||
"react/jsx-filename-extension": 0,
|
||||
"react/prop-types": [
|
||||
2,
|
||||
{
|
||||
"ignore": [
|
||||
"location",
|
||||
"history",
|
||||
"component"
|
||||
]
|
||||
}
|
||||
],
|
||||
"react/no-string-refs": 2,
|
||||
"no-only-tests/no-only-tests": ["error", {"focus": ["only", "skip"]}]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.tsx", "**/*.ts"],
|
||||
"extends": [
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"rules": {
|
||||
"import/no-unresolved": 0, // ts handles this better
|
||||
"camelcase": 0,
|
||||
"@typescript-eslint/naming-convention": [
|
||||
2,
|
||||
{
|
||||
"selector": "function",
|
||||
"format": ["camelCase", "PascalCase"]
|
||||
},
|
||||
{
|
||||
"selector": "variable",
|
||||
"format": ["camelCase", "PascalCase", "UPPER_CASE"]
|
||||
},
|
||||
{
|
||||
"selector": "parameter",
|
||||
"format": ["camelCase", "PascalCase"],
|
||||
"leadingUnderscore": "allow"
|
||||
},
|
||||
{
|
||||
"selector": "typeLike",
|
||||
"format": ["PascalCase"]
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-non-null-assertion": 0,
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
2,
|
||||
{
|
||||
"vars": "all",
|
||||
"args": "after-used"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-var-requires": 0,
|
||||
"@typescript-eslint/no-empty-function": 0,
|
||||
"@typescript-eslint/prefer-interface": 0,
|
||||
"@typescript-eslint/explicit-function-return-type": 0,
|
||||
"@typescript-eslint/indent": [
|
||||
2,
|
||||
4,
|
||||
{
|
||||
"SwitchCase": 0
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-use-before-define": [
|
||||
2,
|
||||
{
|
||||
"classes": false,
|
||||
"functions": false,
|
||||
"variables": false
|
||||
}
|
||||
],
|
||||
"react/jsx-filename-extension": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["tests/**", "**/*.test.*"],
|
||||
"env": {
|
||||
"jest": true
|
||||
},
|
||||
"rules": {
|
||||
"func-names": 0,
|
||||
"global-require": 0,
|
||||
"new-cap": 0,
|
||||
"prefer-arrow-callback": 0,
|
||||
"no-import-assign": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["e2e/**"],
|
||||
"rules": {
|
||||
"func-names": 0,
|
||||
"import/no-unresolved": 0,
|
||||
"max-nested-callbacks": 0,
|
||||
"no-process-env": 0,
|
||||
"babel/no-unused-expressions": 0,
|
||||
"no-unused-expressions": 0,
|
||||
"jquery/no-ajax": 0,
|
||||
"jquery/no-ajax-events": 0,
|
||||
"jquery/no-animate": 0,
|
||||
"jquery/no-attr": 0,
|
||||
"jquery/no-bind": 0,
|
||||
"jquery/no-class": 0,
|
||||
"jquery/no-clone": 0,
|
||||
"jquery/no-closest": 0,
|
||||
"jquery/no-css": 0,
|
||||
"jquery/no-data": 0,
|
||||
"jquery/no-deferred": 0,
|
||||
"jquery/no-delegate": 0,
|
||||
"jquery/no-each": 0,
|
||||
"jquery/no-extend": 0,
|
||||
"jquery/no-fade": 0,
|
||||
"jquery/no-filter": 0,
|
||||
"jquery/no-find": 0,
|
||||
"jquery/no-global-eval": 0,
|
||||
"jquery/no-grep": 0,
|
||||
"jquery/no-has": 0,
|
||||
"jquery/no-hide": 0,
|
||||
"jquery/no-html": 0,
|
||||
"jquery/no-in-array": 0,
|
||||
"jquery/no-is-array": 0,
|
||||
"jquery/no-is-function": 0,
|
||||
"jquery/no-is": 0,
|
||||
"jquery/no-load": 0,
|
||||
"jquery/no-map": 0,
|
||||
"jquery/no-merge": 0,
|
||||
"jquery/no-param": 0,
|
||||
"jquery/no-parent": 0,
|
||||
"jquery/no-parents": 0,
|
||||
"jquery/no-parse-html": 0,
|
||||
"jquery/no-prop": 0,
|
||||
"jquery/no-proxy": 0,
|
||||
"jquery/no-ready": 0,
|
||||
"jquery/no-serialize": 0,
|
||||
"jquery/no-show": 0,
|
||||
"jquery/no-size": 0,
|
||||
"jquery/no-sizzle": 0,
|
||||
"jquery/no-slide": 0,
|
||||
"jquery/no-submit": 0,
|
||||
"jquery/no-text": 0,
|
||||
"jquery/no-toggle": 0,
|
||||
"jquery/no-trigger": 0,
|
||||
"jquery/no-trim": 0,
|
||||
"jquery/no-val": 0,
|
||||
"jquery/no-when": 0,
|
||||
"jquery/no-wrap": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -36,3 +36,4 @@ debug
|
|||
__debug_bin
|
||||
files
|
||||
octo*.db
|
||||
.eslintcache
|
||||
|
|
13
Makefile
13
Makefile
|
@ -2,9 +2,6 @@
|
|||
|
||||
all: build
|
||||
|
||||
GOMAIN = ./server/main
|
||||
GOBIN = ./bin/octoserver
|
||||
|
||||
pack:
|
||||
npm run pack
|
||||
|
||||
|
@ -12,10 +9,16 @@ packdev:
|
|||
npm run packdev
|
||||
|
||||
go:
|
||||
go build -o $(GOBIN) $(GOMAIN)
|
||||
cd server; go build -o ../bin/octoserver ./main
|
||||
|
||||
generate:
|
||||
cd server; go generate ./...
|
||||
|
||||
watch-server:
|
||||
cd server; modd
|
||||
|
||||
goUbuntu:
|
||||
env GOOS=linux GOARCH=amd64 go build -o $(GOBIN) $(GOMAIN)
|
||||
cd server; env GOOS=linux GOARCH=amd64 go build -o ../bin/octoserver ./main
|
||||
|
||||
builddev: packdev go
|
||||
|
||||
|
|
|
@ -5,10 +5,10 @@
|
|||
<meta charset="UTF-8">
|
||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||
|
||||
<link rel="stylesheet" href="/easymde.min.css">
|
||||
<link rel="stylesheet" href="/main.css">
|
||||
<link rel="stylesheet" href="/images.css">
|
||||
<link rel="stylesheet" href="/colors.css">
|
||||
<link rel="stylesheet" href="/static/easymde.min.css">
|
||||
<link rel="stylesheet" href="/static/main.css">
|
||||
<link rel="stylesheet" href="/static/images.css">
|
||||
<link rel="stylesheet" href="/static/colors.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
1272
package-lock.json
generated
1272
package-lock.json
generated
File diff suppressed because it is too large
Load diff
16
package.json
16
package.json
|
@ -7,7 +7,9 @@
|
|||
"pack": "NODE_ENV=production webpack --config webpack.js",
|
||||
"packdev": "NODE_ENV=dev webpack --config webpack.dev.js",
|
||||
"watchdev": "NODE_ENV=dev webpack --watch --config webpack.dev.js",
|
||||
"test": "jest"
|
||||
"test": "jest",
|
||||
"check": "eslint --ext .tsx,.ts . --quiet --cache",
|
||||
"fix": "eslint --ext .tsx,.ts . --quiet --fix --cache"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^1.1.1",
|
||||
|
@ -29,8 +31,20 @@
|
|||
"@types/react": "^16.9.49",
|
||||
"@types/react-dom": "^16.9.8",
|
||||
"@types/react-router-dom": "^5.1.6",
|
||||
"@typescript-eslint/eslint-plugin": "^4.4.1",
|
||||
"@typescript-eslint/parser": "^4.4.1",
|
||||
"copy-webpack-plugin": "^6.0.3",
|
||||
"css-loader": "^4.3.0",
|
||||
"eslint": "^7.11.0",
|
||||
"eslint-import-resolver-webpack": "0.12.2",
|
||||
"eslint-plugin-babel": "5.3.1",
|
||||
"eslint-plugin-cypress": "2.11.1",
|
||||
"eslint-plugin-header": "3.0.0",
|
||||
"eslint-plugin-import": "2.22.0",
|
||||
"eslint-plugin-jquery": "1.5.1",
|
||||
"eslint-plugin-mattermost": "github:mattermost/eslint-plugin-mattermost#070ce792d105482ffb2b27cfc0b7e78b3d20acee",
|
||||
"eslint-plugin-no-only-tests": "2.4.0",
|
||||
"eslint-plugin-react": "7.20.6",
|
||||
"file-loader": "^6.1.0",
|
||||
"html-webpack-plugin": "^4.5.0",
|
||||
"jest": "^26.5.3",
|
||||
|
|
275
server/api/api.go
Normal file
275
server/api/api.go
Normal file
|
@ -0,0 +1,275 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/mattermost/mattermost-octo-tasks/server/app"
|
||||
"github.com/mattermost/mattermost-octo-tasks/server/model"
|
||||
)
|
||||
|
||||
// ----------------------------------------------------------------------------------------------------
|
||||
// REST APIs
|
||||
|
||||
type API struct {
|
||||
appBuilder func() *app.App
|
||||
}
|
||||
|
||||
func NewAPI(appBuilder func() *app.App) *API {
|
||||
return &API{appBuilder: appBuilder}
|
||||
}
|
||||
|
||||
func (a *API) app() *app.App {
|
||||
return a.appBuilder()
|
||||
}
|
||||
|
||||
func (a *API) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/v1/blocks", a.handleGetBlocks).Methods("GET")
|
||||
r.HandleFunc("/api/v1/blocks", a.handlePostBlocks).Methods("POST")
|
||||
r.HandleFunc("/api/v1/blocks/{blockID}", a.handleDeleteBlock).Methods("DELETE")
|
||||
r.HandleFunc("/api/v1/blocks/{blockID}/subtree", a.handleGetSubTree).Methods("GET")
|
||||
|
||||
r.HandleFunc("/api/v1/files", a.handleUploadFile).Methods("POST")
|
||||
r.HandleFunc("/files/{filename}", a.handleServeFile).Methods("GET")
|
||||
|
||||
r.HandleFunc("/api/v1/blocks/export", a.handleExport).Methods("GET")
|
||||
r.HandleFunc("/api/v1/blocks/import", a.handleImport).Methods("POST")
|
||||
}
|
||||
|
||||
func (a *API) handleGetBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
parentID := query.Get("parent_id")
|
||||
blockType := query.Get("type")
|
||||
|
||||
blocks, err := a.app().GetBlocks(parentID, blockType)
|
||||
if err != nil {
|
||||
log.Printf(`ERROR GetBlocks: %v`, r)
|
||||
errorResponse(w, http.StatusInternalServerError, `{}`)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("GetBlocks parentID: %s, type: %s, %d result(s)", parentID, blockType, len(blocks))
|
||||
json, err := json.Marshal(blocks)
|
||||
if err != nil {
|
||||
log.Printf(`ERROR json.Marshal: %v`, r)
|
||||
errorResponse(w, http.StatusInternalServerError, `{}`)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, json)
|
||||
}
|
||||
|
||||
func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
requestBody, err := ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
errorResponse(w, http.StatusInternalServerError, `{}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Catch panics from parse errors, etc.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf(`ERROR: %v`, r)
|
||||
errorResponse(w, http.StatusInternalServerError, `{}`)
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
var blocks []model.Block
|
||||
err = json.Unmarshal([]byte(requestBody), &blocks)
|
||||
if err != nil {
|
||||
errorResponse(w, http.StatusInternalServerError, ``)
|
||||
return
|
||||
}
|
||||
|
||||
for _, block := range blocks {
|
||||
// Error checking
|
||||
if len(block.Type) < 1 {
|
||||
errorResponse(w, http.StatusInternalServerError, fmt.Sprintf(`{"description": "missing type", "id": "%s"}`, block.ID))
|
||||
return
|
||||
}
|
||||
if block.CreateAt < 1 {
|
||||
errorResponse(w, http.StatusInternalServerError, fmt.Sprintf(`{"description": "invalid createAt", "id": "%s"}`, block.ID))
|
||||
return
|
||||
}
|
||||
if block.UpdateAt < 1 {
|
||||
errorResponse(w, http.StatusInternalServerError, fmt.Sprintf(`{"description": "invalid updateAt", "id": "%s"}`, block.ID))
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
err = a.app().InsertBlocks(blocks)
|
||||
if err != nil {
|
||||
log.Printf(`ERROR: %v`, r)
|
||||
errorResponse(w, http.StatusInternalServerError, `{}`)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("POST Blocks %d block(s)", len(blocks))
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
}
|
||||
|
||||
func (a *API) handleDeleteBlock(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
blockID := vars["blockID"]
|
||||
|
||||
err := a.app().DeleteBlock(blockID)
|
||||
if err != nil {
|
||||
log.Printf(`ERROR: %v`, r)
|
||||
errorResponse(w, http.StatusInternalServerError, `{}`)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("DELETE Block %s", blockID)
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
}
|
||||
|
||||
func (a *API) handleGetSubTree(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
blockID := vars["blockID"]
|
||||
|
||||
blocks, err := a.app().GetSubTree(blockID)
|
||||
if err != nil {
|
||||
log.Printf(`ERROR: %v`, r)
|
||||
errorResponse(w, http.StatusInternalServerError, `{}`)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("GetSubTree blockID: %s, %d result(s)", blockID, len(blocks))
|
||||
json, err := json.Marshal(blocks)
|
||||
if err != nil {
|
||||
log.Printf(`ERROR json.Marshal: %v`, r)
|
||||
errorResponse(w, http.StatusInternalServerError, `{}`)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, json)
|
||||
}
|
||||
|
||||
func (a *API) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||
blocks, err := a.app().GetAllBlocks()
|
||||
if err != nil {
|
||||
log.Printf(`ERROR: %v`, r)
|
||||
errorResponse(w, http.StatusInternalServerError, `{}`)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("EXPORT Blocks, %d result(s)", len(blocks))
|
||||
json, err := json.Marshal(blocks)
|
||||
if err != nil {
|
||||
log.Printf(`ERROR json.Marshal: %v`, r)
|
||||
errorResponse(w, http.StatusInternalServerError, `{}`)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, json)
|
||||
}
|
||||
|
||||
func (a *API) handleImport(w http.ResponseWriter, r *http.Request) {
|
||||
requestBody, err := ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
errorResponse(w, http.StatusInternalServerError, `{}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Catch panics from parse errors, etc.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf(`ERROR: %v`, r)
|
||||
errorResponse(w, http.StatusInternalServerError, `{}`)
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
var blocks []model.Block
|
||||
err = json.Unmarshal([]byte(requestBody), &blocks)
|
||||
if err != nil {
|
||||
errorResponse(w, http.StatusInternalServerError, ``)
|
||||
return
|
||||
}
|
||||
|
||||
for _, block := range blocks {
|
||||
err := a.app().InsertBlock(block)
|
||||
if err != nil {
|
||||
log.Printf(`ERROR: %v`, r)
|
||||
errorResponse(w, http.StatusInternalServerError, `{}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("IMPORT Blocks %d block(s)", len(blocks))
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
}
|
||||
|
||||
// File upload
|
||||
|
||||
func (a *API) handleServeFile(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
filename := vars["filename"]
|
||||
|
||||
contentType := "image/jpg"
|
||||
fileExtension := strings.ToLower(filepath.Ext(filename))
|
||||
if fileExtension == "png" {
|
||||
contentType = "image/png"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
|
||||
filePath := a.app().GetFilePath(filename)
|
||||
http.ServeFile(w, r, filePath)
|
||||
}
|
||||
|
||||
func (a *API) handleUploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Println(`handleUploadFile`)
|
||||
file, handle, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "%v", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
log.Printf(`handleUploadFile, filename: %s`, handle.Filename)
|
||||
|
||||
url, err := a.app().SaveFile(file, handle.Filename)
|
||||
if err != nil {
|
||||
jsonStringResponse(w, http.StatusInternalServerError, `{}`)
|
||||
return
|
||||
}
|
||||
log.Printf(`saveFile, url: %s`, url)
|
||||
json := fmt.Sprintf(`{ "url": "%s" }`, url)
|
||||
jsonStringResponse(w, http.StatusOK, json)
|
||||
}
|
||||
|
||||
// Response helpers
|
||||
|
||||
func jsonStringResponse(w http.ResponseWriter, code int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
fmt.Fprint(w, message)
|
||||
}
|
||||
|
||||
func jsonBytesResponse(w http.ResponseWriter, code int, json []byte) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
w.Write(json)
|
||||
}
|
||||
|
||||
func errorResponse(w http.ResponseWriter, code int, message string) {
|
||||
log.Printf("%d ERROR", code)
|
||||
w.WriteHeader(code)
|
||||
fmt.Fprint(w, message)
|
||||
}
|
||||
|
||||
func addUserID(rw http.ResponseWriter, req *http.Request, next http.Handler) {
|
||||
ctx := context.WithValue(req.Context(), "userid", req.Header.Get("userid"))
|
||||
req = req.WithContext(ctx)
|
||||
next.ServeHTTP(rw, req)
|
||||
}
|
130
server/app/app.go
Normal file
130
server/app/app.go
Normal file
|
@ -0,0 +1,130 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-octo-tasks/server/model"
|
||||
"github.com/mattermost/mattermost-octo-tasks/server/services/config"
|
||||
"github.com/mattermost/mattermost-octo-tasks/server/services/store"
|
||||
"github.com/mattermost/mattermost-octo-tasks/server/ws"
|
||||
"github.com/mattermost/mattermost-server/v5/services/filesstore"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
config *config.Configuration
|
||||
store store.Store
|
||||
wsServer *ws.WSServer
|
||||
filesBackend filesstore.FileBackend
|
||||
}
|
||||
|
||||
func New(config *config.Configuration, store store.Store, wsServer *ws.WSServer, filesBackend filesstore.FileBackend) *App {
|
||||
return &App{config: config, store: store, wsServer: wsServer, filesBackend: filesBackend}
|
||||
}
|
||||
|
||||
func (a *App) GetBlocks(parentID string, blockType string) ([]model.Block, error) {
|
||||
if len(blockType) > 0 && len(parentID) > 0 {
|
||||
return a.store.GetBlocksWithParentAndType(parentID, blockType)
|
||||
}
|
||||
if len(blockType) > 0 {
|
||||
return a.store.GetBlocksWithType(blockType)
|
||||
}
|
||||
return a.store.GetBlocksWithParent(parentID)
|
||||
}
|
||||
|
||||
func (a *App) GetParentID(blockID string) (string, error) {
|
||||
return a.store.GetParentID(blockID)
|
||||
}
|
||||
|
||||
func (a *App) InsertBlock(block model.Block) error {
|
||||
return a.store.InsertBlock(block)
|
||||
}
|
||||
|
||||
func (a *App) InsertBlocks(blocks []model.Block) error {
|
||||
var blockIDsToNotify = []string{}
|
||||
uniqueBlockIDs := make(map[string]bool)
|
||||
|
||||
for _, block := range blocks {
|
||||
if !uniqueBlockIDs[block.ID] {
|
||||
blockIDsToNotify = append(blockIDsToNotify, block.ID)
|
||||
}
|
||||
if len(block.ParentID) > 0 && !uniqueBlockIDs[block.ParentID] {
|
||||
blockIDsToNotify = append(blockIDsToNotify, block.ParentID)
|
||||
}
|
||||
|
||||
err := a.store.InsertBlock(block)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
a.wsServer.BroadcastBlockChangeToWebsocketClients(blockIDsToNotify)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) GetSubTree(blockID string) ([]model.Block, error) {
|
||||
return a.store.GetSubTree(blockID)
|
||||
}
|
||||
|
||||
func (a *App) GetAllBlocks() ([]model.Block, error) {
|
||||
return a.store.GetAllBlocks()
|
||||
}
|
||||
|
||||
func (a *App) DeleteBlock(blockID string) error {
|
||||
var blockIDsToNotify = []string{blockID}
|
||||
parentID, err := a.GetParentID(blockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(parentID) > 0 {
|
||||
blockIDsToNotify = append(blockIDsToNotify, parentID)
|
||||
}
|
||||
|
||||
err = a.store.DeleteBlock(blockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a.wsServer.BroadcastBlockChangeToWebsocketClients(blockIDsToNotify)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) SaveFile(reader io.Reader, filename string) (string, error) {
|
||||
// NOTE: File extension includes the dot
|
||||
fileExtension := strings.ToLower(filepath.Ext(filename))
|
||||
if fileExtension == ".jpeg" {
|
||||
fileExtension = ".jpg"
|
||||
}
|
||||
|
||||
createdFilename := fmt.Sprintf(`%s%s`, createGUID(), fileExtension)
|
||||
|
||||
_, appErr := a.filesBackend.WriteFile(reader, createdFilename)
|
||||
if appErr != nil {
|
||||
return "", errors.New("unable to store the file in the files storage")
|
||||
}
|
||||
return fmt.Sprintf(`%s/files/%s`, a.config.ServerRoot, createdFilename), nil
|
||||
}
|
||||
|
||||
func (a *App) GetFilePath(filename string) string {
|
||||
folderPath := a.config.FilesPath
|
||||
return filepath.Join(folderPath, filename)
|
||||
}
|
||||
|
||||
// CreateGUID returns a random GUID
|
||||
func createGUID() string {
|
||||
b := make([]byte, 16)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
uuid := fmt.Sprintf("%x-%x-%x-%x-%x",
|
||||
b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
|
||||
|
||||
return uuid
|
||||
}
|
20
server/go.mod
Normal file
20
server/go.mod
Normal file
|
@ -0,0 +1,20 @@
|
|||
module github.com/mattermost/mattermost-octo-tasks/server
|
||||
|
||||
go 1.15
|
||||
|
||||
require (
|
||||
github.com/golang-migrate/migrate v3.5.4+incompatible
|
||||
github.com/golang-migrate/migrate/v4 v4.13.0
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/gorilla/websocket v1.4.2
|
||||
github.com/jteeuwen/go-bindata v3.0.7+incompatible // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/lib/pq v1.8.0
|
||||
github.com/mattermost/mattermost-server/v5 v5.28.0
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
|
||||
github.com/spf13/viper v1.7.1
|
||||
github.com/stretchr/testify v1.6.1
|
||||
golang.org/x/tools v0.0.0-20201017001424-6003fad69a88 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
|
||||
)
|
1426
server/go.sum
Normal file
1426
server/go.sum
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1,289 +1,17 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"io/ioutil"
|
||||
"mime/multipart"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/mattermost/mattermost-octo-tasks/server/server"
|
||||
"github.com/mattermost/mattermost-octo-tasks/server/services/config"
|
||||
)
|
||||
|
||||
var config *Configuration
|
||||
var wsServer *WSServer
|
||||
var store *SQLStore
|
||||
|
||||
// ----------------------------------------------------------------------------------------------------
|
||||
// HTTP handlers
|
||||
|
||||
func serveWebFile(w http.ResponseWriter, r *http.Request, relativeFilePath string) {
|
||||
folderPath := config.WebPath
|
||||
filePath := filepath.Join(folderPath, relativeFilePath)
|
||||
http.ServeFile(w, r, filePath)
|
||||
}
|
||||
|
||||
func handleStaticFile(r *mux.Router, requestPath string, filePath string, contentType string) {
|
||||
r.HandleFunc(requestPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("handleStaticFile: %s", requestPath)
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
serveWebFile(w, r, filePath)
|
||||
})
|
||||
}
|
||||
|
||||
func handleDefault(r *mux.Router, requestPath string) {
|
||||
r.HandleFunc(requestPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("handleDefault")
|
||||
http.Redirect(w, r, "/board", http.StatusFound)
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------------------
|
||||
// REST APIs
|
||||
|
||||
func handleGetBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
parentID := query.Get("parent_id")
|
||||
blockType := query.Get("type")
|
||||
|
||||
var blocks []string
|
||||
if len(blockType) > 0 && len(parentID) > 0 {
|
||||
blocks = store.getBlocksWithParentAndType(parentID, blockType)
|
||||
} else if len(blockType) > 0 {
|
||||
blocks = store.getBlocksWithType(blockType)
|
||||
} else {
|
||||
blocks = store.getBlocksWithParent(parentID)
|
||||
}
|
||||
|
||||
log.Printf("GetBlocks parentID: %s, type: %s, %d result(s)", parentID, blockType, len(blocks))
|
||||
response := `[` + strings.Join(blocks[:], ",") + `]`
|
||||
jsonResponse(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func handlePostBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
requestBody, err := ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
errorResponse(w, http.StatusInternalServerError, `{}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Catch panics from parse errors, etc.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf(`ERROR: %v`, r)
|
||||
errorResponse(w, http.StatusInternalServerError, `{}`)
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
var blocks []Block
|
||||
err = json.Unmarshal([]byte(requestBody), &blocks)
|
||||
if err != nil {
|
||||
errorResponse(w, http.StatusInternalServerError, ``)
|
||||
return
|
||||
}
|
||||
|
||||
var blockIDsToNotify = []string{}
|
||||
uniqueBlockIDs := make(map[string]bool)
|
||||
|
||||
for _, block := range blocks {
|
||||
// Error checking
|
||||
if len(block.Type) < 1 {
|
||||
errorResponse(w, http.StatusInternalServerError, fmt.Sprintf(`{"description": "missing type", "id": "%s"}`, block.ID))
|
||||
return
|
||||
}
|
||||
if block.CreateAt < 1 {
|
||||
errorResponse(w, http.StatusInternalServerError, fmt.Sprintf(`{"description": "invalid createAt", "id": "%s"}`, block.ID))
|
||||
return
|
||||
}
|
||||
if block.UpdateAt < 1 {
|
||||
errorResponse(w, http.StatusInternalServerError, fmt.Sprintf(`{"description": "invalid updateAt", "id": "%s"}`, block.ID))
|
||||
return
|
||||
}
|
||||
|
||||
if !uniqueBlockIDs[block.ID] {
|
||||
blockIDsToNotify = append(blockIDsToNotify, block.ID)
|
||||
}
|
||||
if len(block.ParentID) > 0 && !uniqueBlockIDs[block.ParentID] {
|
||||
blockIDsToNotify = append(blockIDsToNotify, block.ParentID)
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(block)
|
||||
if err != nil {
|
||||
errorResponse(w, http.StatusInternalServerError, `{}`)
|
||||
return
|
||||
}
|
||||
|
||||
store.insertBlock(block, string(jsonBytes))
|
||||
}
|
||||
|
||||
wsServer.broadcastBlockChangeToWebsocketClients(blockIDsToNotify)
|
||||
|
||||
log.Printf("POST Blocks %d block(s)", len(blocks))
|
||||
jsonResponse(w, http.StatusOK, "{}")
|
||||
}
|
||||
|
||||
func handleDeleteBlock(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
blockID := vars["blockID"]
|
||||
|
||||
var blockIDsToNotify = []string{blockID}
|
||||
|
||||
parentID := store.getParentID(blockID)
|
||||
|
||||
if len(parentID) > 0 {
|
||||
blockIDsToNotify = append(blockIDsToNotify, parentID)
|
||||
}
|
||||
|
||||
store.deleteBlock(blockID)
|
||||
|
||||
wsServer.broadcastBlockChangeToWebsocketClients(blockIDsToNotify)
|
||||
|
||||
log.Printf("DELETE Block %s", blockID)
|
||||
jsonResponse(w, http.StatusOK, "{}")
|
||||
}
|
||||
|
||||
func handleGetSubTree(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
blockID := vars["blockID"]
|
||||
|
||||
blocks := store.getSubTree(blockID)
|
||||
|
||||
log.Printf("GetSubTree blockID: %s, %d result(s)", blockID, len(blocks))
|
||||
response := `[` + strings.Join(blocks[:], ",") + `]`
|
||||
jsonResponse(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func handleExport(w http.ResponseWriter, r *http.Request) {
|
||||
blocks := store.getAllBlocks()
|
||||
|
||||
log.Printf("EXPORT Blocks, %d result(s)", len(blocks))
|
||||
response := `[` + strings.Join(blocks[:], ",") + `]`
|
||||
jsonResponse(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func handleImport(w http.ResponseWriter, r *http.Request) {
|
||||
requestBody, err := ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
errorResponse(w, http.StatusInternalServerError, `{}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Catch panics from parse errors, etc.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf(`ERROR: %v`, r)
|
||||
errorResponse(w, http.StatusInternalServerError, `{}`)
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
var blocks []Block
|
||||
err = json.Unmarshal([]byte(requestBody), &blocks)
|
||||
if err != nil {
|
||||
errorResponse(w, http.StatusInternalServerError, ``)
|
||||
return
|
||||
}
|
||||
|
||||
for _, block := range blocks {
|
||||
jsonBytes, err := json.Marshal(block)
|
||||
if err != nil {
|
||||
errorResponse(w, http.StatusInternalServerError, `{}`)
|
||||
return
|
||||
}
|
||||
|
||||
store.insertBlock(block, string(jsonBytes))
|
||||
}
|
||||
|
||||
log.Printf("IMPORT Blocks %d block(s)", len(blocks))
|
||||
jsonResponse(w, http.StatusOK, "{}")
|
||||
}
|
||||
|
||||
// File upload
|
||||
|
||||
func handleServeFile(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
filename := vars["filename"]
|
||||
|
||||
contentType := "image/jpg"
|
||||
fileExtension := strings.ToLower(filepath.Ext(filename))
|
||||
if fileExtension == "png" {
|
||||
contentType = "image/png"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
|
||||
folderPath := config.FilesPath
|
||||
filePath := filepath.Join(folderPath, filename)
|
||||
http.ServeFile(w, r, filePath)
|
||||
}
|
||||
|
||||
func handleUploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Println(`handleUploadFile`)
|
||||
file, handle, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "%v", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
log.Printf(`handleUploadFile, filename: %s`, handle.Filename)
|
||||
|
||||
saveFile(w, file, handle)
|
||||
}
|
||||
|
||||
func saveFile(w http.ResponseWriter, file multipart.File, handle *multipart.FileHeader) {
|
||||
data, err := ioutil.ReadAll(file)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// NOTE: File extension includes the dot
|
||||
fileExtension := strings.ToLower(filepath.Ext(handle.Filename))
|
||||
if fileExtension == ".jpeg" {
|
||||
fileExtension = ".jpg"
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf(`%s%s`, createGUID(), fileExtension)
|
||||
|
||||
folderPath := config.FilesPath
|
||||
filePath := filepath.Join(folderPath, filename)
|
||||
os.MkdirAll(folderPath, os.ModePerm)
|
||||
err = ioutil.WriteFile(filePath, data, 0666)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, `{}`)
|
||||
return
|
||||
}
|
||||
url := fmt.Sprintf(`%s/files/%s`, config.ServerRoot, filename)
|
||||
log.Printf(`saveFile, url: %s`, url)
|
||||
json := fmt.Sprintf(`{ "url": "%s" }`, url)
|
||||
jsonResponse(w, http.StatusOK, json)
|
||||
}
|
||||
|
||||
// Response helpers
|
||||
|
||||
func jsonResponse(w http.ResponseWriter, code int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
fmt.Fprint(w, message)
|
||||
}
|
||||
|
||||
func errorResponse(w http.ResponseWriter, code int, message string) {
|
||||
log.Printf("%d ERROR", code)
|
||||
w.WriteHeader(code)
|
||||
fmt.Fprint(w, message)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------------------
|
||||
// WebSocket OnChange listener
|
||||
|
||||
|
@ -294,11 +22,7 @@ func isProcessRunning(pid int) bool {
|
|||
}
|
||||
|
||||
err = process.Signal(syscall.Signal(0))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func monitorPid(pid int) {
|
||||
|
@ -316,8 +40,7 @@ func monitorPid(pid int) {
|
|||
|
||||
func main() {
|
||||
// config.json file
|
||||
var err error
|
||||
config, err = readConfigFile()
|
||||
config, err := config.ReadConfigFile()
|
||||
if err != nil {
|
||||
log.Fatal("Unable to read the config file: ", err)
|
||||
return
|
||||
|
@ -338,77 +61,12 @@ func main() {
|
|||
config.Port = *pPort
|
||||
}
|
||||
|
||||
wsServer = NewWSServer()
|
||||
|
||||
r := mux.NewRouter()
|
||||
|
||||
// Static files
|
||||
handleDefault(r, "/")
|
||||
|
||||
handleStaticFile(r, "/login", "index.html", "text/html; charset=utf-8")
|
||||
handleStaticFile(r, "/board", "index.html", "text/html; charset=utf-8")
|
||||
handleStaticFile(r, "/main.js", "main.js", "text/javascript; charset=utf-8")
|
||||
handleStaticFile(r, "/boardPage.js", "boardPage.js", "text/javascript; charset=utf-8")
|
||||
|
||||
handleStaticFile(r, "/favicon.ico", "static/favicon.svg", "image/svg+xml; charset=utf-8")
|
||||
|
||||
handleStaticFile(r, "/easymde.min.css", "static/easymde.min.css", "text/css")
|
||||
handleStaticFile(r, "/main.css", "static/main.css", "text/css")
|
||||
handleStaticFile(r, "/colors.css", "static/colors.css", "text/css")
|
||||
handleStaticFile(r, "/images.css", "static/images.css", "text/css")
|
||||
|
||||
// APIs
|
||||
r.HandleFunc("/api/v1/blocks", handleGetBlocks).Methods("GET")
|
||||
r.HandleFunc("/api/v1/blocks", handlePostBlocks).Methods("POST")
|
||||
r.HandleFunc("/api/v1/blocks/{blockID}", handleDeleteBlock).Methods("DELETE")
|
||||
r.HandleFunc("/api/v1/blocks/{blockID}/subtree", handleGetSubTree).Methods("GET")
|
||||
|
||||
r.HandleFunc("/api/v1/files", handleUploadFile).Methods("POST")
|
||||
|
||||
r.HandleFunc("/api/v1/blocks/export", handleExport).Methods("GET")
|
||||
r.HandleFunc("/api/v1/blocks/import", handleImport).Methods("POST")
|
||||
|
||||
// WebSocket
|
||||
r.HandleFunc("/ws/onchange", wsServer.handleWebSocketOnChange)
|
||||
|
||||
// Files
|
||||
r.HandleFunc("/files/{filename}", handleServeFile).Methods("GET")
|
||||
|
||||
http.Handle("/", r)
|
||||
|
||||
store, err = NewSQLStore(config.DBType, config.DBConfigString)
|
||||
server, err := server.New(config)
|
||||
if err != nil {
|
||||
log.Fatal("Unable to start the database", err)
|
||||
panic(err)
|
||||
log.Fatal("ListenAndServeTLS: ", err)
|
||||
}
|
||||
|
||||
// Ctrl+C handling
|
||||
handler := make(chan os.Signal, 1)
|
||||
signal.Notify(handler, os.Interrupt)
|
||||
go func() {
|
||||
for sig := range handler {
|
||||
// sig is a ^C, handle it
|
||||
if sig == os.Interrupt {
|
||||
os.Exit(1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Start the server, with SSL if the certs exist
|
||||
urlPort := fmt.Sprintf(`:%d`, config.Port)
|
||||
var isSSL = config.UseSSL && fileExists("./cert/cert.pem") && fileExists("./cert/key.pem")
|
||||
if isSSL {
|
||||
log.Println("https server started on ", urlPort)
|
||||
err := http.ListenAndServeTLS(urlPort, "./cert/cert.pem", "./cert/key.pem", nil)
|
||||
if err != nil {
|
||||
log.Fatal("ListenAndServeTLS: ", err)
|
||||
}
|
||||
} else {
|
||||
log.Println("http server started on ", urlPort)
|
||||
err := http.ListenAndServe(urlPort, nil)
|
||||
if err != nil {
|
||||
log.Fatal("ListenAndServe: ", err)
|
||||
}
|
||||
if err := server.Start(); err != nil {
|
||||
log.Fatal("ListenAndServeTLS: ", err)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,300 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// SQLStore is a SQL database
|
||||
type SQLStore struct {
|
||||
db *sql.DB
|
||||
dbType string
|
||||
}
|
||||
|
||||
// NewSQLStore creates a new SQLStore
|
||||
func NewSQLStore(dbType, connectionString string) (*SQLStore, error) {
|
||||
log.Println("connectDatabase")
|
||||
var err error
|
||||
|
||||
db, err := sql.Open(dbType, connectionString)
|
||||
if err != nil {
|
||||
log.Fatal("connectDatabase: ", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = db.Ping()
|
||||
if err != nil {
|
||||
log.Println(`Database Ping failed`)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
store := &SQLStore{
|
||||
db: db,
|
||||
dbType: dbType,
|
||||
}
|
||||
|
||||
err = store.createTablesIfNotExists()
|
||||
if err != nil {
|
||||
log.Println(`Table creation failed`)
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// Block is the basic data unit
|
||||
type Block struct {
|
||||
ID string `json:"id"`
|
||||
ParentID string `json:"parentId"`
|
||||
Schema int64 `json:"schema"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Order int64 `json:"order"`
|
||||
Fields map[string]interface{} `json:"fields"`
|
||||
CreateAt int64 `json:"createAt"`
|
||||
UpdateAt int64 `json:"updateAt"`
|
||||
DeleteAt int64 `json:"deleteAt"`
|
||||
}
|
||||
|
||||
func (s *SQLStore) createTablesIfNotExists() error {
|
||||
// TODO: Add update_by with the user's ID
|
||||
// TODO: Consolidate insert_at and update_at, decide if the server of DB should set it
|
||||
var query string
|
||||
if s.dbType == "sqlite3" {
|
||||
query = `CREATE TABLE IF NOT EXISTS blocks (
|
||||
id VARCHAR(36),
|
||||
insert_at DATETIME NOT NULL DEFAULT current_timestamp,
|
||||
parent_id VARCHAR(36),
|
||||
type TEXT,
|
||||
json TEXT,
|
||||
create_at BIGINT,
|
||||
update_at BIGINT,
|
||||
delete_at BIGINT,
|
||||
PRIMARY KEY (id, insert_at)
|
||||
);`
|
||||
} else {
|
||||
query = `CREATE TABLE IF NOT EXISTS blocks (
|
||||
id VARCHAR(36),
|
||||
insert_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
parent_id VARCHAR(36),
|
||||
type TEXT,
|
||||
json TEXT,
|
||||
create_at BIGINT,
|
||||
update_at BIGINT,
|
||||
delete_at BIGINT,
|
||||
PRIMARY KEY (id, insert_at)
|
||||
);`
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(query)
|
||||
if err != nil {
|
||||
log.Fatal("createTablesIfNotExists: ", err)
|
||||
return err
|
||||
}
|
||||
log.Printf("createTablesIfNotExists(%s)", s.dbType)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) getBlocksWithParentAndType(parentID string, blockType string) []string {
|
||||
query := `WITH latest AS
|
||||
(
|
||||
SELECT * FROM
|
||||
(
|
||||
SELECT
|
||||
*,
|
||||
ROW_NUMBER() OVER (PARTITION BY id ORDER BY insert_at DESC) AS rn
|
||||
FROM blocks
|
||||
) a
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT COALESCE("json", '{}')
|
||||
FROM latest
|
||||
WHERE delete_at = 0 and parent_id = $1 and type = $2`
|
||||
|
||||
rows, err := s.db.Query(query, parentID, blockType)
|
||||
if err != nil {
|
||||
log.Printf(`getBlocksWithParentAndType ERROR: %v`, err)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return blocksFromRows(rows)
|
||||
}
|
||||
|
||||
func (s *SQLStore) getBlocksWithParent(parentID string) []string {
|
||||
query := `WITH latest AS
|
||||
(
|
||||
SELECT * FROM
|
||||
(
|
||||
SELECT
|
||||
*,
|
||||
ROW_NUMBER() OVER (PARTITION BY id ORDER BY insert_at DESC) AS rn
|
||||
FROM blocks
|
||||
) a
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT COALESCE("json", '{}')
|
||||
FROM latest
|
||||
WHERE delete_at = 0 and parent_id = $1`
|
||||
|
||||
rows, err := s.db.Query(query, parentID)
|
||||
if err != nil {
|
||||
log.Printf(`getBlocksWithParent ERROR: %v`, err)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return blocksFromRows(rows)
|
||||
}
|
||||
|
||||
func (s *SQLStore) getBlocksWithType(blockType string) []string {
|
||||
query := `WITH latest AS
|
||||
(
|
||||
SELECT * FROM
|
||||
(
|
||||
SELECT
|
||||
*,
|
||||
ROW_NUMBER() OVER (PARTITION BY id ORDER BY insert_at DESC) AS rn
|
||||
FROM blocks
|
||||
) a
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT COALESCE("json", '{}')
|
||||
FROM latest
|
||||
WHERE delete_at = 0 and type = $1`
|
||||
|
||||
rows, err := s.db.Query(query, blockType)
|
||||
if err != nil {
|
||||
log.Printf(`getBlocksWithParentAndType ERROR: %v`, err)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return blocksFromRows(rows)
|
||||
}
|
||||
|
||||
func (s *SQLStore) getSubTree(blockID string) []string {
|
||||
query := `WITH latest AS
|
||||
(
|
||||
SELECT * FROM
|
||||
(
|
||||
SELECT
|
||||
*,
|
||||
ROW_NUMBER() OVER (PARTITION BY id ORDER BY insert_at DESC) AS rn
|
||||
FROM blocks
|
||||
) a
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT COALESCE("json", '{}')
|
||||
FROM latest
|
||||
WHERE delete_at = 0
|
||||
AND (id = $1
|
||||
OR parent_id = $1)`
|
||||
|
||||
rows, err := s.db.Query(query, blockID)
|
||||
if err != nil {
|
||||
log.Printf(`getSubTree ERROR: %v`, err)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return blocksFromRows(rows)
|
||||
}
|
||||
|
||||
func (s *SQLStore) getAllBlocks() []string {
|
||||
query := `WITH latest AS
|
||||
(
|
||||
SELECT * FROM
|
||||
(
|
||||
SELECT
|
||||
*,
|
||||
ROW_NUMBER() OVER (PARTITION BY id ORDER BY insert_at DESC) AS rn
|
||||
FROM blocks
|
||||
) a
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT COALESCE("json", '{}')
|
||||
FROM latest
|
||||
WHERE delete_at = 0`
|
||||
|
||||
rows, err := s.db.Query(query)
|
||||
if err != nil {
|
||||
log.Printf(`getAllBlocks ERROR: %v`, err)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return blocksFromRows(rows)
|
||||
}
|
||||
|
||||
func blocksFromRows(rows *sql.Rows) []string {
|
||||
defer rows.Close()
|
||||
|
||||
var results []string
|
||||
|
||||
for rows.Next() {
|
||||
var json string
|
||||
err := rows.Scan(&json)
|
||||
if err != nil {
|
||||
// handle this error
|
||||
log.Printf(`blocksFromRows ERROR: %v`, err)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
results = append(results, json)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func (s *SQLStore) getParentID(blockID string) string {
|
||||
statement :=
|
||||
`WITH latest AS
|
||||
(
|
||||
SELECT * FROM
|
||||
(
|
||||
SELECT
|
||||
*,
|
||||
ROW_NUMBER() OVER (PARTITION BY id ORDER BY insert_at DESC) AS rn
|
||||
FROM blocks
|
||||
) a
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT parent_id
|
||||
FROM latest
|
||||
WHERE delete_at = 0
|
||||
AND id = $1`
|
||||
|
||||
row := s.db.QueryRow(statement, blockID)
|
||||
|
||||
var parentID string
|
||||
err := row.Scan(&parentID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return parentID
|
||||
}
|
||||
|
||||
func (s *SQLStore) insertBlock(block Block, json string) {
|
||||
statement := `INSERT INTO blocks(id, parent_id, type, json, create_at, update_at, delete_at) VALUES($1, $2, $3, $4, $5, $6, $7)`
|
||||
_, err := s.db.Exec(statement, block.ID, block.ParentID, block.Type, json, block.CreateAt, block.UpdateAt, block.DeleteAt)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SQLStore) deleteBlock(blockID string) {
|
||||
now := time.Now().Unix()
|
||||
json := fmt.Sprintf(`{"id":"%s","updateAt":%d,"deleteAt":%d}`, blockID, now, now)
|
||||
statement := `INSERT INTO blocks(id, json, update_at, delete_at) VALUES($1, $2, $3, $4)`
|
||||
_, err := s.db.Exec(statement, blockID, json, now, now)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
// FileExists returns true if a file exists at the path
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
if os.IsNotExist(err) {
|
||||
return false
|
||||
}
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// CreateGUID returns a random GUID
|
||||
func createGUID() string {
|
||||
b := make([]byte, 16)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
uuid := fmt.Sprintf("%x-%x-%x-%x-%x",
|
||||
b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
|
||||
|
||||
return uuid
|
||||
}
|
4
server/modd.conf
Normal file
4
server/modd.conf
Normal file
|
@ -0,0 +1,4 @@
|
|||
**/*.go !**/*_test.go {
|
||||
prep: go build -o ../bin/octoserver ./main
|
||||
daemon +sigterm: cd .. && ./bin/octoserver
|
||||
}
|
14
server/model/block.go
Normal file
14
server/model/block.go
Normal file
|
@ -0,0 +1,14 @@
|
|||
package model
|
||||
|
||||
// Block is the basic data unit
|
||||
type Block struct {
|
||||
ID string `json:"id"`
|
||||
ParentID string `json:"parentId"`
|
||||
Schema int64 `json:"schema"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Fields map[string]interface{} `json:"fields"`
|
||||
CreateAt int64 `json:"createAt"`
|
||||
UpdateAt int64 `json:"updateAt"`
|
||||
DeleteAt int64 `json:"deleteAt"`
|
||||
}
|
84
server/server/server.go
Normal file
84
server/server/server.go
Normal file
|
@ -0,0 +1,84 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"github.com/mattermost/mattermost-octo-tasks/server/api"
|
||||
"github.com/mattermost/mattermost-octo-tasks/server/app"
|
||||
"github.com/mattermost/mattermost-octo-tasks/server/services/config"
|
||||
"github.com/mattermost/mattermost-octo-tasks/server/services/store"
|
||||
"github.com/mattermost/mattermost-octo-tasks/server/services/store/sqlstore"
|
||||
"github.com/mattermost/mattermost-octo-tasks/server/web"
|
||||
"github.com/mattermost/mattermost-octo-tasks/server/ws"
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/services/filesstore"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
config *config.Configuration
|
||||
wsServer *ws.WSServer
|
||||
webServer *web.WebServer
|
||||
store store.Store
|
||||
filesBackend filesstore.FileBackend
|
||||
}
|
||||
|
||||
func New(config *config.Configuration) (*Server, error) {
|
||||
store, err := sqlstore.New(config.DBType, config.DBConfigString)
|
||||
if err != nil {
|
||||
log.Fatal("Unable to start the database", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wsServer := ws.NewWSServer()
|
||||
|
||||
filesBackendSettings := model.FileSettings{}
|
||||
filesBackendSettings.SetDefaults(false)
|
||||
filesBackendSettings.Directory = &config.FilesPath
|
||||
filesBackend, appErr := filesstore.NewFileBackend(&filesBackendSettings, false)
|
||||
if appErr != nil {
|
||||
log.Fatal("Unable to initialize the files storage")
|
||||
return nil, errors.New("unable to initialize the files storage")
|
||||
}
|
||||
|
||||
appBuilder := func() *app.App { return app.New(config, store, wsServer, filesBackend) }
|
||||
|
||||
webServer := web.NewWebServer(config.WebPath, config.Port, config.UseSSL)
|
||||
api := api.NewAPI(appBuilder)
|
||||
webServer.AddRoutes(wsServer)
|
||||
webServer.AddRoutes(api)
|
||||
|
||||
// Ctrl+C handling
|
||||
handler := make(chan os.Signal, 1)
|
||||
signal.Notify(handler, os.Interrupt)
|
||||
go func() {
|
||||
for sig := range handler {
|
||||
// sig is a ^C, handle it
|
||||
if sig == os.Interrupt {
|
||||
os.Exit(1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return &Server{
|
||||
config: config,
|
||||
wsServer: wsServer,
|
||||
webServer: webServer,
|
||||
store: store,
|
||||
filesBackend: filesBackend,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
if err := s.webServer.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Shutdown() error {
|
||||
return s.store.Shutdown()
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package main
|
||||
package config
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
@ -17,7 +17,7 @@ type Configuration struct {
|
|||
FilesPath string `json:"filespath" mapstructure:"filespath"`
|
||||
}
|
||||
|
||||
func readConfigFile() (*Configuration, error) {
|
||||
func ReadConfigFile() (*Configuration, error) {
|
||||
viper.SetConfigName("config") // name of config file (without extension)
|
||||
viper.SetConfigType("json") // REQUIRED if the config file does not have the extension in the name
|
||||
viper.AddConfigPath(".") // optionally look for config in the working directory
|
256
server/services/store/sqlstore/blocks.go
Normal file
256
server/services/store/sqlstore/blocks.go
Normal file
|
@ -0,0 +1,256 @@
|
|||
package sqlstore
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
"github.com/mattermost/mattermost-octo-tasks/server/model"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func (s *SQLStore) GetBlocksWithParentAndType(parentID string, blockType string) ([]model.Block, error) {
|
||||
query := `WITH latest AS
|
||||
(
|
||||
SELECT * FROM
|
||||
(
|
||||
SELECT
|
||||
*,
|
||||
ROW_NUMBER() OVER (PARTITION BY id ORDER BY insert_at DESC) AS rn
|
||||
FROM blocks
|
||||
) a
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT id, parent_id, schema, type, title, COALESCE("fields", '{}'), create_at, update_at, delete_at
|
||||
FROM latest
|
||||
WHERE delete_at = 0 and parent_id = $1 and type = $2`
|
||||
|
||||
rows, err := s.db.Query(query, parentID, blockType)
|
||||
if err != nil {
|
||||
log.Printf(`getBlocksWithParentAndType ERROR: %v`, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return blocksFromRows(rows)
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetBlocksWithParent(parentID string) ([]model.Block, error) {
|
||||
query := `WITH latest AS
|
||||
(
|
||||
SELECT * FROM
|
||||
(
|
||||
SELECT
|
||||
*,
|
||||
ROW_NUMBER() OVER (PARTITION BY id ORDER BY insert_at DESC) AS rn
|
||||
FROM blocks
|
||||
) a
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT id, parent_id, schema, type, title, COALESCE("fields", '{}'), create_at, update_at, delete_at
|
||||
FROM latest
|
||||
WHERE delete_at = 0 and parent_id = $1`
|
||||
|
||||
rows, err := s.db.Query(query, parentID)
|
||||
if err != nil {
|
||||
log.Printf(`getBlocksWithParent ERROR: %v`, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return blocksFromRows(rows)
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetBlocksWithType(blockType string) ([]model.Block, error) {
|
||||
query := `WITH latest AS
|
||||
(
|
||||
SELECT * FROM
|
||||
(
|
||||
SELECT
|
||||
*,
|
||||
ROW_NUMBER() OVER (PARTITION BY id ORDER BY insert_at DESC) AS rn
|
||||
FROM blocks
|
||||
) a
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT id, parent_id, schema, type, title, COALESCE("fields", '{}'), create_at, update_at, delete_at
|
||||
FROM latest
|
||||
WHERE delete_at = 0 and type = $1`
|
||||
|
||||
rows, err := s.db.Query(query, blockType)
|
||||
if err != nil {
|
||||
log.Printf(`getBlocksWithParentAndType ERROR: %v`, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return blocksFromRows(rows)
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetSubTree(blockID string) ([]model.Block, error) {
|
||||
query := `WITH latest AS
|
||||
(
|
||||
SELECT * FROM
|
||||
(
|
||||
SELECT
|
||||
*,
|
||||
ROW_NUMBER() OVER (PARTITION BY id ORDER BY insert_at DESC) AS rn
|
||||
FROM blocks
|
||||
) a
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT id, parent_id, schema, type, title, COALESCE("fields", '{}'), create_at, update_at, delete_at
|
||||
FROM latest
|
||||
WHERE delete_at = 0
|
||||
AND (id = $1
|
||||
OR parent_id = $1)`
|
||||
|
||||
rows, err := s.db.Query(query, blockID)
|
||||
if err != nil {
|
||||
log.Printf(`getSubTree ERROR: %v`, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return blocksFromRows(rows)
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetAllBlocks() ([]model.Block, error) {
|
||||
query := `WITH latest AS
|
||||
(
|
||||
SELECT * FROM
|
||||
(
|
||||
SELECT
|
||||
*,
|
||||
ROW_NUMBER() OVER (PARTITION BY id ORDER BY insert_at DESC) AS rn
|
||||
FROM blocks
|
||||
) a
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT id, parent_id, schema, type, title, COALESCE("fields", '{}'), create_at, update_at, delete_at
|
||||
FROM latest
|
||||
WHERE delete_at = 0`
|
||||
|
||||
rows, err := s.db.Query(query)
|
||||
if err != nil {
|
||||
log.Printf(`getAllBlocks ERROR: %v`, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return blocksFromRows(rows)
|
||||
}
|
||||
|
||||
func blocksFromRows(rows *sql.Rows) ([]model.Block, error) {
|
||||
defer rows.Close()
|
||||
|
||||
var results []model.Block
|
||||
|
||||
for rows.Next() {
|
||||
var block model.Block
|
||||
var fieldsJSON string
|
||||
err := rows.Scan(
|
||||
&block.ID,
|
||||
&block.ParentID,
|
||||
&block.Schema,
|
||||
&block.Type,
|
||||
&block.Title,
|
||||
&fieldsJSON,
|
||||
&block.CreateAt,
|
||||
&block.UpdateAt,
|
||||
&block.DeleteAt)
|
||||
if err != nil {
|
||||
// handle this error
|
||||
log.Printf(`ERROR blocksFromRows: %v`, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(fieldsJSON), &block.Fields)
|
||||
if err != nil {
|
||||
// handle this error
|
||||
log.Printf(`ERROR blocksFromRows fields: %v`, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results = append(results, block)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetParentID(blockID string) (string, error) {
|
||||
statement :=
|
||||
`WITH latest AS
|
||||
(
|
||||
SELECT * FROM
|
||||
(
|
||||
SELECT
|
||||
*,
|
||||
ROW_NUMBER() OVER (PARTITION BY id ORDER BY insert_at DESC) AS rn
|
||||
FROM blocks
|
||||
) a
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT parent_id
|
||||
FROM latest
|
||||
WHERE delete_at = 0
|
||||
AND id = $1`
|
||||
|
||||
row := s.db.QueryRow(statement, blockID)
|
||||
|
||||
var parentID string
|
||||
err := row.Scan(&parentID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return parentID, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) InsertBlock(block model.Block) error {
|
||||
fieldsJSON, err := json.Marshal(block.Fields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
statement := `INSERT INTO blocks(
|
||||
id,
|
||||
parent_id,
|
||||
schema,
|
||||
type,
|
||||
title,
|
||||
fields,
|
||||
create_at,
|
||||
update_at,
|
||||
delete_at
|
||||
)
|
||||
VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9)`
|
||||
_, err = s.db.Exec(
|
||||
statement,
|
||||
block.ID,
|
||||
block.ParentID,
|
||||
block.Schema,
|
||||
block.Type,
|
||||
block.Title,
|
||||
fieldsJSON,
|
||||
block.CreateAt,
|
||||
block.UpdateAt,
|
||||
block.DeleteAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) DeleteBlock(blockID string) error {
|
||||
now := time.Now().Unix()
|
||||
statement := `INSERT INTO blocks(id, update_at, delete_at) VALUES($1, $2, $3)`
|
||||
_, err := s.db.Exec(statement, blockID, now, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
38
server/services/store/sqlstore/blocks_test.go
Normal file
38
server/services/store/sqlstore/blocks_test.go
Normal file
|
@ -0,0 +1,38 @@
|
|||
package sqlstore
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost-octo-tasks/server/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInsertBlock(t *testing.T) {
|
||||
store, tearDown := SetupTests(t)
|
||||
defer tearDown()
|
||||
|
||||
blocks, err := store.GetAllBlocks()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, blocks)
|
||||
|
||||
block := model.Block{
|
||||
ID: "id-test",
|
||||
}
|
||||
|
||||
err = store.InsertBlock(block)
|
||||
require.NoError(t, err)
|
||||
|
||||
blocks, err = store.GetAllBlocks()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, blocks, 1)
|
||||
|
||||
// Wait for not colliding the ID+insert_at key
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
err = store.DeleteBlock(block.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
blocks, err = store.GetAllBlocks()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, blocks)
|
||||
}
|
58
server/services/store/sqlstore/migrate.go
Normal file
58
server/services/store/sqlstore/migrate.go
Normal file
|
@ -0,0 +1,58 @@
|
|||
package sqlstore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database"
|
||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
bindata "github.com/golang-migrate/migrate/v4/source/go_bindata"
|
||||
_ "github.com/lib/pq"
|
||||
pgmigrations "github.com/mattermost/mattermost-octo-tasks/server/services/store/sqlstore/migrations/postgres"
|
||||
"github.com/mattermost/mattermost-octo-tasks/server/services/store/sqlstore/migrations/sqlite"
|
||||
)
|
||||
|
||||
func (s *SQLStore) Migrate() error {
|
||||
fmt.Println("HOLA")
|
||||
var driver database.Driver
|
||||
var err error
|
||||
var bresource *bindata.AssetSource
|
||||
if s.dbType == "sqlite3" {
|
||||
driver, err = sqlite3.WithInstance(s.db, &sqlite3.Config{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bresource = bindata.Resource(sqlite.AssetNames(),
|
||||
func(name string) ([]byte, error) {
|
||||
return sqlite.Asset(name)
|
||||
})
|
||||
}
|
||||
if s.dbType == "postgres" {
|
||||
driver, err = postgres.WithInstance(s.db, &postgres.Config{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bresource = bindata.Resource(pgmigrations.AssetNames(),
|
||||
func(name string) ([]byte, error) {
|
||||
return pgmigrations.Asset(name)
|
||||
})
|
||||
}
|
||||
|
||||
d, err := bindata.WithInstance(bresource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithInstance("go-bindata", d, s.dbType, driver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.Up()
|
||||
if err != nil && err != migrate.ErrNoChange {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
3
server/services/store/sqlstore/migrations/migrations.go
Normal file
3
server/services/store/sqlstore/migrations/migrations.go
Normal file
|
@ -0,0 +1,3 @@
|
|||
//go:generate go-bindata -prefix postgres_files/ -pkg postgres -o postgres/bindata.go ./postgres_files
|
||||
//go:generate go-bindata -prefix sqlite_files/ -pkg sqlite -o sqlite/bindata.go ./sqlite_files
|
||||
package migrations
|
115
server/services/store/sqlstore/migrations/postgres/bindata.go
Normal file
115
server/services/store/sqlstore/migrations/postgres/bindata.go
Normal file
|
@ -0,0 +1,115 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func bindata_read(data []byte, name string) ([]byte, error) {
|
||||
gz, err := gzip.NewReader(bytes.NewBuffer(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Read %q: %v", name, err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err = io.Copy(&buf, gz)
|
||||
gz.Close()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Read %q: %v", name, err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
var __000001_init_down_sql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x72\x09\xf2\x0f\x50\x08\x71\x74\xf2\x71\x55\x48\xca\xc9\x4f\xce\x2e\xb6\xe6\x02\x04\x00\x00\xff\xff\x45\xbe\x01\x0f\x13\x00\x00\x00")
|
||||
|
||||
func _000001_init_down_sql() ([]byte, error) {
|
||||
return bindata_read(
|
||||
__000001_init_down_sql,
|
||||
"000001_init.down.sql",
|
||||
)
|
||||
}
|
||||
|
||||
var __000001_init_up_sql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x64\x8f\xb1\x4e\xc3\x30\x10\x86\xe7\xf8\x29\x6e\x74\xa4\x6c\x48\x2c\x4c\x6e\xb9\x82\x85\xe3\x56\xce\x15\x5a\x96\xca\xc4\x87\xb0\x30\x25\x8a\xcd\xc0\xdb\xa3\x46\x22\x43\xba\xdd\xff\xe9\xbe\x3b\xfd\x6b\x87\x8a\x10\x48\xad\x0c\x82\xde\x80\xdd\x12\xe0\x41\x77\xd4\xc1\x5b\xfa\xee\x3f\x33\x48\x51\xc5\x00\xcf\xca\xad\x1f\x95\x93\x37\xb7\x75\x23\xaa\x78\xce\x3c\x96\x93\x2f\x40\xba\xc5\x8e\x54\xbb\xa3\xd7\xc9\xb5\x7b\x63\xe0\x1e\x37\x6a\x6f\x08\xec\xf6\x45\x5e\xd6\x07\x3f\xf2\xb9\x9c\xae\xce\xe4\xfe\x83\xbf\x3c\xac\xf4\x83\xb6\xd4\x88\xaa\xfc\x0e\x0c\x84\x87\x69\x8e\x25\xcd\xe1\x3d\x72\x0a\xf9\x3f\xf5\x23\xfb\xc2\x97\xef\xb3\xf9\x33\x84\x25\x0a\x9c\x78\x81\x76\x4e\xb7\xca\x1d\xe1\x09\x8f\x20\x63\x68\x60\xee\x51\x8b\xfa\x4e\xfc\x05\x00\x00\xff\xff\xf7\x74\x9c\xd5\x0c\x01\x00\x00")
|
||||
|
||||
func _000001_init_up_sql() ([]byte, error) {
|
||||
return bindata_read(
|
||||
__000001_init_up_sql,
|
||||
"000001_init.up.sql",
|
||||
)
|
||||
}
|
||||
|
||||
// Asset loads and returns the asset for the given name.
|
||||
// It returns an error if the asset could not be found or
|
||||
// could not be loaded.
|
||||
func Asset(name string) ([]byte, error) {
|
||||
cannonicalName := strings.Replace(name, "\\", "/", -1)
|
||||
if f, ok := _bindata[cannonicalName]; ok {
|
||||
return f()
|
||||
}
|
||||
return nil, fmt.Errorf("Asset %s not found", name)
|
||||
}
|
||||
|
||||
// AssetNames returns the names of the assets.
|
||||
func AssetNames() []string {
|
||||
names := make([]string, 0, len(_bindata))
|
||||
for name := range _bindata {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// _bindata is a table, holding each asset generator, mapped to its name.
|
||||
var _bindata = map[string]func() ([]byte, error){
|
||||
"000001_init.down.sql": _000001_init_down_sql,
|
||||
"000001_init.up.sql": _000001_init_up_sql,
|
||||
}
|
||||
// AssetDir returns the file names below a certain
|
||||
// directory embedded in the file by go-bindata.
|
||||
// For example if you run go-bindata on data/... and data contains the
|
||||
// following hierarchy:
|
||||
// data/
|
||||
// foo.txt
|
||||
// img/
|
||||
// a.png
|
||||
// b.png
|
||||
// then AssetDir("data") would return []string{"foo.txt", "img"}
|
||||
// AssetDir("data/img") would return []string{"a.png", "b.png"}
|
||||
// AssetDir("foo.txt") and AssetDir("notexist") would return an error
|
||||
// AssetDir("") will return []string{"data"}.
|
||||
func AssetDir(name string) ([]string, error) {
|
||||
node := _bintree
|
||||
if len(name) != 0 {
|
||||
cannonicalName := strings.Replace(name, "\\", "/", -1)
|
||||
pathList := strings.Split(cannonicalName, "/")
|
||||
for _, p := range pathList {
|
||||
node = node.Children[p]
|
||||
if node == nil {
|
||||
return nil, fmt.Errorf("Asset %s not found", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if node.Func != nil {
|
||||
return nil, fmt.Errorf("Asset %s not found", name)
|
||||
}
|
||||
rv := make([]string, 0, len(node.Children))
|
||||
for name := range node.Children {
|
||||
rv = append(rv, name)
|
||||
}
|
||||
return rv, nil
|
||||
}
|
||||
|
||||
type _bintree_t struct {
|
||||
Func func() ([]byte, error)
|
||||
Children map[string]*_bintree_t
|
||||
}
|
||||
var _bintree = &_bintree_t{nil, map[string]*_bintree_t{
|
||||
"000001_init.down.sql": &_bintree_t{_000001_init_down_sql, map[string]*_bintree_t{
|
||||
}},
|
||||
"000001_init.up.sql": &_bintree_t{_000001_init_up_sql, map[string]*_bintree_t{
|
||||
}},
|
||||
}}
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE blocks;
|
|
@ -0,0 +1,13 @@
|
|||
CREATE TABLE IF NOT EXISTS blocks (
|
||||
id VARCHAR(36),
|
||||
insert_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
parent_id VARCHAR(36),
|
||||
schema BIGINT,
|
||||
type TEXT,
|
||||
title TEXT,
|
||||
fields TEXT,
|
||||
create_at BIGINT,
|
||||
update_at BIGINT,
|
||||
delete_at BIGINT,
|
||||
PRIMARY KEY (id, insert_at)
|
||||
);
|
115
server/services/store/sqlstore/migrations/sqlite/bindata.go
Normal file
115
server/services/store/sqlstore/migrations/sqlite/bindata.go
Normal file
|
@ -0,0 +1,115 @@
|
|||
package sqlite
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func bindata_read(data []byte, name string) ([]byte, error) {
|
||||
gz, err := gzip.NewReader(bytes.NewBuffer(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Read %q: %v", name, err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err = io.Copy(&buf, gz)
|
||||
gz.Close()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Read %q: %v", name, err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
var __000001_init_down_sql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x72\x09\xf2\x0f\x50\x08\x71\x74\xf2\x71\x55\x48\xca\xc9\x4f\xce\x2e\xb6\xe6\x02\x04\x00\x00\xff\xff\x45\xbe\x01\x0f\x13\x00\x00\x00")
|
||||
|
||||
func _000001_init_down_sql() ([]byte, error) {
|
||||
return bindata_read(
|
||||
__000001_init_down_sql,
|
||||
"000001_init.down.sql",
|
||||
)
|
||||
}
|
||||
|
||||
var __000001_init_up_sql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x64\x8f\xbf\x6e\x83\x30\x10\x87\x67\xfc\x14\xb7\x58\x18\x89\x4c\x95\x3a\xa4\x93\x93\x1c\x8d\x55\x20\x95\xb9\xb4\x61\x8a\x28\xbe\xa8\x56\x49\x8a\xc0\x1d\xfa\xf6\x15\x91\xca\x90\x6c\xf7\xfb\x4e\xdf\xfd\x59\x5b\xd4\x84\x40\x7a\x95\x23\x98\x0c\xca\x1d\x01\x1e\x4c\x45\x15\x7c\x74\xdf\xed\xd7\x08\x4a\x44\xde\xc1\x9b\xb6\xeb\xad\xb6\xea\xe1\x31\x49\x45\xe4\x2f\x23\x0f\xe1\xd8\x04\xd8\x68\x42\x32\x05\x5e\xc5\x72\x9f\xe7\xb0\xc1\x4c\xef\x73\x52\x15\xd9\x6c\xea\xa8\x58\xd6\x0b\x79\x5e\x48\x07\x72\xbb\x94\xc5\x52\x9e\xe2\x14\xe2\x72\xf7\x1e\x27\xd3\xac\xbe\x19\xf8\x12\x8e\x77\x3b\xc6\xf6\x93\xcf\x0d\xac\xcc\xb3\x29\x29\x15\x51\xf8\xed\x19\x08\x0f\xd7\xda\x87\x6e\x0e\x27\xcf\x9d\x1b\xff\x53\x3b\x70\x13\x78\x3a\x6d\x36\x7f\x7a\x77\x8b\x1c\x77\x7c\x83\x5e\xad\x29\xb4\xad\xe1\x05\x6b\x50\xde\xa5\x30\x3f\x99\x88\xe4\x49\xfc\x05\x00\x00\xff\xff\xa2\x33\x30\x8e\x29\x01\x00\x00")
|
||||
|
||||
func _000001_init_up_sql() ([]byte, error) {
|
||||
return bindata_read(
|
||||
__000001_init_up_sql,
|
||||
"000001_init.up.sql",
|
||||
)
|
||||
}
|
||||
|
||||
// Asset loads and returns the asset for the given name.
|
||||
// It returns an error if the asset could not be found or
|
||||
// could not be loaded.
|
||||
func Asset(name string) ([]byte, error) {
|
||||
cannonicalName := strings.Replace(name, "\\", "/", -1)
|
||||
if f, ok := _bindata[cannonicalName]; ok {
|
||||
return f()
|
||||
}
|
||||
return nil, fmt.Errorf("Asset %s not found", name)
|
||||
}
|
||||
|
||||
// AssetNames returns the names of the assets.
|
||||
func AssetNames() []string {
|
||||
names := make([]string, 0, len(_bindata))
|
||||
for name := range _bindata {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// _bindata is a table, holding each asset generator, mapped to its name.
|
||||
var _bindata = map[string]func() ([]byte, error){
|
||||
"000001_init.down.sql": _000001_init_down_sql,
|
||||
"000001_init.up.sql": _000001_init_up_sql,
|
||||
}
|
||||
// AssetDir returns the file names below a certain
|
||||
// directory embedded in the file by go-bindata.
|
||||
// For example if you run go-bindata on data/... and data contains the
|
||||
// following hierarchy:
|
||||
// data/
|
||||
// foo.txt
|
||||
// img/
|
||||
// a.png
|
||||
// b.png
|
||||
// then AssetDir("data") would return []string{"foo.txt", "img"}
|
||||
// AssetDir("data/img") would return []string{"a.png", "b.png"}
|
||||
// AssetDir("foo.txt") and AssetDir("notexist") would return an error
|
||||
// AssetDir("") will return []string{"data"}.
|
||||
func AssetDir(name string) ([]string, error) {
|
||||
node := _bintree
|
||||
if len(name) != 0 {
|
||||
cannonicalName := strings.Replace(name, "\\", "/", -1)
|
||||
pathList := strings.Split(cannonicalName, "/")
|
||||
for _, p := range pathList {
|
||||
node = node.Children[p]
|
||||
if node == nil {
|
||||
return nil, fmt.Errorf("Asset %s not found", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if node.Func != nil {
|
||||
return nil, fmt.Errorf("Asset %s not found", name)
|
||||
}
|
||||
rv := make([]string, 0, len(node.Children))
|
||||
for name := range node.Children {
|
||||
rv = append(rv, name)
|
||||
}
|
||||
return rv, nil
|
||||
}
|
||||
|
||||
type _bintree_t struct {
|
||||
Func func() ([]byte, error)
|
||||
Children map[string]*_bintree_t
|
||||
}
|
||||
var _bintree = &_bintree_t{nil, map[string]*_bintree_t{
|
||||
"000001_init.down.sql": &_bintree_t{_000001_init_down_sql, map[string]*_bintree_t{
|
||||
}},
|
||||
"000001_init.up.sql": &_bintree_t{_000001_init_up_sql, map[string]*_bintree_t{
|
||||
}},
|
||||
}}
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE blocks;
|
|
@ -0,0 +1,13 @@
|
|||
CREATE TABLE IF NOT EXISTS blocks (
|
||||
id VARCHAR(36),
|
||||
insert_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
|
||||
parent_id VARCHAR(36),
|
||||
schema BIGINT,
|
||||
type TEXT,
|
||||
title TEXT,
|
||||
fields TEXT,
|
||||
create_at BIGINT,
|
||||
update_at BIGINT,
|
||||
delete_at BIGINT,
|
||||
PRIMARY KEY (id, insert_at)
|
||||
);
|
46
server/services/store/sqlstore/sqlstore.go
Normal file
46
server/services/store/sqlstore/sqlstore.go
Normal file
|
@ -0,0 +1,46 @@
|
|||
package sqlstore
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
)
|
||||
|
||||
// SQLStore is a SQL database
|
||||
type SQLStore struct {
|
||||
db *sql.DB
|
||||
dbType string
|
||||
}
|
||||
|
||||
// New creates a new SQL implementation of the store
|
||||
func New(dbType, connectionString string) (*SQLStore, error) {
|
||||
log.Println("connectDatabase")
|
||||
var err error
|
||||
|
||||
db, err := sql.Open(dbType, connectionString)
|
||||
if err != nil {
|
||||
log.Fatal("connectDatabase: ", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = db.Ping()
|
||||
if err != nil {
|
||||
log.Println(`Database Ping failed`)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
store := &SQLStore{
|
||||
db: db,
|
||||
dbType: dbType,
|
||||
}
|
||||
|
||||
err = store.Migrate()
|
||||
if err != nil {
|
||||
log.Println(`Table creation failed`)
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) Shutdown() error {
|
||||
return s.db.Close()
|
||||
}
|
28
server/services/store/sqlstore/sqlstore_test.go
Normal file
28
server/services/store/sqlstore/sqlstore_test.go
Normal file
|
@ -0,0 +1,28 @@
|
|||
package sqlstore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func SetupTests(t *testing.T) (*SQLStore, func()) {
|
||||
dbType := os.Getenv("OT_STORE_TEST_DB_TYPE")
|
||||
if dbType == "" {
|
||||
dbType = "sqlite3"
|
||||
}
|
||||
connectionString := os.Getenv("OT_STORE_TEST_CONN_STRING")
|
||||
if connectionString == "" {
|
||||
connectionString = ":memory:"
|
||||
}
|
||||
|
||||
store, err := New(dbType, connectionString)
|
||||
require.Nil(t, err)
|
||||
|
||||
tearDown := func() {
|
||||
err = store.Shutdown()
|
||||
require.Nil(t, err)
|
||||
}
|
||||
return store, tearDown
|
||||
}
|
16
server/services/store/store.go
Normal file
16
server/services/store/store.go
Normal file
|
@ -0,0 +1,16 @@
|
|||
package store
|
||||
|
||||
import "github.com/mattermost/mattermost-octo-tasks/server/model"
|
||||
|
||||
// Store represents the abstraction of the data storage
|
||||
type Store interface {
|
||||
GetBlocksWithParentAndType(parentID string, blockType string) ([]model.Block, error)
|
||||
GetBlocksWithParent(parentID string) ([]model.Block, error)
|
||||
GetBlocksWithType(blockType string) ([]model.Block, error)
|
||||
GetSubTree(blockID string) ([]model.Block, error)
|
||||
GetAllBlocks() ([]model.Block, error)
|
||||
GetParentID(blockID string) (string, error)
|
||||
InsertBlock(block model.Block) error
|
||||
DeleteBlock(blockID string) error
|
||||
Shutdown() error
|
||||
}
|
79
server/web/webserver.go
Normal file
79
server/web/webserver.go
Normal file
|
@ -0,0 +1,79 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type RoutedService interface {
|
||||
RegisterRoutes(*mux.Router)
|
||||
}
|
||||
|
||||
type WebServer struct {
|
||||
router *mux.Router
|
||||
rootPath string
|
||||
port int
|
||||
ssl bool
|
||||
}
|
||||
|
||||
func NewWebServer(rootPath string, port int, ssl bool) *WebServer {
|
||||
r := mux.NewRouter()
|
||||
|
||||
ws := &WebServer{
|
||||
router: r,
|
||||
rootPath: rootPath,
|
||||
port: port,
|
||||
ssl: ssl,
|
||||
}
|
||||
|
||||
return ws
|
||||
}
|
||||
|
||||
func (ws *WebServer) AddRoutes(rs RoutedService) {
|
||||
rs.RegisterRoutes(ws.router)
|
||||
}
|
||||
|
||||
func (ws *WebServer) registerRoutes() {
|
||||
ws.router.PathPrefix("/static").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join(ws.rootPath, "static")))))
|
||||
ws.router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
http.ServeFile(w, r, path.Join(ws.rootPath, "index.html"))
|
||||
})
|
||||
}
|
||||
|
||||
func (ws *WebServer) Start() error {
|
||||
ws.registerRoutes()
|
||||
http.Handle("/", ws.router)
|
||||
|
||||
urlPort := fmt.Sprintf(`:%d`, ws.port)
|
||||
var isSSL = ws.ssl && fileExists("./cert/cert.pem") && fileExists("./cert/key.pem")
|
||||
if isSSL {
|
||||
log.Println("https server started on ", urlPort)
|
||||
err := http.ListenAndServeTLS(urlPort, "./cert/cert.pem", "./cert/key.pem", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
log.Println("http server started on ", urlPort)
|
||||
err := http.ListenAndServe(urlPort, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FileExists returns true if a file exists at the path
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
if os.IsNotExist(err) {
|
||||
return false
|
||||
}
|
||||
return err == nil
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package main
|
||||
package ws
|
||||
|
||||
import (
|
||||
"sync"
|
|
@ -1,13 +1,18 @@
|
|||
package main
|
||||
package ws
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func (ws *WSServer) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/ws/onchange", ws.handleWebSocketOnChange)
|
||||
}
|
||||
|
||||
// AddListener adds a listener for a blockID's change
|
||||
func (ws *WSServer) AddListener(client *websocket.Conn, blockID string) {
|
||||
ws.mu.Lock()
|
||||
|
@ -105,7 +110,7 @@ func (ws *WSServer) handleWebSocketOnChange(w http.ResponseWriter, r *http.Reque
|
|||
}
|
||||
}
|
||||
|
||||
func (ws *WSServer) broadcastBlockChangeToWebsocketClients(blockIDs []string) {
|
||||
func (ws *WSServer) BroadcastBlockChangeToWebsocketClients(blockIDs []string) {
|
||||
for _, blockID := range blockIDs {
|
||||
listeners := ws.GetListeners(blockID)
|
||||
log.Printf("%d listener(s) for blockID: %s", len(listeners), blockID)
|
|
@ -23,6 +23,9 @@ export default function App() {
|
|||
<Route path="/login">
|
||||
<LoginPage />
|
||||
</Route>
|
||||
<Route path="/">
|
||||
<BoardPage />
|
||||
</Route>
|
||||
<Route path="/board">
|
||||
<BoardPage />
|
||||
</Route>
|
||||
|
|
|
@ -50,7 +50,7 @@ class Archiver {
|
|||
// TODO: Remove or reuse link
|
||||
}
|
||||
|
||||
static importFullArchive(onImported?: () => void): void {
|
||||
static importFullArchive(onComplete?: () => void): void {
|
||||
const input = document.createElement("input")
|
||||
input.type = "file"
|
||||
input.accept = ".octo"
|
||||
|
@ -73,7 +73,7 @@ class Archiver {
|
|||
|
||||
await mutator.importFullArchive(filteredBlocks)
|
||||
Utils.log(`Import completed`)
|
||||
onImported?.()
|
||||
onComplete?.()
|
||||
}
|
||||
|
||||
input.style.display = "none"
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { IBlock } from "./octoTypes"
|
||||
import { Utils } from "./utils"
|
||||
import { IBlock } from "../octoTypes"
|
||||
import { Utils } from "../utils"
|
||||
|
||||
class Block implements IBlock {
|
||||
id: string = Utils.createGuid()
|
||||
|
@ -7,7 +7,6 @@ class Block implements IBlock {
|
|||
parentId: string
|
||||
type: string
|
||||
title: string
|
||||
order: number
|
||||
fields: Record<string, any> = {}
|
||||
createAt: number = Date.now()
|
||||
updateAt: number = 0
|
||||
|
@ -38,7 +37,6 @@ class Block implements IBlock {
|
|||
this.fields = block.fields ? { ...block.fields } : {}
|
||||
|
||||
this.title = block.title
|
||||
this.order = block.order
|
||||
|
||||
this.createAt = block.createAt || now
|
||||
this.updateAt = block.updateAt || now
|
|
@ -1,5 +1,5 @@
|
|||
import { FilterGroup } from "../filterGroup"
|
||||
import { Block } from "./block"
|
||||
import { FilterGroup } from "./filterGroup"
|
||||
|
||||
type IViewType = "board" | "table" | "calendar" | "list" | "gallery"
|
||||
type ISortOption = { propertyId: "__name" | string, reversed: boolean }
|
10
src/client/blocks/commentBlock.ts
Normal file
10
src/client/blocks/commentBlock.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { Block } from "./block"
|
||||
|
||||
class CommentBlock extends Block {
|
||||
constructor(block: any = {}) {
|
||||
super(block)
|
||||
this.type = "comment"
|
||||
}
|
||||
}
|
||||
|
||||
export { CommentBlock }
|
17
src/client/blocks/imageBlock.ts
Normal file
17
src/client/blocks/imageBlock.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { IOrderedBlock } from "../octoTypes"
|
||||
import { Block } from "./block"
|
||||
|
||||
class ImageBlock extends Block implements IOrderedBlock {
|
||||
get order(): number { return this.fields.order as number }
|
||||
set order(value: number) { this.fields.order = value }
|
||||
|
||||
get url(): string { return this.fields.url as string }
|
||||
set url(value: string) { this.fields.url = value }
|
||||
|
||||
constructor(block: any = {}) {
|
||||
super(block)
|
||||
this.type = "image"
|
||||
}
|
||||
}
|
||||
|
||||
export { ImageBlock }
|
14
src/client/blocks/textBlock.ts
Normal file
14
src/client/blocks/textBlock.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { IOrderedBlock } from "../octoTypes"
|
||||
import { Block } from "./block"
|
||||
|
||||
class TextBlock extends Block implements IOrderedBlock {
|
||||
get order(): number { return this.fields.order as number }
|
||||
set order(value: number) { this.fields.order = value }
|
||||
|
||||
constructor(block: any = {}) {
|
||||
super(block)
|
||||
this.type = "text"
|
||||
}
|
||||
}
|
||||
|
||||
export { TextBlock }
|
|
@ -1,9 +1,11 @@
|
|||
import { Board, IPropertyOption, IPropertyTemplate } from "./board"
|
||||
import { BoardView } from "./boardView"
|
||||
import { Card } from "./card"
|
||||
import { Block } from "./blocks/block"
|
||||
import { Board, IPropertyOption, IPropertyTemplate } from "./blocks/board"
|
||||
import { BoardView } from "./blocks/boardView"
|
||||
import { Card } from "./blocks/card"
|
||||
import { CardFilter } from "./cardFilter"
|
||||
import octoClient from "./octoClient"
|
||||
import { IBlock } from "./octoTypes"
|
||||
import { OctoUtils } from "./octoUtils"
|
||||
import { Utils } from "./utils"
|
||||
|
||||
type Group = { option: IPropertyOption, cards: Card[] }
|
||||
|
@ -29,21 +31,13 @@ class BoardTree {
|
|||
|
||||
async sync() {
|
||||
const blocks = await octoClient.getSubtree(this.boardId)
|
||||
this.rebuild(blocks)
|
||||
this.rebuild(OctoUtils.hydrateBlocks(blocks))
|
||||
}
|
||||
|
||||
private rebuild(blocks: IBlock[]) {
|
||||
const boardBlock = blocks.find(block => block.type === "board")
|
||||
|
||||
if (boardBlock) {
|
||||
this.board = new Board(boardBlock)
|
||||
}
|
||||
|
||||
const viewBlocks = blocks.filter(block => block.type === "view")
|
||||
this.views = viewBlocks.map(o => new BoardView(o))
|
||||
|
||||
const cardBlocks = blocks.filter(block => block.type === "card")
|
||||
this.allCards = cardBlocks.map(o => new Card(o))
|
||||
private rebuild(blocks: Block[]) {
|
||||
this.board = blocks.find(block => block.type === "board") as Board
|
||||
this.views = blocks.filter(block => block.type === "view") as BoardView[]
|
||||
this.allCards = blocks.filter(block => block.type === "card") as Card[]
|
||||
this.cards = []
|
||||
|
||||
this.ensureMinimumSchema()
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { IPropertyTemplate } from "./board"
|
||||
import { Card } from "./card"
|
||||
import { IPropertyTemplate } from "./blocks/board"
|
||||
import { Card } from "./blocks/card"
|
||||
import { FilterClause } from "./filterClause"
|
||||
import { FilterGroup } from "./filterGroup"
|
||||
import { Utils } from "./utils"
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import { Card } from "./card"
|
||||
import { Block } from "./blocks/block"
|
||||
import { Card } from "./blocks/card"
|
||||
import octoClient from "./octoClient"
|
||||
import { IBlock } from "./octoTypes"
|
||||
import { IBlock, IOrderedBlock } from "./octoTypes"
|
||||
import { OctoUtils } from "./octoUtils"
|
||||
|
||||
class CardTree {
|
||||
card: Card
|
||||
comments: IBlock[]
|
||||
contents: IBlock[]
|
||||
contents: IOrderedBlock[]
|
||||
isSynched: boolean
|
||||
|
||||
constructor(private cardId: string) {
|
||||
|
@ -13,19 +15,18 @@ class CardTree {
|
|||
|
||||
async sync() {
|
||||
const blocks = await octoClient.getSubtree(this.cardId)
|
||||
this.rebuild(blocks)
|
||||
this.rebuild(OctoUtils.hydrateBlocks(blocks))
|
||||
}
|
||||
|
||||
private rebuild(blocks: IBlock[]) {
|
||||
this.card = new Card(blocks.find(o => o.id === this.cardId))
|
||||
private rebuild(blocks: Block[]) {
|
||||
this.card = blocks.find(o => o.id === this.cardId) as Card
|
||||
|
||||
this.comments = blocks
|
||||
.filter(block => block.type === "comment")
|
||||
.sort((a, b) => a.createAt - b.createAt)
|
||||
|
||||
this.contents = blocks
|
||||
.filter(block => block.type === "text" || block.type === "image")
|
||||
.sort((a, b) => a.order - b.order)
|
||||
const contentBlocks = blocks.filter(block => block.type === "text" || block.type === "image") as IOrderedBlock[]
|
||||
this.contents = contentBlocks.sort((a, b) => a.order - b.order)
|
||||
|
||||
this.isSynched = true
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from "react"
|
||||
import { Block } from "../block"
|
||||
import { IPropertyTemplate } from "../board"
|
||||
import { Card } from "../card"
|
||||
import { Block } from "../blocks/block"
|
||||
import { IPropertyTemplate } from "../blocks/board"
|
||||
import { Card } from "../blocks/card"
|
||||
import { Menu } from "../menu"
|
||||
import mutator from "../mutator"
|
||||
import { OctoUtils } from "../octoUtils"
|
||||
|
|
|
@ -1,24 +1,23 @@
|
|||
import React from "react"
|
||||
import { Archiver } from "../archiver"
|
||||
import { BlockIcons } from "../blockIcons"
|
||||
import { IPropertyOption } from "../board"
|
||||
import { IPropertyOption } from "../blocks/board"
|
||||
import { Card } from "../blocks/card"
|
||||
import { BoardTree } from "../boardTree"
|
||||
import { Card } from "../card"
|
||||
import { CardFilter } from "../cardFilter"
|
||||
import ViewMenu from "../components/viewMenu"
|
||||
import MenuWrapper from "../widgets/menuWrapper"
|
||||
import Menu from "../widgets/menu"
|
||||
import { Constants } from "../constants"
|
||||
import { randomEmojiList } from "../emojiList"
|
||||
import { Menu as OldMenu } from "../menu"
|
||||
import mutator from "../mutator"
|
||||
import { OctoUtils } from "../octoUtils"
|
||||
import { Utils } from "../utils"
|
||||
import Menu from "../widgets/menu"
|
||||
import MenuWrapper from "../widgets/menuWrapper"
|
||||
import { BoardCard } from "./boardCard"
|
||||
import { BoardColumn } from "./boardColumn"
|
||||
import Button from "./button"
|
||||
import { Editable } from "./editable"
|
||||
import { CardDialog } from "./cardDialog"
|
||||
import { Editable } from "./editable"
|
||||
import RootPortal from "./rootPortal"
|
||||
|
||||
type Props = {
|
||||
|
@ -96,8 +95,8 @@ class BoardComponent extends React.Component<Props, State> {
|
|||
<MenuWrapper>
|
||||
<div className="octo-button octo-icon">{board.icon}</div>
|
||||
<Menu>
|
||||
<Menu.Text id='random' name='Random' onClick={() => mutator.changeIcon(board, undefined, "remove icon")}/>
|
||||
<Menu.Text id='remove' name='Remove Icon' onClick={() => mutator.changeIcon(board, BlockIcons.shared.randomIcon())}/>
|
||||
<Menu.Text id='random' name='Random' onClick={() => mutator.changeIcon(board, BlockIcons.shared.randomIcon())}/>
|
||||
<Menu.Text id='remove' name='Remove Icon' onClick={() => mutator.changeIcon(board, undefined, "remove icon")}/>
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
: undefined}
|
||||
|
@ -149,7 +148,7 @@ class BoardComponent extends React.Component<Props, State> {
|
|||
|
||||
<div className="octo-board-header-cell">
|
||||
<div className="octo-label" title={`Items with an empty ${boardTree.groupByProperty?.name} property will go here. This column cannot be removed.`}>{`No ${boardTree.groupByProperty?.name}`}</div>
|
||||
<Button text={`${boardTree.emptyGroupCards.length}`} />
|
||||
<Button>{`${boardTree.emptyGroupCards.length}`}</Button>
|
||||
<div className="octo-spacer" />
|
||||
<Button><div className="imageOptions" /></Button>
|
||||
<Button onClick={() => { this.addCard(undefined) }}><div className="imageAdd" /></Button>
|
||||
|
@ -173,7 +172,7 @@ class BoardComponent extends React.Component<Props, State> {
|
|||
className={`octo-label ${group.option.color}`}
|
||||
text={group.option.value}
|
||||
onChanged={(text) => { this.propertyNameChanged(group.option, text) }} />
|
||||
<Button text={`${group.cards.length}`} />
|
||||
<Button>{`${group.cards.length}`}</Button>
|
||||
<div className="octo-spacer" />
|
||||
<MenuWrapper>
|
||||
<Button><div className="imageOptions" /></Button>
|
||||
|
@ -190,7 +189,7 @@ class BoardComponent extends React.Component<Props, State> {
|
|||
)}
|
||||
|
||||
<div className="octo-board-header-cell">
|
||||
<Button text="+ Add a group" onClick={(e) => { this.addGroupClicked() }} />
|
||||
<Button onClick={(e) => { this.addGroupClicked() }}>+ Add a group</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -210,7 +209,7 @@ class BoardComponent extends React.Component<Props, State> {
|
|||
onDragStart={() => { this.draggedCard = card }}
|
||||
onDragEnd={() => { this.draggedCard = undefined }} />
|
||||
)}
|
||||
<Button text="+ New" onClick={() => { this.addCard(undefined) }} />
|
||||
<Button onClick={() => { this.addCard(undefined) }} >+ New</Button>
|
||||
</BoardColumn>
|
||||
|
||||
{/* Columns */}
|
||||
|
@ -226,7 +225,7 @@ class BoardComponent extends React.Component<Props, State> {
|
|||
onDragStart={() => { this.draggedCard = card }}
|
||||
onDragEnd={() => { this.draggedCard = undefined }} />
|
||||
)}
|
||||
<Button text="+ New" onClick={() => { this.addCard(group.option.value) }} />
|
||||
<Button onClick={() => { this.addCard(group.option.value) }} >+ New</Button>
|
||||
</BoardColumn>
|
||||
)}
|
||||
</div>
|
||||
|
@ -243,10 +242,11 @@ class BoardComponent extends React.Component<Props, State> {
|
|||
const card = new Card()
|
||||
card.parentId = boardTree.board.id
|
||||
card.properties = CardFilter.propertiesThatMeetFilterGroup(activeView.filter, board.cardProperties)
|
||||
card.icon = BlockIcons.shared.randomIcon()
|
||||
if (boardTree.groupByProperty) {
|
||||
card.properties[boardTree.groupByProperty.id] = groupByValue
|
||||
}
|
||||
await mutator.insertBlock(card, "add card", async () => { await this.setState({shownCard: card}) }, async () => { await this.setState({shownCard: undefined}) })
|
||||
await mutator.insertBlock(card, "add card", async () => { this.setState({shownCard: card}) }, async () => { this.setState({shownCard: undefined}) })
|
||||
}
|
||||
|
||||
async propertyNameChanged(option: IPropertyOption, text: string) {
|
||||
|
@ -266,6 +266,7 @@ class BoardComponent extends React.Component<Props, State> {
|
|||
{ id: "exportBoardArchive", name: "Export board archive" },
|
||||
{ id: "testAdd100Cards", name: "TEST: Add 100 cards" },
|
||||
{ id: "testAdd1000Cards", name: "TEST: Add 1,000 cards" },
|
||||
{ id: "testRandomizeIcons", name: "TEST: Randomize icons" },
|
||||
]
|
||||
|
||||
OldMenu.shared.onMenuClicked = async (id: string) => {
|
||||
|
@ -282,6 +283,10 @@ class BoardComponent extends React.Component<Props, State> {
|
|||
this.testAddCards(1000)
|
||||
break
|
||||
}
|
||||
case "testRandomizeIcons": {
|
||||
this.testRandomizeIcons()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
OldMenu.shared.showAtElement(e.target as HTMLElement)
|
||||
|
@ -310,6 +315,14 @@ class BoardComponent extends React.Component<Props, State> {
|
|||
}
|
||||
}
|
||||
|
||||
private async testRandomizeIcons() {
|
||||
const { boardTree } = this.props
|
||||
|
||||
for (const card of boardTree.cards) {
|
||||
mutator.changeIcon(card, BlockIcons.shared.randomIcon(), "randomize icon")
|
||||
}
|
||||
}
|
||||
|
||||
private async propertiesClicked(e: React.MouseEvent) {
|
||||
const { boardTree } = this.props
|
||||
const { activeView } = boardTree
|
||||
|
|
|
@ -6,7 +6,7 @@ type Props = {
|
|||
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void
|
||||
style?: React.CSSProperties
|
||||
backgroundColor?: string
|
||||
text?: string
|
||||
children?: React.ReactNode
|
||||
title?: string
|
||||
}
|
||||
|
||||
|
@ -20,7 +20,6 @@ export default class Button extends React.Component<Props> {
|
|||
style={style}
|
||||
title={this.props.title}>
|
||||
{this.props.children}
|
||||
{this.props.text}
|
||||
</div>)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import React from "react"
|
||||
import { Card } from "../blocks/card"
|
||||
import { BoardTree } from "../boardTree"
|
||||
import { Card } from "../card"
|
||||
import Menu from "../widgets/menu"
|
||||
import Dialog from "./dialog"
|
||||
import CardDetail from "./cardDetail"
|
||||
import mutator from "../mutator"
|
||||
import Menu from "../widgets/menu"
|
||||
import CardDetail from "./cardDetail"
|
||||
import Dialog from "./dialog"
|
||||
|
||||
type Props = {
|
||||
boardTree: BoardTree
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import React from "react"
|
||||
import { Archiver } from "../archiver"
|
||||
import { Board } from "../board"
|
||||
import { Board } from "../blocks/board"
|
||||
import { BoardTree } from "../boardTree"
|
||||
import { Menu, MenuOption } from "../menu"
|
||||
import Menu from '../widgets/menu'
|
||||
import MenuWrapper from '../widgets/menuWrapper'
|
||||
import mutator from "../mutator"
|
||||
import { IPageController } from "../octoTypes"
|
||||
import { WorkspaceTree } from "../workspaceTree"
|
||||
|
||||
type Props = {
|
||||
|
@ -32,7 +32,19 @@ class Sidebar extends React.Component<Props> {
|
|||
<div key={board.id} className="octo-sidebar-item octo-hover-container">
|
||||
<div className="octo-sidebar-title" onClick={() => { this.boardClicked(board) }}>{board.icon ? `${board.icon} ${displayTitle}` : displayTitle}</div>
|
||||
<div className="octo-spacer"></div>
|
||||
<div className="octo-button square octo-hover-item" onClick={(e) => { this.showOptions(e, board) }}><div className="imageOptions" /></div>
|
||||
<MenuWrapper>
|
||||
<div className="octo-button square octo-hover-item"><div className="imageOptions" /></div>
|
||||
<Menu>
|
||||
<Menu.Text id="delete" name="Delete board" onClick={async () => {
|
||||
const nextBoardId = boards.length > 1 ? boards.find(o => o.id !== board.id).id : undefined
|
||||
mutator.deleteBlock(
|
||||
board,
|
||||
"delete block",
|
||||
async () => { nextBoardId && this.props.showBoard(nextBoardId!) },
|
||||
async () => { this.props.showBoard(board.id) },
|
||||
)}} />
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
@ -44,66 +56,17 @@ class Sidebar extends React.Component<Props> {
|
|||
|
||||
<div className="octo-spacer"></div>
|
||||
|
||||
<div className="octo-button" onClick={(e) => { this.settingsClicked(e) }}>Settings</div>
|
||||
<MenuWrapper>
|
||||
<div className="octo-button">Settings</div>
|
||||
<Menu position="top">
|
||||
<Menu.Text id="import" name="Import Archive" onClick={async () => Archiver.importFullArchive(() => { this.forceUpdate() })} />
|
||||
<Menu.Text id="export" name="Export Archive" onClick={async () => Archiver.exportFullArchive()} />
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private showOptions(e: React.MouseEvent, board: Board) {
|
||||
const { showBoard, workspaceTree } = this.props
|
||||
const { boards } = workspaceTree
|
||||
|
||||
const options: MenuOption[] = []
|
||||
|
||||
const nextBoardId = boards.length > 1 ? boards.find(o => o.id !== board.id).id : undefined
|
||||
if (nextBoardId) {
|
||||
options.push({ id: "delete", name: "Delete board" })
|
||||
}
|
||||
|
||||
Menu.shared.options = options
|
||||
Menu.shared.onMenuClicked = (optionId: string, type?: string) => {
|
||||
switch (optionId) {
|
||||
case "delete": {
|
||||
mutator.deleteBlock(
|
||||
board,
|
||||
"delete block",
|
||||
async () => { showBoard(nextBoardId!) },
|
||||
async () => { showBoard(board.id) },
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
||||
}
|
||||
|
||||
private settingsClicked(e: React.MouseEvent) {
|
||||
Menu.shared.options = [
|
||||
{ id: "import", name: "Import Archive" },
|
||||
{ id: "export", name: "Export Archive" },
|
||||
]
|
||||
Menu.shared.onMenuClicked = (optionId: string, type?: string) => {
|
||||
switch (optionId) {
|
||||
case "import": {
|
||||
Archiver.importFullArchive(() => {
|
||||
this.forceUpdate()
|
||||
})
|
||||
break
|
||||
}
|
||||
case "export": {
|
||||
Archiver.exportFullArchive()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HACKHACK: Show menu above (TODO: refactor menu code to do this automatically)
|
||||
const element = e.target as HTMLElement
|
||||
const bodyRect = document.body.getBoundingClientRect()
|
||||
const rect = element.getBoundingClientRect()
|
||||
Menu.shared.showAt(rect.left - bodyRect.left + 20, rect.top - bodyRect.top - 30 * Menu.shared.options.length)
|
||||
}
|
||||
|
||||
private boardClicked(board: Board) {
|
||||
this.props.showBoard(board.id)
|
||||
}
|
||||
|
|
|
@ -1,23 +1,22 @@
|
|||
import React from "react"
|
||||
import { Archiver } from "../archiver"
|
||||
import { Block } from "../block"
|
||||
import { BlockIcons } from "../blockIcons"
|
||||
import { IPropertyTemplate } from "../board"
|
||||
import { IPropertyTemplate } from "../blocks/board"
|
||||
import { Card } from "../blocks/card"
|
||||
import { BoardTree } from "../boardTree"
|
||||
import { Card } from "../card"
|
||||
import ViewMenu from "../components/viewMenu"
|
||||
import MenuWrapper from "../widgets/menuWrapper"
|
||||
import Menu from "../widgets/menu"
|
||||
import { CsvExporter } from "../csvExporter"
|
||||
import { Menu as OldMenu } from "../menu"
|
||||
import mutator from "../mutator"
|
||||
import { OctoUtils } from "../octoUtils"
|
||||
import { Utils } from "../utils"
|
||||
import Menu from "../widgets/menu"
|
||||
import MenuWrapper from "../widgets/menuWrapper"
|
||||
import Button from "./button"
|
||||
import { Editable } from "./editable"
|
||||
import { TableRow } from "./tableRow"
|
||||
import { CardDialog } from "./cardDialog"
|
||||
import { Editable } from "./editable"
|
||||
import RootPortal from "./rootPortal"
|
||||
import { TableRow } from "./tableRow"
|
||||
|
||||
type Props = {
|
||||
boardTree?: BoardTree
|
||||
|
@ -92,8 +91,8 @@ class TableComponent extends React.Component<Props, State> {
|
|||
<MenuWrapper>
|
||||
<div className="octo-button octo-icon">{board.icon}</div>
|
||||
<Menu>
|
||||
<Menu.Text id='random' name='Random' onClick={() => mutator.changeIcon(board, undefined, "remove icon")}/>
|
||||
<Menu.Text id='remove' name='Remove Icon' onClick={() => mutator.changeIcon(board, BlockIcons.shared.randomIcon())}/>
|
||||
<Menu.Text id='random' name='Random' onClick={() => mutator.changeIcon(board, BlockIcons.shared.randomIcon())}/>
|
||||
<Menu.Text id='remove' name='Remove Icon' onClick={() => mutator.changeIcon(board, undefined, "remove icon")}/>
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
: undefined}
|
||||
|
@ -361,6 +360,7 @@ class TableComponent extends React.Component<Props, State> {
|
|||
|
||||
const card = new Card()
|
||||
card.parentId = boardTree.board.id
|
||||
card.icon = BlockIcons.shared.randomIcon()
|
||||
await mutator.insertBlock(
|
||||
card,
|
||||
"add card",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from "react"
|
||||
import { BoardTree } from "../boardTree"
|
||||
import { Card } from "../card"
|
||||
import { Card } from "../blocks/card"
|
||||
import mutator from "../mutator"
|
||||
import { OctoUtils } from "../octoUtils"
|
||||
import { Editable } from "./editable"
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from "react"
|
||||
import { Board } from "../board"
|
||||
import { Board } from "../blocks/board"
|
||||
import { BoardView } from "../blocks/boardView"
|
||||
import { BoardTree } from "../boardTree"
|
||||
import { BoardView } from "../boardView"
|
||||
import mutator from "../mutator"
|
||||
import { Utils } from "../utils"
|
||||
import Menu from "../widgets/menu"
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import React from "react"
|
||||
import { BoardTree } from "../boardTree"
|
||||
import { Card } from "../card"
|
||||
import { Utils } from "../utils"
|
||||
import { WorkspaceTree } from "../workspaceTree"
|
||||
import { BoardComponent } from "./boardComponent"
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { BoardView } from "./blocks/boardView"
|
||||
import { BoardTree } from "./boardTree"
|
||||
import { BoardView } from "./boardView"
|
||||
import { OctoUtils } from "./octoUtils"
|
||||
import { Utils } from "./utils"
|
||||
|
||||
|
|
|
@ -13,9 +13,11 @@ class FilterClause {
|
|||
case "notIncludes": return "doesn't include"
|
||||
case "isEmpty": return "is empty"
|
||||
case "isNotEmpty": return "is not empty"
|
||||
default: {
|
||||
Utils.assertFailure()
|
||||
return "(unknown)"
|
||||
}
|
||||
}
|
||||
Utils.assertFailure()
|
||||
return "(unknown)"
|
||||
}
|
||||
|
||||
constructor(o: any = {}) {
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { Block } from "./block"
|
||||
import { Board, IPropertyOption, IPropertyTemplate, PropertyType } from "./board"
|
||||
import { Block } from "./blocks/block"
|
||||
import { Board, IPropertyOption, IPropertyTemplate, PropertyType } from "./blocks/board"
|
||||
import { BoardView, ISortOption } from "./blocks/boardView"
|
||||
import { Card } from "./blocks/card"
|
||||
import { ImageBlock } from "./blocks/imageBlock"
|
||||
import { BoardTree } from "./boardTree"
|
||||
import { BoardView, ISortOption } from "./boardView"
|
||||
import { Card } from "./card"
|
||||
import { FilterGroup } from "./filterGroup"
|
||||
import octoClient from "./octoClient"
|
||||
import { IBlock } from "./octoTypes"
|
||||
import { IBlock, IOrderedBlock } from "./octoTypes"
|
||||
import undoManager from "./undomanager"
|
||||
import { Utils } from "./utils"
|
||||
|
||||
|
@ -14,9 +15,6 @@ import { Utils } from "./utils"
|
|||
// It also ensures that the Undo-manager is called for each action
|
||||
//
|
||||
class Mutator {
|
||||
constructor() {
|
||||
}
|
||||
|
||||
async insertBlock(block: IBlock, description: string = "add", afterRedo?: () => Promise<void>, beforeUndo?: () => Promise<void>) {
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
|
@ -95,7 +93,7 @@ class Mutator {
|
|||
)
|
||||
}
|
||||
|
||||
async changeOrder(block: IBlock, order: number, description: string = "change order") {
|
||||
async changeOrder(block: IOrderedBlock, order: number, description: string = "change order") {
|
||||
const oldValue = block.order
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
|
@ -494,8 +492,9 @@ class Mutator {
|
|||
return undefined
|
||||
}
|
||||
|
||||
const block = new Block({ type: "image", parentId, order })
|
||||
block.fields.url = url
|
||||
const block = new ImageBlock({ parentId })
|
||||
block.order = order
|
||||
block.url = url
|
||||
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
|
@ -510,21 +509,21 @@ class Mutator {
|
|||
return block
|
||||
}
|
||||
|
||||
async undo() {
|
||||
await undoManager.undo()
|
||||
}
|
||||
async undo() {
|
||||
await undoManager.undo()
|
||||
}
|
||||
|
||||
undoDescription(): string | undefined {
|
||||
return undoManager.undoDescription
|
||||
}
|
||||
undoDescription(): string | undefined {
|
||||
return undoManager.undoDescription
|
||||
}
|
||||
|
||||
async redo() {
|
||||
await undoManager.redo()
|
||||
}
|
||||
async redo() {
|
||||
await undoManager.redo()
|
||||
}
|
||||
|
||||
redoDescription(): string | undefined {
|
||||
return undoManager.redoDescription
|
||||
}
|
||||
redoDescription(): string | undefined {
|
||||
return undoManager.redoDescription
|
||||
}
|
||||
}
|
||||
|
||||
const mutator = new Mutator()
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import { Card } from "./card"
|
||||
|
||||
// A block is the fundamental data type
|
||||
interface IBlock {
|
||||
id: string
|
||||
|
@ -8,7 +6,6 @@ interface IBlock {
|
|||
schema: number
|
||||
type: string
|
||||
title?: string
|
||||
order: number
|
||||
fields: Record<string, any>
|
||||
|
||||
createAt: number
|
||||
|
@ -16,6 +13,10 @@ interface IBlock {
|
|||
deleteAt: number
|
||||
}
|
||||
|
||||
interface IOrderedBlock extends IBlock {
|
||||
order: number
|
||||
}
|
||||
|
||||
// These are methods exposed by the top-level page to components
|
||||
interface IPageController {
|
||||
showBoard(boardId: string): void
|
||||
|
@ -24,4 +25,4 @@ interface IPageController {
|
|||
setSearchText(text?: string): void
|
||||
}
|
||||
|
||||
export { IBlock, IPageController }
|
||||
export { IBlock, IOrderedBlock, IPageController }
|
||||
|
|
|
@ -1,12 +1,16 @@
|
|||
import React from "react"
|
||||
import { IPropertyTemplate } from "./board"
|
||||
import { Block } from "./blocks/block"
|
||||
import { Board, IPropertyTemplate } from "./blocks/board"
|
||||
import { BoardView, ISortOption } from "./blocks/boardView"
|
||||
import { Card } from "./blocks/card"
|
||||
import { CommentBlock } from "./blocks/commentBlock"
|
||||
import { ImageBlock } from "./blocks/imageBlock"
|
||||
import { TextBlock } from "./blocks/textBlock"
|
||||
import { BoardTree } from "./boardTree"
|
||||
import { ISortOption } from "./boardView"
|
||||
import { Card } from "./card"
|
||||
import { Editable } from "./components/editable"
|
||||
import { Menu } from "./menu"
|
||||
import mutator from "./mutator"
|
||||
import { IBlock } from "./octoTypes"
|
||||
import { IBlock, IOrderedBlock } from "./octoTypes"
|
||||
import { Utils } from "./utils"
|
||||
|
||||
class OctoUtils {
|
||||
|
@ -103,7 +107,7 @@ class OctoUtils {
|
|||
return element
|
||||
}
|
||||
|
||||
static getOrderBefore(block: IBlock, blocks: IBlock[]): number {
|
||||
static getOrderBefore(block: IOrderedBlock, blocks: IOrderedBlock[]): number {
|
||||
const index = blocks.indexOf(block)
|
||||
if (index === 0) {
|
||||
return block.order / 2
|
||||
|
@ -112,7 +116,7 @@ class OctoUtils {
|
|||
return (block.order + previousBlock.order) / 2
|
||||
}
|
||||
|
||||
static getOrderAfter(block: IBlock, blocks: IBlock[]): number {
|
||||
static getOrderAfter(block: IOrderedBlock, blocks: IOrderedBlock[]): number {
|
||||
const index = blocks.indexOf(block)
|
||||
if (index === blocks.length - 1) {
|
||||
return block.order + 1000
|
||||
|
@ -151,6 +155,24 @@ class OctoUtils {
|
|||
}
|
||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
||||
}
|
||||
|
||||
static hydrateBlock(block: IBlock): Block {
|
||||
switch (block.type) {
|
||||
case "board": { return new Board(block) }
|
||||
case "view": { return new BoardView(block) }
|
||||
case "card": { return new Card(block) }
|
||||
case "text": { return new TextBlock(block) }
|
||||
case "image": { return new ImageBlock(block) }
|
||||
case "comment": { return new CommentBlock(block) }
|
||||
default: {
|
||||
Utils.assertFailure(`Can't hydrate unknown block type: ${block.type}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static hydrateBlocks(blocks: IBlock[]): Block[] {
|
||||
return blocks.map( block => this.hydrateBlock(block) )
|
||||
}
|
||||
}
|
||||
|
||||
export { OctoUtils }
|
||||
|
|
|
@ -1,15 +1,12 @@
|
|||
import React from "react"
|
||||
import ReactDOM from "react-dom"
|
||||
import { BoardView } from "../blocks/boardView"
|
||||
import { BoardTree } from "../boardTree"
|
||||
import { BoardView } from "../boardView"
|
||||
import { Card } from "../card"
|
||||
import { CardTree } from "../cardTree"
|
||||
import { CardDialog } from "../components/cardDialog"
|
||||
import { FilterComponent } from "../components/filterComponent"
|
||||
import { WorkspaceComponent } from "../components/workspaceComponent"
|
||||
import { FlashMessage } from "../flashMessage"
|
||||
import mutator from "../mutator"
|
||||
import octoClient from "../octoClient"
|
||||
import { OctoListener } from "../octoListener"
|
||||
import { Utils } from "../utils"
|
||||
import { WorkspaceTree } from "../workspaceTree"
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import React from 'react'
|
||||
import { Archiver } from "../archiver"
|
||||
import { Board } from "../board"
|
||||
import { Board } from "../blocks/board"
|
||||
import Button from '../components/button'
|
||||
import mutator from '../mutator'
|
||||
import octoClient from "../octoClient"
|
||||
import { IBlock } from "../octoTypes"
|
||||
import { Utils } from "../utils"
|
||||
|
|
27
src/client/pages/loginPage.scss
Normal file
27
src/client/pages/loginPage.scss
Normal file
|
@ -0,0 +1,27 @@
|
|||
.LoginPage {
|
||||
border: 1px solid #cccccc;
|
||||
border-radius: 15px;
|
||||
width: 450px;
|
||||
height: 400px;
|
||||
margin: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
.username, .password {
|
||||
margin-bottom: 5px;
|
||||
label {
|
||||
display: inline-block;
|
||||
width: 140px;
|
||||
}
|
||||
input {
|
||||
display: inline-block;
|
||||
width: 250px;
|
||||
border: 1px solid #cccccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
.Button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
|
@ -1,5 +1,9 @@
|
|||
import React from "react"
|
||||
|
||||
import Button from '../components/button'
|
||||
|
||||
import './loginPage.scss'
|
||||
|
||||
type Props = {}
|
||||
|
||||
type State = {
|
||||
|
@ -14,23 +18,30 @@ export default class LoginPage extends React.Component<Props, State> {
|
|||
}
|
||||
|
||||
handleLogin = () => {
|
||||
console.log("Logging in");
|
||||
console.log("Logging in")
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
render(): React.ReactNode {
|
||||
return (
|
||||
<div className='LoginPage'>
|
||||
<label htmlFor='login-username'>Username</label>
|
||||
<input
|
||||
id='login-username'
|
||||
value={this.state.username}
|
||||
onChange={(e) => this.setState({username: e.target.value})}
|
||||
/>
|
||||
<label htmlFor='login-username'>Password</label>
|
||||
<input
|
||||
id='login-password'
|
||||
/>
|
||||
<button onClick={this.handleLogin}>Login</button>
|
||||
<div className='username'>
|
||||
<label htmlFor='login-username'>Username</label>
|
||||
<input
|
||||
id='login-username'
|
||||
value={this.state.username}
|
||||
onChange={(e) => this.setState({username: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div className='password'>
|
||||
<label htmlFor='login-username'>Password</label>
|
||||
<input
|
||||
id='login-password'
|
||||
type='password'
|
||||
value={this.state.password}
|
||||
onChange={(e) => this.setState({password: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={this.handleLogin}>Login</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { IPropertyTemplate, PropertyType } from "./board"
|
||||
import { IPropertyTemplate, PropertyType } from "./blocks/board"
|
||||
import { Menu } from "./menu"
|
||||
import { Utils } from "./utils"
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
interface UndoCommand {
|
||||
checkpoint: number
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
undo: () => Promise<void>
|
||||
redo: () => Promise<void>
|
||||
description?: string
|
||||
}
|
||||
|
||||
|
@ -54,8 +54,8 @@ class UndoManager {
|
|||
}
|
||||
|
||||
async perform(
|
||||
redo: () => void,
|
||||
undo: () => void,
|
||||
redo: () => Promise<void>,
|
||||
undo: () => Promise<void>,
|
||||
description?: string,
|
||||
isDiscardable = false
|
||||
): Promise<UndoManager> {
|
||||
|
@ -64,7 +64,10 @@ class UndoManager {
|
|||
}
|
||||
|
||||
registerUndo(
|
||||
command: { undo: () => void; redo: () => void },
|
||||
command: {
|
||||
undo: () => Promise<void>,
|
||||
redo: () => Promise<void>
|
||||
},
|
||||
description?: string,
|
||||
isDiscardable = false
|
||||
): UndoManager {
|
||||
|
|
|
@ -117,6 +117,7 @@ class TextOption extends React.Component<TextOptionProps> {
|
|||
|
||||
type MenuProps = {
|
||||
children: React.ReactNode
|
||||
position?: 'top'|'bottom'
|
||||
}
|
||||
|
||||
export default class Menu extends React.Component<MenuProps> {
|
||||
|
@ -127,10 +128,11 @@ export default class Menu extends React.Component<MenuProps> {
|
|||
static Text = TextOption
|
||||
|
||||
render() {
|
||||
const {position, children} = this.props
|
||||
return (
|
||||
<div className="Menu menu noselect">
|
||||
<div className={"Menu menu noselect " + (position ? position : "bottom")}>
|
||||
<div className="menu-options">
|
||||
{this.props.children}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -82,7 +82,7 @@ export default class MenuWrapper extends React.PureComponent<Props, State> {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={'MenuWrapper'}
|
||||
className={'MenuWrapper menu-wrapper'}
|
||||
onClick={this.toggle}
|
||||
ref={this.node}
|
||||
>
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
import { Board } from "./board"
|
||||
import { Block } from "./blocks/block"
|
||||
import { Board } from "./blocks/board"
|
||||
import octoClient from "./octoClient"
|
||||
import { IBlock } from "./octoTypes"
|
||||
import { OctoUtils } from "./octoUtils"
|
||||
|
||||
class WorkspaceTree {
|
||||
boards: Board[] = []
|
||||
|
||||
async sync() {
|
||||
const blocks = await octoClient.getBlocks(undefined, "board")
|
||||
this.rebuild(blocks)
|
||||
this.rebuild(OctoUtils.hydrateBlocks(blocks))
|
||||
}
|
||||
|
||||
private rebuild(blocks: IBlock[]) {
|
||||
const boardBlocks = blocks.filter(block => block.type === "board")
|
||||
this.boards = boardBlocks.map(o => new Board(o))
|
||||
private rebuild(blocks: Block[]) {
|
||||
this.boards = blocks.filter(block => block.type === "board") as Board[]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -329,6 +329,14 @@ hr {
|
|||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.menu.top {
|
||||
bottom: 100%;
|
||||
}
|
||||
|
||||
.menu-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menu-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -336,6 +344,8 @@ hr {
|
|||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
color: rgb(55, 53, 47);
|
||||
}
|
||||
|
||||
.menu-option {
|
||||
|
@ -454,7 +464,7 @@ hr {
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.octo-frame .octo-icontitle .octo-icon {
|
||||
.octo-frame > .octo-icontitle .octo-icon {
|
||||
font-size: 36px;
|
||||
line-height: 36px;
|
||||
margin-right: 15px;
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
"no-bitwise": true,
|
||||
"no-conditional-assignment": true,
|
||||
"no-consecutive-blank-lines": true,
|
||||
"indent": [true, "tabs"],
|
||||
"indent": [false, "tabs"],
|
||||
"member-access": [true, "no-public"],
|
||||
"semicolon": [true, "never"],
|
||||
"variable-name": [
|
||||
|
|
|
@ -26,12 +26,12 @@ function makeCommonConfig() {
|
|||
loader: "file-loader",
|
||||
},
|
||||
{
|
||||
test: /\.s[ac]ss$/i,
|
||||
use: [
|
||||
'style-loader',
|
||||
'css-loader',
|
||||
'sass-loader',
|
||||
],
|
||||
test: /\.s[ac]ss$/i,
|
||||
use: [
|
||||
'style-loader',
|
||||
'css-loader',
|
||||
'sass-loader',
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.(tsx?|js|jsx|html)$/,
|
||||
|
@ -60,14 +60,15 @@ function makeCommonConfig() {
|
|||
title: "OCTO",
|
||||
chunks: ["main"],
|
||||
template: "html-templates/page.ejs",
|
||||
filename: 'index.html'
|
||||
filename: 'index.html',
|
||||
publicPath: '/'
|
||||
}),
|
||||
],
|
||||
entry: {
|
||||
main: "./src/client/main.tsx",
|
||||
},
|
||||
output: {
|
||||
filename: "[name].js",
|
||||
filename: "static/[name].js",
|
||||
path: outpath
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue