Automatic SQL updates

This commit is contained in:
Magnus Åhall 2023-06-18 22:05:10 +02:00
parent 1812873e33
commit 85a2d0683e
8 changed files with 331 additions and 24 deletions

55
db.go
View File

@ -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)
if err != nil {
panic(err)
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 {
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
View File

@ -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
View File

@ -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

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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;
}
}