Automatic SQL updates
This commit is contained in:
parent
1812873e33
commit
85a2d0683e
53
db.go
53
db.go
@ -6,17 +6,22 @@ import (
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
// Standard
|
||||
_ "database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var (
|
||||
dbConn string
|
||||
db *sqlx.DB
|
||||
|
||||
//go:embed sql/*
|
||||
embedded embed.FS
|
||||
)
|
||||
|
||||
func dbInit() {
|
||||
func dbInit() (err error) {
|
||||
dbConn = fmt.Sprintf(
|
||||
"host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
|
||||
config.Database.Host,
|
||||
@ -32,11 +37,51 @@ func dbInit() {
|
||||
config.Database.Port,
|
||||
)
|
||||
|
||||
var err error
|
||||
db, err = sqlx.Connect("postgres", dbConn)
|
||||
return
|
||||
}
|
||||
|
||||
func dbUpdate() (err error) {
|
||||
/* Current schema revision is read from database.
|
||||
* Used to iterate through the embedded SQL updates
|
||||
* up to the DB_SCHEMA version currently compiled
|
||||
* program is made for. */
|
||||
var rows *sqlx.Rows
|
||||
var schemaStr string
|
||||
var schema int
|
||||
rows, err = db.Queryx(`SELECT value FROM _internal.db WHERE "key"='schema'`)
|
||||
if err != nil { return }
|
||||
defer rows.Close()
|
||||
|
||||
if !rows.Next() {
|
||||
return errors.New("Table _interval.db missing schema row")
|
||||
}
|
||||
|
||||
if err = rows.Scan(&schemaStr); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Run updates
|
||||
schema, err = strconv.Atoi(schemaStr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return err
|
||||
}
|
||||
for i := (schema+1); i <= DB_SCHEMA; i++ {
|
||||
log.Printf("\x1b[32mNotes\x1b[0m Upgrading SQL schema to revision %d\n", i)
|
||||
sql, _ := embedded.ReadFile(
|
||||
fmt.Sprintf("sql/%04d.sql", i),
|
||||
)
|
||||
_, err = db.Exec(string(sql))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, err = db.Exec(`UPDATE _internal.db SET "value"=$1 WHERE "key"='schema'`, i)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// vim: foldmethod=marker
|
||||
|
69
main.go
69
main.go
@ -12,10 +12,12 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
const VERSION = "v0.0.5";
|
||||
const LISTEN_HOST = "0.0.0.0";
|
||||
const DB_SCHEMA = 2
|
||||
|
||||
var (
|
||||
flagPort int
|
||||
@ -49,7 +51,15 @@ func main() {// {{{
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
dbInit()
|
||||
if err = dbInit(); err != nil {
|
||||
fmt.Printf("%s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err = dbUpdate(); err != nil {
|
||||
fmt.Printf("%s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
connectionManager = NewConnectionManager()
|
||||
go connectionManager.BroadcastLoop()
|
||||
|
||||
@ -61,6 +71,8 @@ func main() {// {{{
|
||||
http.HandleFunc("/node/retrieve", nodeRetrieve)
|
||||
http.HandleFunc("/node/create", nodeCreate)
|
||||
http.HandleFunc("/node/update", nodeUpdate)
|
||||
http.HandleFunc("/node/rename", nodeRename)
|
||||
http.HandleFunc("/node/delete", nodeDelete)
|
||||
http.HandleFunc("/ws", websocketHandler)
|
||||
http.HandleFunc("/", staticHandler)
|
||||
|
||||
@ -262,6 +274,61 @@ func nodeUpdate(w http.ResponseWriter, r *http.Request) {// {{{
|
||||
"OK": true,
|
||||
})
|
||||
}// }}}
|
||||
func nodeRename(w http.ResponseWriter, r *http.Request) {// {{{
|
||||
var err error
|
||||
var session Session
|
||||
|
||||
if session, _, err = ValidateSession(r, true); err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := struct {
|
||||
NodeID int
|
||||
Name string
|
||||
}{}
|
||||
if err = parseRequest(r, &req); err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = session.RenameNode(req.NodeID, req.Name)
|
||||
if err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
responseData(w, map[string]interface{}{
|
||||
"OK": true,
|
||||
})
|
||||
}// }}}
|
||||
func nodeDelete(w http.ResponseWriter, r *http.Request) {// {{{
|
||||
var err error
|
||||
var session Session
|
||||
|
||||
if session, _, err = ValidateSession(r, true); err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := struct {
|
||||
NodeID int
|
||||
}{}
|
||||
if err = parseRequest(r, &req); err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = session.DeleteNode(req.NodeID)
|
||||
if err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
responseData(w, map[string]interface{}{
|
||||
"OK": true,
|
||||
})
|
||||
}// }}}
|
||||
|
||||
func newTemplate(requestPath string) (tmpl *template.Template, err error) {// {{{
|
||||
// Append index.html if needed for further reading of the file
|
||||
|
35
node.go
35
node.go
@ -223,5 +223,40 @@ func (session Session) UpdateNode(nodeID int, content string) (err error) {// {{
|
||||
)
|
||||
return
|
||||
}// }}}
|
||||
func (session Session) RenameNode(nodeID int, name string) (err error) {// {{{
|
||||
_, err = db.Exec(`
|
||||
UPDATE node SET name = $1 WHERE user_id = $2 AND id = $3
|
||||
`,
|
||||
name,
|
||||
session.UserID,
|
||||
nodeID,
|
||||
)
|
||||
return
|
||||
}// }}}
|
||||
func (session Session) DeleteNode(nodeID int) (err error) {// {{{
|
||||
_, err = db.Exec(`
|
||||
WITH RECURSIVE nodetree AS (
|
||||
SELECT
|
||||
id, parent_id
|
||||
FROM node
|
||||
WHERE
|
||||
user_id = $1 AND id = $2
|
||||
|
||||
UNION
|
||||
|
||||
SELECT
|
||||
n.id, n.parent_id
|
||||
FROM node n
|
||||
INNER JOIN nodetree nt ON n.parent_id = nt.id
|
||||
)
|
||||
|
||||
DELETE FROM node WHERE id IN (
|
||||
SELECT id FROM nodetree
|
||||
)`,
|
||||
session.UserID,
|
||||
nodeID,
|
||||
)
|
||||
return
|
||||
}// }}}
|
||||
|
||||
// vim: foldmethod=marker
|
||||
|
@ -8,9 +8,8 @@
|
||||
margin-bottom: 32px;
|
||||
width: 100%;
|
||||
border: 0px;
|
||||
border-bottom: 1px solid #fff;
|
||||
border-bottom: 1px solid #444;
|
||||
font-size: 18pt;
|
||||
color: #fff;
|
||||
background-color: #fff;
|
||||
}
|
||||
#login input:focus {
|
||||
@ -20,11 +19,14 @@
|
||||
max-width: 300px;
|
||||
border: 1px solid #666;
|
||||
background: #fff;
|
||||
color: #fff;
|
||||
color: #444;
|
||||
padding: 16px 32px;
|
||||
font-size: 0.8em;
|
||||
font-size: 1em;
|
||||
align-self: center;
|
||||
}
|
||||
#login button:hover {
|
||||
background: #ddd;
|
||||
}
|
||||
#login .auth-failed {
|
||||
margin-top: 32px;
|
||||
color: #a00;
|
||||
|
@ -24,9 +24,48 @@ h1 {
|
||||
display: grid;
|
||||
color: #fff;
|
||||
}
|
||||
#menu-blackout {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
top: 0px;
|
||||
bottom: 0px;
|
||||
z-index: 1024;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
#menu-blackout.show {
|
||||
display: initial;
|
||||
}
|
||||
#menu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
border: 2px solid #000;
|
||||
box-shadow: 5px 5px 8px 0px rgba(0, 0, 0, 0.5);
|
||||
z-index: 1025;
|
||||
}
|
||||
#menu.show {
|
||||
display: initial;
|
||||
}
|
||||
#menu .item {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #aaa;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
#menu .item:hover {
|
||||
background: #ddd;
|
||||
}
|
||||
#menu .item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr min-content;
|
||||
grid-template-columns: 1fr min-content min-content;
|
||||
align-items: center;
|
||||
background: #ecbf00;
|
||||
padding: 0px;
|
||||
@ -45,6 +84,13 @@ header .add {
|
||||
padding-right: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
header .menu {
|
||||
font-size: 1.25em;
|
||||
padding-right: 16px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.crumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@ -98,7 +144,7 @@ header .add {
|
||||
}
|
||||
.node-content {
|
||||
justify-self: center;
|
||||
padding: 0px 32px 32px 32px;
|
||||
padding: 0px 32px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.85em;
|
||||
color: #333;
|
||||
@ -107,9 +153,13 @@ header .add {
|
||||
.node-content[contenteditable] {
|
||||
outline: 0px solid transparent;
|
||||
}
|
||||
.node-content:empty {
|
||||
background: #ddd;
|
||||
height: 48px;
|
||||
}
|
||||
@media only screen and (max-width: 100ex) {
|
||||
.node-content {
|
||||
width: initial;
|
||||
width: 100%;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ const html = htm.bind(h)
|
||||
export class NodeUI extends Component {
|
||||
constructor() {//{{{
|
||||
super()
|
||||
this.menu = signal(false)
|
||||
this.node = signal(null)
|
||||
this.nodeContent = createRef()
|
||||
window.addEventListener('popstate', evt=>{
|
||||
@ -42,14 +43,26 @@ export class NodeUI extends Component {
|
||||
modified = 'modified';
|
||||
|
||||
return html`
|
||||
<div id="menu-blackout" class="${this.menu.value ? 'show' : ''}" onclick=${()=>this.menu.value = false}></div>
|
||||
<div id="menu" class="${this.menu.value ? 'show' : ''}">
|
||||
<div class="item" onclick=${()=>this.renameNode()}>Rename</div>
|
||||
<div class="item" onclick=${()=>this.deleteNode()}>Delete</div>
|
||||
</div>
|
||||
|
||||
<header class="${modified}" onclick=${()=>this.saveNode()}>
|
||||
<div class="name">Notes</div>
|
||||
<div class="add" onclick=${()=>this.createNode()}>+</div>
|
||||
<div class="menu" onclick=${()=>this.showMenu()}>☰</div>
|
||||
</header>
|
||||
|
||||
<div class="crumbs">${crumbs}</crumbs>
|
||||
|
||||
${children.length > 0 ? html`<div class="child-nodes">${children}</div>` : html``}
|
||||
|
||||
${node.ID > 0 ? html`
|
||||
<div class="node-name">${node.Name}</div>
|
||||
<${NodeContent} key=${node.ID} content=${node.Content} ref=${this.nodeContent} />
|
||||
` : html``}
|
||||
`
|
||||
}//}}}
|
||||
componentDidMount() {//{{{
|
||||
@ -61,6 +74,11 @@ export class NodeUI extends Component {
|
||||
this.node.value = node
|
||||
})
|
||||
}//}}}
|
||||
|
||||
showMenu() {//{{{
|
||||
this.menu.value = true
|
||||
}//}}}
|
||||
|
||||
goToNode(nodeID, dontPush) {//{{{
|
||||
if(this.props.app.nodeModified.value) {
|
||||
if(!confirm("Changes not saved. Do you want to discard changes?"))
|
||||
@ -89,7 +107,7 @@ export class NodeUI extends Component {
|
||||
})
|
||||
.catch(this.props.app.responseError)
|
||||
}//}}}
|
||||
saveNode() {
|
||||
saveNode() {//{{{
|
||||
let content = this.nodeContent.current.contentDiv.current.innerText
|
||||
this.props.app.request('/node/update', {
|
||||
NodeID: this.node.value.ID,
|
||||
@ -99,7 +117,35 @@ export class NodeUI extends Component {
|
||||
this.props.app.nodeModified.value = false
|
||||
})
|
||||
.catch(this.props.app.responseError)
|
||||
}
|
||||
}//}}}
|
||||
renameNode() {//{{{
|
||||
let name = prompt("New name")
|
||||
if(!name)
|
||||
return
|
||||
|
||||
this.props.app.request('/node/rename', {
|
||||
Name: name.trim(),
|
||||
NodeID: this.node.value.ID,
|
||||
})
|
||||
.then(_=>{
|
||||
this.goToNode(this.node.value.ID)
|
||||
this.menu.value = false
|
||||
})
|
||||
.catch(this.props.app.responseError)
|
||||
}//}}}
|
||||
deleteNode() {//{{{
|
||||
if(!confirm("Do you want to delete this note and all sub-notes?"))
|
||||
return
|
||||
|
||||
this.props.app.request('/node/delete', {
|
||||
NodeID: this.node.value.ID,
|
||||
})
|
||||
.then(_=>{
|
||||
this.goToNode(this.node.value.ParentID)
|
||||
this.menu.value = false
|
||||
})
|
||||
.catch(this.props.app.responseError)
|
||||
}//}}}
|
||||
}
|
||||
|
||||
class NodeContent extends Component {
|
||||
|
@ -10,10 +10,9 @@
|
||||
margin-bottom: 32px;
|
||||
width: 100%;
|
||||
border: 0px;
|
||||
border-bottom: 1px solid #fff;
|
||||
border-bottom: 1px solid #444;
|
||||
|
||||
font-size: 18pt;
|
||||
color: #fff;
|
||||
background-color: @background;
|
||||
|
||||
&:focus {
|
||||
@ -25,10 +24,14 @@
|
||||
max-width: 300px;
|
||||
border: 1px solid #666;
|
||||
background: @background;
|
||||
color: #fff;
|
||||
color: #444;
|
||||
padding: 16px 32px;
|
||||
font-size: 0.8em;
|
||||
font-size: 1em;
|
||||
align-self: center;
|
||||
|
||||
&:hover {
|
||||
background: #ddd;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-failed {
|
||||
|
@ -30,9 +30,55 @@ h1 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#menu-blackout {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
top: 0px;
|
||||
bottom: 0px;
|
||||
z-index: 1024;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
|
||||
&.show {
|
||||
display: initial;
|
||||
}
|
||||
}
|
||||
|
||||
#menu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
border: 2px solid #000;
|
||||
box-shadow: 5px 5px 8px 0px rgba(0,0,0,0.5);
|
||||
z-index: 1025;
|
||||
|
||||
&.show {
|
||||
display: initial;
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #aaa;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr min-content;
|
||||
grid-template-columns: 1fr min-content min-content;
|
||||
align-items: center;
|
||||
background: @accent_1;
|
||||
padding: 0px;
|
||||
@ -53,6 +99,14 @@ header {
|
||||
padding-right: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu {
|
||||
font-size: 1.25em;
|
||||
padding-right: 16px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.crumbs {
|
||||
@ -117,7 +171,7 @@ header {
|
||||
|
||||
.node-content {
|
||||
justify-self: center;
|
||||
padding: 0px 32px 32px 32px;
|
||||
padding: 0px 32px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.85em;
|
||||
color: #333;
|
||||
@ -126,11 +180,16 @@ header {
|
||||
&[contenteditable] {
|
||||
outline: 0px solid transparent;
|
||||
}
|
||||
|
||||
&:empty {
|
||||
background: #ddd;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 100ex) {
|
||||
.node-content {
|
||||
width: initial;
|
||||
width: 100%;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user