Compare commits

...

49 commits
v16 ... main

Author SHA1 Message Date
Magnus Åhall
60f20a754f Removed unnecessary debug log 2024-04-19 18:44:12 +02:00
Magnus Åhall
c132f495b4 Added back a deleted URL character 2024-04-19 18:42:41 +02:00
Magnus Åhall
bcce516c66 Fixed calendar aspect ratio to my liking 2024-04-19 18:36:41 +02:00
Magnus Åhall
e9967ebdc6 Bumped to v29 2024-04-19 18:32:44 +02:00
Magnus Åhall
0a6eaed89f #8, added fullcalendar 2024-04-19 18:32:37 +02:00
Magnus Åhall
1a9d532d02 Fixed #7 2024-04-19 15:35:16 +02:00
d9fa6fd477 Update README.md 2024-04-19 13:34:30 +00:00
Magnus Åhall
8039dfaf42 Bumped to v28 2024-04-18 20:08:25 +02:00
Magnus Åhall
0dcc1e1fd9 Fixed bug deleting all schedules 2024-04-18 20:08:07 +02:00
Magnus Åhall
9d45d87ef3 #6, initial continuous adding of items 2024-04-18 07:47:35 +02:00
Magnus Åhall
2c16d7af60 Removed unnecessary logging 2024-04-17 18:48:25 +02:00
Magnus Åhall
b0496c8de1 Bumped to v27 2024-04-17 18:44:22 +02:00
Magnus Åhall
3b8c6432b6 Event schedule 2024-04-17 18:43:24 +02:00
Magnus Åhall
d186489f28 Fix crumbs shadow over node tree 2024-04-05 09:31:41 +02:00
Magnus Åhall
6a757c94b0 Fixed automatic CSS refresh 2024-04-05 09:27:47 +02:00
Magnus Åhall
3669b7e6ec Added wrappederror explicit logging 2024-04-05 09:01:59 +02:00
Magnus Åhall
566cff5e94 Smaller ui changes 2024-04-05 09:01:59 +02:00
Magnus Åhall
83e1ce5ffe Added SQL 2024-04-05 09:01:54 +02:00
Magnus Åhall
48c1227d9f wip 2024-04-05 08:59:59 +02:00
Magnus Åhall
49fd943110 Added timezone to user 2024-04-03 17:37:32 +02:00
Magnus Åhall
f9f083367e Fixed #1 2024-03-30 22:26:26 +01:00
Magnus Åhall
eea0b1c0f6 Bumped to v26 2024-03-30 17:59:15 +01:00
Magnus Åhall
90b73edc34 Fixed CORS 2024-03-30 17:58:51 +01:00
Magnus Åhall
6782362af1 Bumped to v25 2024-03-30 17:43:34 +01:00
Magnus Åhall
c352176417 Implemented reminder 2024-03-30 17:43:21 +01:00
Magnus Åhall
d89d63803f Bumped to v24 2024-03-30 09:57:25 +01:00
Magnus Åhall
27e493945a Better timezone handling 2024-03-30 09:46:48 +01:00
Magnus Åhall
aee1c25f54 Bumped to v23 2024-03-29 20:54:51 +01:00
Magnus Åhall
7de9feea58 Restore minute-based schedule 2024-03-29 20:54:42 +01:00
Magnus Åhall
a6c94ac7ca Bumped to v22 2024-03-29 20:25:59 +01:00
Magnus Åhall
afcadc8ae1 Implemented rudimentary notification with NTFY and acknowledgement 2024-03-29 20:24:53 +01:00
Magnus Åhall
c2555a1d35 Added files 2024-03-29 08:06:23 +01:00
Magnus Åhall
e9ce21133a Schedule and notification 2024-03-28 21:49:48 +01:00
Magnus Åhall
fd01e751e2 Bumped to v21 2024-01-13 10:47:02 +01:00
Magnus Åhall
08f2344ef9 Added UI elements for toggling checklist 2024-01-13 10:46:52 +01:00
Magnus Åhall
44f5d92815 Move item in checklist 2024-01-13 10:01:10 +01:00
Magnus Åhall
d0f410323e Checklist management without reordering 2024-01-12 21:31:32 +01:00
Magnus Åhall
dc2b6dac8b Checkbox item visualizes error 2024-01-10 23:28:00 +01:00
Magnus Åhall
f98a6ab863 Added checklists to database, rendering and toggling items 2024-01-10 23:19:40 +01:00
Magnus Åhall
5c27f9ed1c Bumped to v20 2024-01-09 18:03:30 +01:00
Magnus Åhall
d3bc5e3f7f Modified Alt+Shift+S to also save node properties 2024-01-09 18:03:12 +01:00
Magnus Åhall
8768ab1692 Added highlight to markdown code. Added markdown icon to differentiate against markdown on/off. 2024-01-09 17:58:41 +01:00
Magnus Åhall
a3155c5882 Bumped to v19 2024-01-09 17:30:58 +01:00
Magnus Åhall
60c6d4eaba Added markdown property save for unencrypted notes 2024-01-09 17:30:52 +01:00
Magnus Åhall
51502bd694 Bumped to v18 2024-01-09 17:20:02 +01:00
Magnus Åhall
39c26f77bc Fixed config for upload path 2024-01-09 17:19:54 +01:00
Magnus Åhall
02c427da23 Fixed orphaned markdown elements 2024-01-09 17:15:05 +01:00
Magnus Åhall
1e42be3a4b Bumped to v17 2024-01-09 16:59:28 +01:00
Magnus Åhall
12a908cde8 Fixed CSS wrapping header icons 2024-01-09 16:59:17 +01:00
38 changed files with 3131 additions and 116 deletions

View file

@ -8,6 +8,10 @@ Create an empty database. The configured user needs to be able to create and alt
Create a configuration file `$HOME/.config/notes.yaml` with the following content:
```yaml
network:
address: '[::]'
port: 1371
websocket:
domains:
- notes.com

View file

@ -28,6 +28,7 @@ type Config struct {
Static string
Upload string
}
NotificationBaseURL string
}
Session struct {

13
db.go Normal file
View file

@ -0,0 +1,13 @@
package main
import (
// Standard
"database/sql"
)
// Queryable can take both a Db and a transaction
type Queryable interface {
Exec(string, ...any) (sql.Result, error)
Query(string, ...any) (*sql.Rows, error)
QueryRow(string, ...any) *sql.Row
}

1
go.mod
View file

@ -4,6 +4,7 @@ go 1.21.0
require (
git.gibonuddevalla.se/go/webservice v0.2.2
git.gibonuddevalla.se/go/wrappederror v0.3.3
github.com/google/uuid v1.5.0
github.com/gorilla/websocket v1.5.0
github.com/jmoiron/sqlx v1.3.5

2
go.sum
View file

@ -2,6 +2,8 @@ git.gibonuddevalla.se/go/dbschema v1.3.0 h1:HzFMR29tWfy/ibIjltTbIMI4inVktj/rh8bE
git.gibonuddevalla.se/go/dbschema v1.3.0/go.mod h1:BNw3q/574nXbGoeWyK+tLhRfggVkw2j2aXZzrBKC3ig=
git.gibonuddevalla.se/go/webservice v0.2.2 h1:pmfeLa7c9pSPbuu6TuzcJ6yuVwdMLJ8SSPm1IkusThk=
git.gibonuddevalla.se/go/webservice v0.2.2/go.mod h1:3uBS6nLbK9qbuGzDls8MZD5Xr9ORY1Srbj6v06BIhws=
git.gibonuddevalla.se/go/wrappederror v0.3.3 h1:pdIy3/daSY3zMmUr9PXW6ffIt8iYonOv64mgJBpKz+0=
git.gibonuddevalla.se/go/wrappederror v0.3.3/go.mod h1:j4w320Hk1wvhOPjUaK4GgLvmtnjUUM5yVu6JFO1OCSc=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=

343
main.go
View file

@ -3,14 +3,17 @@ package main
import (
// External
"git.gibonuddevalla.se/go/webservice"
"git.gibonuddevalla.se/go/wrappederror"
// Internal
"git.gibonuddevalla.se/go/webservice/session"
"notes/notification"
// Standard
"crypto/md5"
"embed"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
@ -20,6 +23,7 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
)
const LISTEN_HOST = "0.0.0.0"
@ -31,12 +35,14 @@ var (
flagCheckLocal bool
flagConfig string
service *webservice.Service
connectionManager ConnectionManager
static http.Handler
config Config
logger *slog.Logger
VERSION string
service *webservice.Service
connectionManager ConnectionManager
notificationManager notification.Manager
static http.Handler
config Config
logger *slog.Logger
schedulers map[int]Schedule
VERSION string
//go:embed version sql/*
embeddedSQL embed.FS
@ -45,7 +51,7 @@ var (
staticFS embed.FS
)
func sqlProvider(dbname string, version int) (sql []byte, found bool) {
func sqlProvider(dbname string, version int) (sql []byte, found bool) { // {{{
var err error
sql, err = embeddedSQL.ReadFile(fmt.Sprintf("sql/%05d.sql", version))
if err != nil {
@ -53,7 +59,27 @@ func sqlProvider(dbname string, version int) (sql []byte, found bool) {
}
found = true
return
}
} // }}}
func logCallback(e WrappedError.Error) { // {{{
now := time.Now()
out := struct {
Year int
Month int
Day int
Time string
Error error
}{now.Year(), int(now.Month()), now.Day(), now.Format("15:04:05"), e}
j, _ := json.Marshal(out)
file, err := os.OpenFile("/tmp/notes.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
logger.Error("log", "error", err)
return
}
file.Write(j)
file.Write([]byte("\n"))
file.Close()
} // }}}
func init() { // {{{
version, _ := embeddedSQL.ReadFile("version")
@ -63,6 +89,8 @@ func init() { // {{{
opt.Level = slog.LevelDebug
logger = slog.New(slog.NewJSONHandler(os.Stdout, &opt))
schedulers = make(map[int]Schedule, 512)
configFilename := os.Getenv("HOME") + "/.config/notes.yaml"
flag.IntVar(&flagPort, "port", 1371, "TCP port to listen on")
flag.BoolVar(&flagVersion, "version", false, "Shows Notes version and exists")
@ -72,6 +100,9 @@ func init() { // {{{
flag.Parse()
} // }}}
func main() { // {{{
WrappedError.Init()
WrappedError.SetLogCallback(logCallback)
var err error
if flagVersion {
@ -80,6 +111,12 @@ func main() { // {{{
}
logger.Info("application", "version", VERSION)
config, err = ConfigRead(flagConfig)
if err != nil {
logger.Error("application", "error", err)
os.Exit(1)
}
service, err = webservice.New(flagConfig, VERSION, logger)
if err != nil {
logger.Error("application", "error", err)
@ -98,9 +135,19 @@ func main() { // {{{
service.Register("/node/delete", true, true, nodeDelete)
service.Register("/node/download", true, true, nodeDownload)
service.Register("/node/search", true, true, nodeSearch)
service.Register("/node/checklist_group/add", true, true, nodeChecklistGroupAdd)
service.Register("/node/checklist_group/item_add", true, true, nodeChecklistGroupItemAdd)
service.Register("/node/checklist_group/label", true, true, nodeChecklistGroupLabel)
service.Register("/node/checklist_group/delete", true, true, nodeChecklistGroupDelete)
service.Register("/node/checklist_item/state", true, true, nodeChecklistItemState)
service.Register("/node/checklist_item/label", true, true, nodeChecklistItemLabel)
service.Register("/node/checklist_item/delete", true, true, nodeChecklistItemDelete)
service.Register("/node/checklist_item/move", true, true, nodeChecklistItemMove)
service.Register("/key/retrieve", true, true, keyRetrieve)
service.Register("/key/create", true, true, keyCreate)
service.Register("/key/counter", true, true, keyCounter)
service.Register("/notification/ack", false, false, notificationAcknowledge)
service.Register("/schedule/list", true, true, scheduleList)
service.Register("/", false, false, service.StaticHandler)
if flagCreateUser {
@ -108,6 +155,19 @@ func main() { // {{{
os.Exit(0)
}
go scheduleHandler()
if err = service.InitDatabaseConnection(); err != nil {
logger.Error("application", "error", err)
os.Exit(1)
}
err = InitNotificationManager()
if err != nil {
logger.Error("application", "error", err)
os.Exit(1)
}
err = service.Start()
if err != nil {
logger.Error("webserver", "error", err)
@ -115,39 +175,6 @@ func main() { // {{{
}
} // }}}
/*
func userPassword(w http.ResponseWriter, r *http.Request) { // {{{
var err error
var ok bool
var session Session
if session, _, err = ValidateSession(r, true); err != nil {
responseError(w, err)
return
}
req := struct {
CurrentPassword string
NewPassword string
}{}
if err = parseRequest(r, &req); err != nil {
responseError(w, err)
return
}
ok, err = session.UpdatePassword(req.CurrentPassword, req.NewPassword)
if err != nil {
responseError(w, err)
return
}
responseData(w, map[string]interface{}{
"OK": true,
"CurrentPasswordOK": ok,
})
} // }}}
*/
func nodeTree(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
logger.Info("webserver", "request", "/node/tree")
var err error
@ -223,13 +250,14 @@ func nodeUpdate(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{
Content string
CryptoKeyID int
Markdown bool
TimeOffset int
}{}
if err = parseRequest(r, &req); err != nil {
responseError(w, err)
return
}
err = UpdateNode(sess.UserID, req.NodeID, req.Content, req.CryptoKeyID, req.Markdown)
err = UpdateNode(sess.UserID, req.NodeID, req.TimeOffset, req.Content, req.CryptoKeyID, req.Markdown)
if err != nil {
responseError(w, err)
return
@ -471,6 +499,190 @@ func nodeSearch(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{
"Nodes": nodes,
})
} // }}}
func nodeChecklistGroupAdd(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
var err error
req := struct {
NodeID int
Label string
}{}
if err = parseRequest(r, &req); err != nil {
responseError(w, err)
return
}
var group ChecklistGroup
group, err = ChecklistGroupAdd(sess.UserID, req.NodeID, req.Label)
if err != nil {
responseError(w, err)
return
}
group.Items = []ChecklistItem{}
responseData(w, map[string]interface{}{
"OK": true,
"Group": group,
})
} // }}}
func nodeChecklistGroupItemAdd(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
var err error
req := struct {
ChecklistGroupID int
Label string
}{}
if err = parseRequest(r, &req); err != nil {
responseError(w, err)
return
}
var item ChecklistItem
item, err = ChecklistGroupItemAdd(sess.UserID, req.ChecklistGroupID, req.Label)
if err != nil {
responseError(w, err)
return
}
responseData(w, map[string]interface{}{
"OK": true,
"Item": item,
})
} // }}}
func nodeChecklistGroupLabel(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
var err error
req := struct {
ChecklistGroupID int
Label string
}{}
if err = parseRequest(r, &req); err != nil {
responseError(w, err)
return
}
var item ChecklistItem
item, err = ChecklistGroupLabel(sess.UserID, req.ChecklistGroupID, req.Label)
if err != nil {
responseError(w, err)
return
}
responseData(w, map[string]interface{}{
"OK": true,
"Item": item,
})
} // }}}
func nodeChecklistGroupDelete(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
var err error
req := struct {
ChecklistGroupID int
}{}
if err = parseRequest(r, &req); err != nil {
responseError(w, err)
return
}
err = ChecklistGroupDelete(sess.UserID, req.ChecklistGroupID)
if err != nil {
logger.Error("checklist", "error", err)
responseError(w, err)
return
}
responseData(w, map[string]interface{}{
"OK": true,
})
} // }}}
func nodeChecklistItemState(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
var err error
req := struct {
ChecklistItemID int
State bool
}{}
if err = parseRequest(r, &req); err != nil {
responseError(w, err)
return
}
err = ChecklistItemState(sess.UserID, req.ChecklistItemID, req.State)
if err != nil {
responseError(w, err)
return
}
responseData(w, map[string]interface{}{
"OK": true,
})
} // }}}
func nodeChecklistItemLabel(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
var err error
req := struct {
ChecklistItemID int
Label string
}{}
if err = parseRequest(r, &req); err != nil {
responseError(w, err)
return
}
err = ChecklistItemLabel(sess.UserID, req.ChecklistItemID, req.Label)
if err != nil {
responseError(w, err)
return
}
responseData(w, map[string]interface{}{
"OK": true,
})
} // }}}
func nodeChecklistItemDelete(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
var err error
req := struct {
ChecklistItemID int
}{}
if err = parseRequest(r, &req); err != nil {
responseError(w, err)
return
}
err = ChecklistItemDelete(sess.UserID, req.ChecklistItemID)
if err != nil {
logger.Error("checklist", "error", err)
responseError(w, err)
return
}
responseData(w, map[string]interface{}{
"OK": true,
})
} // }}}
func nodeChecklistItemMove(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
var err error
req := struct {
ChecklistItemID int
AfterItemID int
}{}
if err = parseRequest(r, &req); err != nil {
responseError(w, err)
return
}
err = ChecklistItemMove(sess.UserID, req.ChecklistItemID, req.AfterItemID)
if err != nil {
logger.Error("checklist", "error", err)
responseError(w, err)
return
}
responseData(w, map[string]interface{}{
"OK": true,
})
} // }}}
func keyRetrieve(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
logger.Info("webserver", "request", "/key/retrieve")
@ -528,4 +740,51 @@ func keyCounter(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{
})
} // }}}
func notificationAcknowledge(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
logger.Info("webserver", "request", "/notification/ack")
var err error
w.Header().Add("Access-Control-Allow-Origin", "*")
err = AcknowledgeNotification(r.URL.Query().Get("uuid"))
if err != nil {
responseError(w, err)
return
}
responseData(w, map[string]interface{}{
"OK": true,
})
} // }}}
func scheduleList(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
logger.Info("webserver", "request", "/schedule/list")
var err error
w.Header().Add("Access-Control-Allow-Origin", "*")
request := struct {
NodeID int
StartDate time.Time
EndDate time.Time
}{}
body, _ := io.ReadAll(r.Body)
if len(body) > 0 {
err = json.Unmarshal(body, &request)
if err != nil {
responseError(w, err)
return
}
}
var schedules []Schedule
schedules, err = FutureSchedules(sess.UserID, request.NodeID, request.StartDate, request.EndDate)
if err != nil {
responseError(w, err)
return
}
responseData(w, map[string]interface{}{
"OK": true,
"ScheduleEvents": schedules,
})
} // }}}
// vim: foldmethod=marker

388
node.go
View file

@ -3,11 +3,29 @@ package main
import (
// External
"github.com/jmoiron/sqlx"
werr "git.gibonuddevalla.se/go/wrappederror"
// Standard
"time"
"database/sql"
)
type ChecklistItem struct {
ID int
GroupID int `db:"checklist_group_id"`
Order int
Label string
Checked bool
}
type ChecklistGroup struct {
ID int
NodeID int `db:"node_id"`
Order int
Label string
Items []ChecklistItem
}
type Node struct {
ID int
UserID int `db:"user_id"`
@ -22,6 +40,8 @@ type Node struct {
Complete bool
Level int
ChecklistGroups []ChecklistGroup
ContentEncrypted string `db:"content_encrypted" json:"-"`
Markdown bool
}
@ -211,6 +231,8 @@ func RetrieveNode(userID, nodeID int) (node Node, err error) { // {{{
} else {
node.Content = row.Content
}
node.retrieveChecklist()
}
if row.Level == 1 {
@ -302,9 +324,82 @@ func CreateNode(userID, parentID int, name string) (node Node, err error) { // {
node.Crumbs, err = NodeCrumbs(node.ID)
return
} // }}}
func UpdateNode(userID, nodeID int, content string, cryptoKeyID int, markdown bool) (err error) { // {{{
func UpdateNode(userID, nodeID, timeOffset int, content string, cryptoKeyID int, markdown bool) (err error) { // {{{
if nodeID == 0 {
return
}
var timezone string
row := service.Db.Conn.QueryRow(`SELECT timezone FROM _webservice.user WHERE id=$1`, userID)
err = row.Scan(&timezone)
if err != nil {
err = werr.Wrap(err).WithCode("002-000F")
return
}
var scannedSchedules, dbSchedules, add, remove []Schedule
scannedSchedules = ScanForSchedules(timezone, content)
for i := range scannedSchedules {
scannedSchedules[i].Node.ID = nodeID
scannedSchedules[i].UserID = userID
}
var tsx *sql.Tx
tsx, err = service.Db.Conn.Begin()
if err != nil {
return
}
dbSchedules, err = RetrieveSchedules(userID, nodeID)
if err != nil {
tsx.Rollback()
return
}
for _, scanned := range scannedSchedules {
found := false
for _, db := range dbSchedules {
if scanned.IsEqual(db) {
found = true
break
}
}
if !found {
add = append(add, scanned)
}
}
for _, db := range dbSchedules {
found := false
for _, scanned := range scannedSchedules {
if db.IsEqual(scanned) {
found = true
break
}
}
if !found {
remove = append(remove, db)
}
}
for _, event := range remove {
err = event.Delete(tsx)
if err != nil {
tsx.Rollback()
return
}
}
for _, event := range add {
err = event.Insert(tsx)
if err != nil {
tsx.Rollback()
return
}
}
if cryptoKeyID > 0 {
_, err = service.Db.Conn.Exec(`
_, err = tsx.Exec(`
UPDATE node
SET
content = '',
@ -325,11 +420,12 @@ func UpdateNode(userID, nodeID int, content string, cryptoKeyID int, markdown bo
markdown,
)
} else {
_, err = service.Db.Conn.Exec(`
_, err = tsx.Exec(`
UPDATE node
SET
content = $1,
content_encrypted = '',
markdown = $5,
crypto_key_id = CASE $2::int
WHEN 0 THEN NULL
ELSE $2
@ -342,8 +438,15 @@ func UpdateNode(userID, nodeID int, content string, cryptoKeyID int, markdown bo
cryptoKeyID,
nodeID,
userID,
markdown,
)
}
if err != nil {
tsx.Rollback()
return
}
err = tsx.Commit()
return
} // }}}
@ -420,4 +523,283 @@ func SearchNodes(userID int, search string) (nodes []Node, err error) { // {{{
return
} // }}}
func ChecklistGroupAdd(userID, nodeID int, label string) (item ChecklistGroup, err error) { // {{{
var row *sqlx.Row
row = service.Db.Conn.QueryRowx(
`
INSERT INTO checklist_group(node_id, "order", "label")
(
SELECT
$1,
MAX("order")+1 AS "order",
$2 AS "label"
FROM checklist_group g
INNER JOIN node n ON g.node_id = n.id
WHERE
user_id = $3 AND
node_id = $1
GROUP BY
node_id
) UNION (
SELECT
node.id AS node_id,
0 AS "order",
$2 AS "label"
FROM node
WHERE
user_id = $3 AND
node.id = $1
)
ORDER BY "order" DESC
LIMIT 1
RETURNING
*
`,
nodeID,
label,
userID,
)
err = row.StructScan(&item)
return
} // }}}
func ChecklistGroupLabel(userID, checklistGroupID int, label string) (item ChecklistItem, err error) { // {{{
_, err = service.Db.Conn.Exec(
`
UPDATE checklist_group g
SET label = $3
FROM node n
WHERE
g.node_id = n.id AND
n.user_id = $1 AND
g.id = $2;
`,
userID,
checklistGroupID,
label,
)
return
} // }}}
func ChecklistGroupItemAdd(userID, checklistGroupID int, label string) (item ChecklistItem, err error) { // {{{
var row *sqlx.Row
row = service.Db.Conn.QueryRowx(
`
INSERT INTO checklist_item(checklist_group_id, "order", "label")
(
SELECT
checklist_group_id,
MAX("order")+1 AS "order",
$1 AS "label"
FROM checklist_item
WHERE
checklist_group_id = $2
GROUP BY
checklist_group_id
) UNION (
SELECT $2 AS checklist_group_id, 0 AS "order", $1 AS "label"
)
ORDER BY "order" DESC
LIMIT 1
RETURNING
*
`,
label,
checklistGroupID,
)
err = row.StructScan(&item)
return
} // }}}
func ChecklistGroupDelete(userID, checklistGroupID int) (err error) { // {{{
_, err = service.Db.Conn.Exec(
`
DELETE
FROM checklist_group g
USING
node n
WHERE
g.id = $2 AND
g.node_id = n.id AND
n.user_id = $1
`,
userID,
checklistGroupID,
)
return
} // }}}
func ChecklistItemState(userID, checklistItemID int, state bool) (err error) { // {{{
_, err = service.Db.Conn.Exec(
`
UPDATE checklist_item i
SET checked = $3
FROM checklist_group g, node n
WHERE
i.checklist_group_id = g.id AND
g.node_id = n.id AND
n.user_id = $1 AND
i.id = $2;
`,
userID,
checklistItemID,
state,
)
return
} // }}}
func ChecklistItemLabel(userID, checklistItemID int, label string) (err error) { // {{{
_, err = service.Db.Conn.Exec(
`
UPDATE checklist_item i
SET label = $3
FROM checklist_group g, node n
WHERE
i.checklist_group_id = g.id AND
g.node_id = n.id AND
n.user_id = $1 AND
i.id = $2;
`,
userID,
checklistItemID,
label,
)
return
} // }}}
func ChecklistItemDelete(userID, checklistItemID int) (err error) { // {{{
_, err = service.Db.Conn.Exec(
`
DELETE
FROM checklist_item i
USING
checklist_group g,
node n
WHERE
i.id = $2 AND
i.checklist_group_id = g.id AND
g.node_id = n.id AND
n.user_id = $1
`,
userID,
checklistItemID,
)
return
} // }}}
func ChecklistItemMove(userID, checklistItemID, afterItemID int) (err error) { // {{{
_, err = service.Db.Conn.Exec(
`
WITH
"to" AS (
SELECT
i.checklist_group_id AS group_id,
i."order"
FROM checklist_item i
INNER JOIN checklist_group g ON i.checklist_group_id = g.id
INNER JOIN node n ON g.node_id = n.id
WHERE
n.user_id = $1 AND
i.id = $3
),
update_order AS (
UPDATE checklist_item
SET
"order" =
CASE
WHEN checklist_item."order" <= "to"."order" THEN checklist_item."order" - 1
WHEN checklist_item."order" > "to"."order" THEN checklist_item."order" + 1
END
FROM "to"
WHERE
checklist_item.id != $2 AND
checklist_item.checklist_group_id = "to".group_id
)
UPDATE checklist_item
SET
checklist_group_id = "to".group_id,
"order" = "to"."order"
FROM "to"
WHERE
checklist_item.id = $2
`,
userID,
checklistItemID,
afterItemID,
)
return
} // }}}
func (node *Node) retrieveChecklist() (err error) { // {{{
var rows *sqlx.Rows
rows, err = service.Db.Conn.Queryx(`
SELECT
g.id AS group_id,
g.order AS group_order,
g.label AS group_label,
COALESCE(i.id, 0) AS item_id,
COALESCE(i.order, 0) AS item_order,
COALESCE(i.label, '') AS item_label,
COALESCE(i.checked, false) AS checked
FROM public.checklist_group g
LEFT JOIN public.checklist_item i ON i.checklist_group_id = g.id
WHERE
g.node_id = $1
ORDER BY
g.order DESC,
i.order DESC
`, node.ID)
if err != nil {
return
}
defer rows.Close()
groups := make(map[int]*ChecklistGroup)
var found bool
var group *ChecklistGroup
var item ChecklistItem
for rows.Next() {
row := struct {
GroupID int `db:"group_id"`
GroupOrder int `db:"group_order"`
GroupLabel string `db:"group_label"`
ItemID int `db:"item_id"`
ItemOrder int `db:"item_order"`
ItemLabel string `db:"item_label"`
Checked bool
}{}
err = rows.StructScan(&row)
if err != nil {
return
}
if group, found = groups[row.GroupID]; !found {
group = new(ChecklistGroup)
group.ID = row.GroupID
group.NodeID = node.ID
group.Order = row.GroupOrder
group.Label = row.GroupLabel
group.Items = []ChecklistItem{}
groups[group.ID] = group
}
item = ChecklistItem{}
item.ID = row.ItemID
item.GroupID = row.GroupID
item.Order = row.ItemOrder
item.Label = row.ItemLabel
item.Checked = row.Checked
if item.ID > 0 {
group.Items = append(group.Items, item)
}
}
node.ChecklistGroups = []ChecklistGroup{}
for _, group := range groups {
node.ChecklistGroups = append(node.ChecklistGroups, *group)
}
return
} // }}}
// vim: foldmethod=marker

15
notification/factory.go Normal file
View file

@ -0,0 +1,15 @@
package notification
import (
// External
werr "git.gibonuddevalla.se/go/wrappederror"
)
func ServiceFactory(t string, config []byte, prio int, ackURL string) (Service, error) {
switch t {
case "NTFY":
return NewNTFY(config, prio, ackURL)
}
return nil, werr.New("Unknown notification service, '%s'", t).WithCode("002-0000")
}

72
notification/ntfy.go Normal file
View file

@ -0,0 +1,72 @@
package notification
import (
// External
werr "git.gibonuddevalla.se/go/wrappederror"
// Standard
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
type NTFY struct {
URL string
Prio int
AcknowledgeURL string
}
func NewNTFY(config []byte, prio int, ackURL string) (instance NTFY, err error) {
err = json.Unmarshal(config, &instance)
if err != nil {
err = werr.Wrap(err).WithCode("002-0001").WithData(config)
return
}
instance.Prio = prio
instance.AcknowledgeURL = ackURL
return instance, nil
}
func (ntfy NTFY) GetPrio() int {
return ntfy.Prio
}
func (ntfy NTFY) Send(uuid string, msg []byte) (err error) {
var req *http.Request
var res *http.Response
req, err = http.NewRequest("POST", ntfy.URL, bytes.NewReader(msg))
if err != nil {
err = werr.Wrap(err).WithCode("002-0002").WithData(ntfy.URL)
return
}
ackURL := fmt.Sprintf("http, OK, %s/notification/ack?uuid=%s", ntfy.AcknowledgeURL, uuid)
req.Header.Add("X-Actions", ackURL)
req.Header.Add("X-Priority", "5")
req.Header.Add("X-Tags", "calendar")
res, err = http.DefaultClient.Do(req)
if err != nil {
err = werr.Wrap(err).WithCode("002-0003")
return
}
body, _ := io.ReadAll(res.Body)
if res.StatusCode != 200 {
err = werr.New("Invalid NTFY response").WithCode("002-0004").WithData(body)
return
}
ntfyResp := struct {
ID string
}{}
err = json.Unmarshal(body, &ntfyResp)
if err != nil {
err = werr.Wrap(err).WithCode("002-0005").WithData(body)
return
}
return
}

60
notification/pkg.go Normal file
View file

@ -0,0 +1,60 @@
package notification
import (
// External
werr "git.gibonuddevalla.se/go/wrappederror"
// Standard
_ "fmt"
"slices"
)
type Service interface {
GetPrio() int
Send(string, []byte) error
}
type Manager struct {
services map[int][]Service
}
func NewManager() (nm Manager) {
nm.services = make(map[int][]Service, 32)
return
}
func (nm *Manager) AddService(userID int, service Service) {
var services []Service
var found bool
if services, found = nm.services[userID]; !found {
services = []Service{}
}
services = append(services, service)
slices.SortFunc(services, func(a, b Service) int {
if a.GetPrio() < b.GetPrio() {
return -1
}
if a.GetPrio() > b.GetPrio() {
return 1
}
return 0
})
nm.services[userID] = services
}
func (nm *Manager) Send(userID int, uuid string, msg []byte) (err error) {
services, found := nm.services[userID]
if !found {
return werr.New("No notification services defined for user ID %d", userID).WithCode("002-0008")
}
for _, service := range services {
if err = service.Send(uuid, msg); err == nil {
break
}
}
return
}

75
notification_manager.go Normal file
View file

@ -0,0 +1,75 @@
package main
import (
// External
werr "git.gibonuddevalla.se/go/wrappederror"
// Internal
"notes/notification"
// Standard
"database/sql"
"encoding/json"
)
type DbNotificationService struct {
ID int
UserID int `json:"user_id"`
Service string
Configuration string
Prio int
}
func InitNotificationManager() (err error) {// {{{
var dbServices []DbNotificationService
var row *sql.Row
row = service.Db.Conn.QueryRow(`
WITH services AS (
SELECT
id,
user_id,
prio,
service,
configuration::varchar
FROM notification n
ORDER BY
user_id ASC,
prio ASC
)
SELECT COALESCE(jsonb_agg(s.*), '[]')
FROM services s
`,
)
var dbData []byte
err = row.Scan(&dbData)
if err != nil {
err = werr.Wrap(err).WithCode("002-0006")
return
}
err = json.Unmarshal(dbData, &dbServices)
if err != nil {
err = werr.Wrap(err).WithCode("002-0007")
return
}
notificationManager = notification.NewManager()
var service notification.Service
for _, dbService := range dbServices {
service, err = notification.ServiceFactory(
dbService.Service,
[]byte(dbService.Configuration),
dbService.Prio,
config.Application.NotificationBaseURL,
)
notificationManager.AddService(dbService.UserID, service)
}
return
}// }}}
func AcknowledgeNotification(uuid string) (err error) {// {{{
_, err = service.Db.Conn.Exec(`UPDATE schedule SET acknowledged=true WHERE schedule_uuid=$1`, uuid)
return
}// }}}

View file

@ -1,6 +1,9 @@
package main
import (
// External
werr "git.gibonuddevalla.se/go/wrappederror"
// Standard
"encoding/json"
"io"
@ -18,6 +21,8 @@ func responseError(w http.ResponseWriter, err error) {
}
resJSON, _ := json.Marshal(res)
werr.Wrap(err).Log()
w.Header().Add("Content-Type", "application/json")
w.Write(resJSON)
}

289
schedule.go Normal file
View file

@ -0,0 +1,289 @@
package main
import (
// External
werr "git.gibonuddevalla.se/go/wrappederror"
// Standard
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
func init() {
fmt.Printf("")
}
type Schedule struct {
ID int
UserID int `json:"user_id" db:"user_id"`
Node Node
ScheduleUUID string `db:"schedule_uuid"`
Time time.Time
RemindMinutes int `db:"remind_minutes"`
Description string
Acknowledged bool
}
func scheduleHandler() { // {{{
// Wait for the approximate minute.
wait := 60000 - time.Now().Sub(time.Now().Truncate(time.Minute)).Milliseconds()
logger.Info("schedule", "wait", wait/1000)
time.Sleep(time.Millisecond * time.Duration(wait))
tick := time.NewTicker(time.Minute)
for {
schedules := ExpiredSchedules()
for _, event := range schedules {
notificationManager.Send(
event.UserID,
event.ScheduleUUID,
[]byte(
fmt.Sprintf(
"%s\n%s",
event.Time.Format("2006-01-02 15:04"),
event.Description,
),
),
)
}
<-tick.C
}
} // }}}
func ScanForSchedules(timezone string, content string) (schedules []Schedule) { // {{{
schedules = []Schedule{}
rxp := regexp.MustCompile(`\{\s*([0-9]{4}-[0-9]{2}-[0-9]{2}\s+[0-9]{2}:[0-9]{2}(?::[0-9]{2})?)\s*\,\s*(?:(\d+)\s*(h|min)\s*,)?\s*([^\]]+?)\s*\}`)
foundSchedules := rxp.FindAllStringSubmatch(content, -1)
for _, data := range foundSchedules {
// Missing seconds
if strings.Count(data[1], ":") == 1 {
data[1] = data[1] + ":00"
}
logger.Info("time", "parse", data[1])
location, err := time.LoadLocation(timezone)
if err != nil {
err = werr.Wrap(err).WithCode("002-00010")
return
}
timestamp, err := time.ParseInLocation("2006-01-02 15:04:05", data[1], location)
if err != nil {
continue
}
// Reminder
var remindMinutes int
if data[2] != "" && data[3] != "" {
value, _ := strconv.Atoi(data[2])
unit := strings.ToLower(data[3])
switch unit {
case "min":
remindMinutes = value
case "h":
remindMinutes = value * 60
}
}
schedule := Schedule{
Time: timestamp,
RemindMinutes: remindMinutes,
Description: data[4],
}
schedules = append(schedules, schedule)
}
return
} // }}}
func RetrieveSchedules(userID int, nodeID int) (schedules []Schedule, err error) { // {{{
schedules = []Schedule{}
res := service.Db.Conn.QueryRow(`
WITH schedule_events AS (
SELECT
s.id,
s.user_id,
json_build_object('id', s.node_id) AS node,
s.schedule_uuid,
(time - MAKE_INTERVAL(mins => s.remind_minutes)) AT TIME ZONE u.timezone AS time,
s.description,
s.acknowledged
FROM schedule s
INNER JOIN _webservice.user u ON s.user_id = u.id
WHERE
user_id=$1 AND
CASE
WHEN $2 > 0 THEN node_id = $2
ELSE true
END
)
SELECT
COALESCE(jsonb_agg(s.*), '[]'::jsonb)
FROM schedule_events s
`,
userID,
nodeID,
)
var data []byte
err = res.Scan(&data)
if err != nil {
err = werr.Wrap(err).WithCode("002-000E")
return
}
err = json.Unmarshal(data, &schedules)
return
} // }}}
func (a Schedule) IsEqual(b Schedule) bool { // {{{
return a.UserID == b.UserID &&
a.Node.ID == b.Node.ID &&
a.Time.Equal(b.Time) &&
a.RemindMinutes == b.RemindMinutes &&
a.Description == b.Description
} // }}}
func (s *Schedule) Insert(queryable Queryable) error { // {{{
res := queryable.QueryRow(`
INSERT INTO schedule(user_id, node_id, time, remind_minutes, description)
VALUES($1, $2, $3, $4, $5)
RETURNING id
`,
s.UserID,
s.Node.ID,
s.Time.Format("2006-01-02 15:04:05"),
s.RemindMinutes,
s.Description,
)
err := res.Scan(&s.ID)
if err != nil {
err = werr.Wrap(err).WithCode("002-000D")
}
return err
} // }}}
func (s *Schedule) Delete(queryable Queryable) error { // {{{
_, err := queryable.Exec(`
DELETE FROM schedule
WHERE
user_id = $1 AND
id = $2
`,
s.UserID,
s.ID,
)
return err
} // }}}
func ExpiredSchedules() (schedules []Schedule) { // {{{
schedules = []Schedule{}
res, err := service.Db.Conn.Queryx(`
SELECT
s.id,
s.user_id,
s.node_id,
s.schedule_uuid,
(s.time - MAKE_INTERVAL(mins => s.remind_minutes)) AT TIME ZONE u.timezone AS time,
s.description
FROM schedule s
INNER JOIN _webservice.user u ON s.user_id = u.id
WHERE
(time - MAKE_INTERVAL(mins => remind_minutes)) AT TIME ZONE u.timezone < NOW() AND
NOT acknowledged
ORDER BY
time ASC
`)
if err != nil {
err = werr.Wrap(err).WithCode("002-0009")
return
}
defer res.Close()
for res.Next() {
s := Schedule{}
if err = res.Scan(&s.ID, &s.UserID, &s.Node.ID, &s.ScheduleUUID, &s.Time, &s.Description); err != nil {
werr.Wrap(err).WithCode("002-000a")
continue
}
schedules = append(schedules, s)
}
return
} // }}}
func FutureSchedules(userID int, nodeID int, start time.Time, end time.Time) (schedules []Schedule, err error) { // {{{
schedules = []Schedule{}
var foo string
row := service.Db.Conn.QueryRow(`SELECT TO_CHAR($1::date AT TIME ZONE 'UTC', 'yyyy-mm-dd HH24:MI')`, start)
err = row.Scan(&foo)
if err != nil {
return
}
logger.Info("FOO", "date", foo)
res := service.Db.Conn.QueryRow(`
WITH schedule_events AS (
SELECT
s.id,
s.user_id,
jsonb_build_object(
'id', n.id,
'name', n.name,
'updated', n.updated
) AS node,
s.schedule_uuid,
time AT TIME ZONE u.timezone AS time,
s.description,
s.acknowledged,
s.remind_minutes AS RemindMinutes
FROM schedule s
INNER JOIN _webservice.user u ON s.user_id = u.id
INNER JOIN node n ON s.node_id = n.id
WHERE
s.user_id = $1 AND
(
CASE
WHEN $2 > 0 THEN n.id = $2
ELSE true
END
) AND (
CASE WHEN TO_CHAR($3::date, 'yyyy-mm-dd HH24:MI') = '0001-01-01 00:00' THEN TRUE
ELSE (s.time AT TIME ZONE u.timezone) >= $3
END
) AND (
CASE WHEN TO_CHAR($4::date, 'yyyy-mm-dd HH24:MI') = '0001-01-01 00:00' THEN TRUE
ELSE (s.time AT TIME ZONE u.timezone) <= $4
END
) AND
time >= NOW() AND
NOT acknowledged
)
SELECT
COALESCE(jsonb_agg(s.*), '[]'::jsonb)
FROM schedule_events s
`,
userID,
nodeID,
start,
end,
)
var j []byte
err = res.Scan(&j)
if err != nil {
err = werr.Wrap(err).WithCode("002-000B").WithData(userID)
return
}
err = json.Unmarshal(j, &schedules)
if err != nil {
err = werr.Wrap(err).WithCode("002-0010").WithData(string(j)).Log()
return
}
return
} // }}}

18
sql/00014.sql Normal file
View file

@ -0,0 +1,18 @@
CREATE TABLE checklist_group (
id serial NOT NULL,
node_id int4 NOT NULL,
"order" int NOT NULL DEFAULT 0,
label varchar NOT NULL,
CONSTRAINT checklist_group_pk PRIMARY KEY (id),
CONSTRAINT checklist_group_node_fk FOREIGN KEY (node_id) REFERENCES public."node"(id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE checklist_item (
id serial NOT NULL,
checklist_group_id int4 NOT NULL,
"order" int NOT NULL DEFAULT 0,
label varchar NOT NULL,
checked bool NOT NULL DEFAULT false,
CONSTRAINT checklist_item_pk PRIMARY KEY (id),
CONSTRAINT checklist_group_item_fk FOREIGN KEY (checklist_group_id) REFERENCES public."checklist_group"(id) ON DELETE CASCADE ON UPDATE CASCADE
)

14
sql/00015.sql Normal file
View file

@ -0,0 +1,14 @@
CREATE TABLE public.schedule (
id SERIAL NOT NULL,
user_id INT4 NOT NULL,
node_id INT4 NOT NULL,
schedule_uuid CHAR(36) DEFAULT GEN_RANDOM_UUID() NOT NULL,
"time" TIMESTAMP NOT NULL,
description VARCHAR DEFAULT '' NOT NULL,
acknowledged BOOL DEFAULT false NOT NULL,
CONSTRAINT schedule_pk PRIMARY KEY (id),
CONSTRAINT schedule_uuid UNIQUE (schedule_uuid),
CONSTRAINT schedule_node_fk FOREIGN KEY (node_id) REFERENCES public.node(id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT schedule_user_fk FOREIGN KEY (user_id) REFERENCES "_webservice"."user"(id) ON DELETE CASCADE ON UPDATE CASCADE
);

1
sql/00016.sql Normal file
View file

@ -0,0 +1 @@
ALTER TABLE public.schedule ADD CONSTRAINT schedule_event UNIQUE (user_id, node_id, "time", description);

11
sql/00017.sql Normal file
View file

@ -0,0 +1,11 @@
CREATE TABLE public.notification (
id SERIAl NOT NULL,
user_id INT4 NOT NULL,
service VARCHAR DEFAULT 'NTFY' NOT NULL,
"configuration" JSONB DEFAULT '{}' NOT NULL,
prio INT DEFAULT 0 NOT NULL,
CONSTRAINT notification_pk PRIMARY KEY (id),
CONSTRAINT notification_unique UNIQUE (user_id,prio),
CONSTRAINT notification_user_fk FOREIGN KEY (user_id) REFERENCES "_webservice"."user"(id) ON DELETE CASCADE ON UPDATE CASCADE
);

2
sql/00018.sql Normal file
View file

@ -0,0 +1,2 @@
ALTER TABLE public.schedule ALTER COLUMN "time" TYPE timestamptz USING "time"::timestamptz;

1
sql/00019.sql Normal file
View file

@ -0,0 +1 @@
ALTER TABLE public.schedule ADD COLUMN remind_minutes int NOT NULL DEFAULT 0;

2
sql/00020.sql Normal file
View file

@ -0,0 +1,2 @@
ALTER TABLE _webservice."user" ADD timezone varchar DEFAULT 'UTC' NOT NULL;
ALTER TABLE public.schedule ALTER COLUMN "time" TYPE timestamp USING "time"::timestamp;

1
sql/00021.sql Normal file
View file

@ -0,0 +1 @@
ALTER TABLE public.node ALTER COLUMN updated TYPE timestamptz USING updated::timestamptz;

View file

@ -13,9 +13,17 @@ html {
*:after {
box-sizing: inherit;
}
*,
*:focus,
*:hover {
outline: none;
}
[onClick] {
cursor: pointer;
}
label {
user-select: none;
}
html,
body {
margin: 0px;
@ -26,12 +34,16 @@ body {
height: 100%;
}
h1 {
margin-top: 0px;
font-size: 1.5em;
font-size: 1.25em;
color: #518048;
border-bottom: 1px solid #ccc;
}
h2 {
margin-top: 32px;
font-size: 1.25em;
font-size: 1em;
color: #518048;
}
h3 {
font-size: 1em;
}
button {
font-size: 1em;
@ -141,10 +153,15 @@ button {
#properties .key label {
margin-left: 8px;
}
#properties .checks {
display: grid;
grid-template-columns: min-content 1fr;
grid-gap: 4px 8px;
}
header {
display: grid;
grid-area: header;
grid-template-columns: min-content 1fr repeat(4, min-content);
grid-template-columns: min-content 1fr repeat(6, min-content);
align-items: center;
padding: 8px 0px;
color: #333c11;
@ -169,12 +186,14 @@ header .name {
font-size: 1.25em;
}
header .markdown,
header .checklist,
header .search,
header .add,
header .keys {
padding-right: 16px;
}
header .markdown img,
header .checklist img,
header .search img,
header .add img,
header .keys img {
@ -193,6 +212,7 @@ header .menu {
padding: 16px;
background-color: #333;
color: #ddd;
z-index: 100;
}
#tree .node {
display: grid;
@ -307,10 +327,15 @@ header .menu {
padding-top: 16px;
}
#markdown {
padding: 16px;
color: #333;
grid-area: content;
justify-self: center;
width: calc(100% - 32px);
max-width: 900px;
padding: 0.5rem;
border-radius: 8px;
margin-top: 8px;
margin-bottom: 0px;
}
#markdown table {
border-collapse: collapse;
@ -320,6 +345,155 @@ header .menu {
border: 1px solid #ddd;
padding: 4px 8px;
}
#markdown code {
background: #e6eeee;
padding: 4px;
border-radius: 4px;
}
#markdown pre {
background: #f5f5f5;
padding: 8px;
border-radius: 8px;
}
#markdown pre > code {
background: unset;
padding: 0px;
border-radius: 0px;
}
#checklist {
grid-area: checklist;
color: #333;
justify-self: center;
width: calc(100% - 32px);
max-width: 900px;
padding: 0.5rem;
border-radius: 8px;
margin-top: 8px;
margin-bottom: 0px;
}
#checklist .header {
display: grid;
grid-template-columns: repeat(3, min-content);
align-items: center;
grid-gap: 0 16px;
}
#checklist .header img {
height: 20px;
cursor: pointer;
}
#checklist .header + .checklist-group.edit {
margin-top: 16px;
}
#checklist .checklist-group {
display: grid;
grid-template-columns: repeat(4, min-content);
align-items: center;
grid-gap: 0 8px;
margin-top: 1em;
margin-bottom: 8px;
font-weight: bold;
}
#checklist .checklist-group .label {
white-space: nowrap;
}
#checklist .checklist-group .label.ok {
color: #54b356;
}
#checklist .checklist-group .label.error {
color: #d13636;
}
#checklist .checklist-group.edit {
margin-top: 32px;
border-bottom: 1px solid #aaa;
padding-bottom: 8px;
}
#checklist .checklist-group:not(.edit) .reorder {
display: none;
}
#checklist .checklist-group:not(.edit) img {
display: none;
}
#checklist .checklist-group img {
height: 14px;
cursor: pointer;
}
#checklist .checklist-item {
transform: translate(0, 0);
display: grid;
grid-template-columns: repeat(3, min-content);
grid-gap: 0 8px;
align-items: center;
padding: 4px 0;
border-bottom: 2px solid #fff;
}
#checklist .checklist-item.checked {
text-decoration: line-through;
color: #aaa;
}
#checklist .checklist-item.drag-target {
border-bottom: 2px solid #71c837;
}
#checklist .checklist-item input[type="checkbox"] {
-webkit-appearance: none;
appearance: none;
background-color: #fff;
margin: 0 2px 0 0;
font: inherit;
color: currentColor;
width: 1.25em;
height: 1.25em;
border: 0.15em solid currentColor;
border-radius: 0.15em;
transform: translateY(-0.075em);
display: grid;
place-content: center;
}
#checklist .checklist-item label.ok {
color: #54b356;
}
#checklist .checklist-item label.error {
color: #d13636;
}
#checklist .checklist-item input[type="checkbox"].ok {
border: 0.15em solid #54b356;
}
#checklist .checklist-item input[type="checkbox"].ok::before {
box-shadow: inset 1em 1em #54b356;
}
#checklist .checklist-item input[type="checkbox"].error {
border: 0.15em solid #d13636;
}
#checklist .checklist-item input[type="checkbox"].error::before {
box-shadow: inset 1em 1em #d13636;
}
#checklist .checklist-item input[type="checkbox"]::before {
content: "";
width: 0.7em;
height: 0.7em;
transform: scale(0);
transition: 120ms transform ease-in-out;
box-shadow: inset 1em 1em #666;
}
#checklist .checklist-item input[type="checkbox"]:checked::before {
transform: scale(1);
}
#checklist .checklist-item.edit input[type="checkbox"] {
margin-left: 8px;
}
#checklist .checklist-item:not(.edit) .reorder {
display: none;
}
#checklist .checklist-item:not(.edit) img {
display: none;
}
#checklist .checklist-item img {
height: 14px;
cursor: pointer;
}
#checklist .checklist-item label {
user-select: none;
white-space: nowrap;
}
/* ============================================================= *
* Textarea replicates the height of an element expanding height *
* ============================================================= */
@ -358,7 +532,7 @@ header .menu {
grid-area: 1 / 1 / 2 / 2;
}
/* ============================================================= */
#file-section {
#schedule-section {
grid-area: files;
justify-self: center;
width: calc(100% - 32px);
@ -368,6 +542,23 @@ header .menu {
border-radius: 8px;
margin-top: 32px;
margin-bottom: 32px;
color: #000;
}
#schedule-section .header {
font-weight: bold;
color: #000;
margin-bottom: 16px;
}
#file-section {
grid-area: schedule;
justify-self: center;
width: calc(100% - 32px);
max-width: 900px;
padding: 32px;
background: #f5f5f5;
border-radius: 8px;
margin-top: 32px;
margin-bottom: 16px;
}
#file-section .header {
font-weight: bold;
@ -466,9 +657,9 @@ header .menu {
}
.layout-tree {
display: grid;
grid-template-areas: "header header" "tree crumbs" "tree child-nodes" "tree name" "tree content" "tree files" "tree blank";
grid-template-areas: "header header" "tree crumbs" "tree child-nodes" "tree name" "tree content" "tree checklist" "tree schedule" "tree files" "tree blank";
grid-template-columns: min-content 1fr;
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* files */ 1fr;
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* schedule */ min-content /* files */ 1fr;
/* blank */
color: #fff;
min-height: 100%;
@ -501,14 +692,17 @@ header .menu {
display: block;
}
.layout-crumbs {
grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "files" "blank";
grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "schedule" "files" "blank";
grid-template-columns: 1fr;
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* files */ 1fr;
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* schedule */ min-content /* files */ 1fr;
/* blank */
}
.layout-crumbs #tree {
display: none;
}
.layout-crumbs #checklist {
padding: 16px;
}
.layout-keys {
display: grid;
grid-template-areas: "header" "keys";
@ -547,22 +741,25 @@ header .menu {
}
#app.node {
display: grid;
grid-template-areas: "header header" "tree crumbs" "tree child-nodes" "tree name" "tree content" "tree files" "tree blank";
grid-template-areas: "header header" "tree crumbs" "tree child-nodes" "tree name" "tree content" "tree checklist" "tree schedule" "tree files" "tree blank";
grid-template-columns: min-content 1fr;
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* files */ 1fr;
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* schedule */ min-content /* files */ 1fr;
/* blank */
color: #fff;
min-height: 100%;
}
#app.node.toggle-tree {
grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "files" "blank";
grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "schedule" "files" "blank";
grid-template-columns: 1fr;
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* files */ 1fr;
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* schedule */ min-content /* files */ 1fr;
/* blank */
}
#app.node.toggle-tree #tree {
display: none;
}
#app.node.toggle-tree #checklist {
padding: 16px;
}
#profile-settings {
color: #333;
padding: 16px;
@ -576,16 +773,80 @@ header .menu {
#profile-settings .passwords div {
white-space: nowrap;
}
#schedule-events {
display: grid;
grid-template-columns: repeat(5, min-content);
grid-gap: 4px 12px;
margin: 32px;
color: #000;
white-space: nowrap;
}
#schedule-events .header {
font-weight: bold;
}
#input-text {
border: 1px solid #000 !important;
padding: 16px;
width: 300px;
}
#input-text .label {
margin-bottom: 4px;
}
#input-text input[type=text] {
width: 100%;
padding: 4px;
}
#input-text .buttons {
display: grid;
grid-template-columns: 1fr 64px 64px;
grid-gap: 8px;
margin-top: 8px;
}
#fullcalendar {
margin: 32px;
color: #444;
}
.folder .tabs {
border-left: 1px solid #888;
display: flex;
}
.folder .tabs .tab {
padding: 16px 32px;
border-top: 1px solid #888;
border-bottom: 1px solid #888;
border-right: 1px solid #888;
color: #444;
background: #eee;
cursor: pointer;
}
.folder .tabs .tab.selected {
border-bottom: none;
background: #fff;
}
.folder .tabs .hack {
border-bottom: 1px solid #888;
width: 100%;
}
.folder .content {
padding-top: 1px;
border-left: 1px solid #888;
border-right: 1px solid #888;
border-bottom: 1px solid #888;
padding-bottom: 1px;
}
@media only screen and (max-width: 932px) {
#app.node {
grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "files" "blank";
grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "schedule" "files" "blank";
grid-template-columns: 1fr;
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* files */ 1fr;
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* schedule */ min-content /* files */ 1fr;
/* blank */
}
#app.node #tree {
display: none;
}
#app.node #checklist {
padding: 16px;
}
#app.node.toggle-tree {
display: grid;
grid-template-areas: "header" "tree";
@ -618,7 +879,9 @@ header .menu {
padding: 16px;
justify-self: start;
}
#file-section {
#file-section,
#checklist,
#markdown {
width: calc(100% - 32px);
padding: 16px;
margin-left: 16px;

View file

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="127.99999"
height="127.99999"
viewBox="0 0 33.866664 33.866666"
version="1.1"
id="svg8"
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
sodipodi:docname="add-gray.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="28.5"
inkscape:cy="32"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="1916"
inkscape:window-height="1044"
inkscape:window-x="1920"
inkscape:window-y="1096"
inkscape:window-maximized="0"
inkscape:showpageshadow="true"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d6d6d6"
showborder="true" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-57.706364,-79.853668)">
<rect
style="color:#000000;overflow:visible;fill:#8dd35f;fill-rule:evenodd;stroke-width:3.175;paint-order:markers stroke fill;stop-color:#000000;fill-opacity:1"
id="rect232"
width="33.866669"
height="7.4083333"
x="57.706364"
y="93.082832" />
<rect
style="color:#000000;overflow:visible;fill:#8dd35f;fill-rule:evenodd;stroke-width:3.175;paint-order:markers stroke fill;stop-color:#000000;fill-opacity:1"
id="rect396"
width="33.866665"
height="7.4083333"
x="79.853668"
y="-78.343864"
transform="rotate(90)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="4.7624998mm"
height="5.2916698mm"
viewBox="0 0 4.7624996 5.2916701"
version="1.1"
id="svg8"
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
sodipodi:docname="checklist-off.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#0088ff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="4"
inkscape:cx="9"
inkscape:cy="9.5"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="1916"
inkscape:window-height="1404"
inkscape:window-x="1280"
inkscape:window-y="16"
inkscape:window-maximized="0"
inkscape:showpageshadow="true"
inkscape:pagecheckerboard="false"
inkscape:deskcolor="#dddddd"
showborder="true" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-120.36363,-120.48512)">
<title
id="title1">clipboard-check</title>
<title
id="title1-7">clipboard-check</title>
<title
id="title1-5">clipboard-check-outline</title>
<path
d="m 124.59697,121.01429 h -1.10596 c -0.11113,-0.30692 -0.40217,-0.52917 -0.74613,-0.52917 -0.34395,0 -0.635,0.22225 -0.74612,0.52917 h -1.10596 a 0.52916667,0.52916667 0 0 0 -0.52917,0.52917 v 3.70416 a 0.52916667,0.52916667 0 0 0 0.52917,0.52917 h 3.70417 a 0.52916667,0.52916667 0 0 0 0.52916,-0.52917 v -3.70416 a 0.52916667,0.52916667 0 0 0 -0.52916,-0.52917 m -1.85209,0 a 0.26458333,0.26458333 0 0 1 0.26459,0.26458 0.26458333,0.26458333 0 0 1 -0.26459,0.26459 0.26458333,0.26458333 0 0 1 -0.26458,-0.26459 0.26458333,0.26458333 0 0 1 0.26458,-0.26458 m -1.32291,1.05833 h 2.64583 v -0.52916 h 0.52917 v 3.70416 h -3.70417 v -3.70416 h 0.52917 v 0.52916 m 0.13229,1.7198 0.39687,-0.39688 0.52917,0.52917 1.19063,-1.19063 0.39687,0.39688 -1.5875,1.5875 z"
id="path1"
style="stroke-width:0.264583;fill:#f9f9f9" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="4.7624998mm"
height="5.2916698mm"
viewBox="0 0 4.7624996 5.2916701"
version="1.1"
id="svg8"
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
sodipodi:docname="checklist-on.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#0088ff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="4"
inkscape:cx="9"
inkscape:cy="9.5"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="1916"
inkscape:window-height="1404"
inkscape:window-x="1280"
inkscape:window-y="16"
inkscape:window-maximized="0"
inkscape:showpageshadow="true"
inkscape:pagecheckerboard="false"
inkscape:deskcolor="#dddddd"
showborder="true" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-120.36363,-120.48512)">
<title
id="title1">clipboard-check</title>
<title
id="title1-7">clipboard-check</title>
<title
id="title1-5">clipboard-check-outline</title>
<title
id="title1-3">clipboard-check</title>
<path
d="m 122.21571,124.71846 -1.05833,-1.05834 0.37306,-0.37306 0.68527,0.68263 1.74361,-1.74361 0.37306,0.37571 m -1.5875,-1.5875 a 0.26458333,0.26458333 0 0 1 0.26458,0.26458 0.26458333,0.26458333 0 0 1 -0.26458,0.26459 0.26458333,0.26458333 0 0 1 -0.26458,-0.26459 0.26458333,0.26458333 0 0 1 0.26458,-0.26458 m 1.85208,0 H 123.491 c -0.11112,-0.30692 -0.40216,-0.52917 -0.74612,-0.52917 -0.34396,0 -0.635,0.22225 -0.74613,0.52917 h -1.10595 a 0.52916666,0.52916666 0 0 0 -0.52917,0.52917 v 3.70416 a 0.52916666,0.52916666 0 0 0 0.52917,0.52917 h 3.70416 a 0.52916666,0.52916666 0 0 0 0.52917,-0.52917 v -3.70416 a 0.52916666,0.52916666 0 0 0 -0.52917,-0.52917 z"
id="path1"
style="stroke-width:0.264583;fill:#f9f9f9" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="19.463146mm"
height="14.45555mm"
viewBox="0 0 19.463145 14.455551"
version="1.1"
id="svg8"
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
sodipodi:docname="edit.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="4"
inkscape:cx="34.875"
inkscape:cy="63"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="1916"
inkscape:window-height="1044"
inkscape:window-x="1920"
inkscape:window-y="1096"
inkscape:window-maximized="0"
inkscape:showpageshadow="true"
inkscape:pagecheckerboard="false"
inkscape:deskcolor="#dddddd"
showborder="true" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-95.481611,-106.78967)">
<path
fill="currentColor"
d="m 95.481611,106.78967 v 2.06503 h 11.357839 v -2.06503 H 95.481611 m 0,4.13011 v 2.06507 h 11.357839 v -2.06507 H 95.481611 m 17.553019,0.10341 c -0.10342,0 -0.30986,0.10342 -0.41304,0.20644 l -1.03251,1.03256 2.16829,2.16829 1.03255,-1.03252 c 0.20645,-0.20644 0.20645,-0.61951 0,-0.82603 l -1.34229,-1.3423 c -0.10342,-0.10302 -0.20644,-0.20644 -0.413,-0.20644 m -1.96181,1.85855 -6.29844,6.19518 v 2.1683 h 2.16829 l 6.29844,-6.29844 -2.16829,-2.06504 m -15.591209,2.1683 v 2.06507 h 7.227699 v -2.06507 z"
id="path1"
style="stroke-width:1.03253;fill:#b1b1b1;fill-opacity:1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="19.463146mm"
height="14.45555mm"
viewBox="0 0 19.463145 14.455551"
version="1.1"
id="svg8"
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
sodipodi:docname="edit.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="-9.5"
inkscape:cy="166"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="1916"
inkscape:window-height="1044"
inkscape:window-x="1920"
inkscape:window-y="1096"
inkscape:window-maximized="0"
inkscape:showpageshadow="true"
inkscape:pagecheckerboard="false"
inkscape:deskcolor="#dddddd"
showborder="true" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-95.481611,-106.78967)">
<path
fill="currentColor"
d="m 95.481611,106.78967 v 2.06503 h 11.357839 v -2.06503 H 95.481611 m 0,4.13011 v 2.06507 h 11.357839 v -2.06507 H 95.481611 m 17.553019,0.10341 c -0.10342,0 -0.30986,0.10342 -0.41304,0.20644 l -1.03251,1.03256 2.16829,2.16829 1.03255,-1.03252 c 0.20645,-0.20644 0.20645,-0.61951 0,-0.82603 l -1.34229,-1.3423 c -0.10342,-0.10302 -0.20644,-0.20644 -0.413,-0.20644 m -1.96181,1.85855 -6.29844,6.19518 v 2.1683 h 2.16829 l 6.29844,-6.29844 -2.16829,-2.06504 m -15.591209,2.1683 v 2.06507 h 7.227699 v -2.06507 z"
id="path1"
style="stroke-width:1.03253" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

66
static/images/edit.svg Normal file
View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="26.156595mm"
height="26.156595mm"
viewBox="0 0 26.156594 26.156597"
version="1.1"
id="svg8"
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
sodipodi:docname="edit.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="6.5"
inkscape:cy="41"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="1916"
inkscape:window-height="1044"
inkscape:window-x="1920"
inkscape:window-y="1096"
inkscape:window-maximized="0"
inkscape:showpageshadow="true"
inkscape:pagecheckerboard="false"
inkscape:deskcolor="#dddddd"
showborder="true" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-103.00032,-112.64716)">
<path
fill="currentColor"
d="m 128.73192,118.52071 c 0.56666,-0.56666 0.56666,-1.51108 0,-2.04869 l -3.39986,-3.39986 c -0.53761,-0.56666 -1.48203,-0.56666 -2.04869,0 l -2.67339,2.6589 5.44854,5.44849 m -23.0582,12.17566 v 5.44855 h 5.44849 l 16.06958,-16.08408 -5.44854,-5.44855 z"
id="path1"
style="stroke-width:1.45294;fill:#ff9955;fill-opacity:1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
height="128"
width="208"
viewBox="-31.2 -32 208 128"
version="1.1"
id="svg1"
sodipodi:docname="markdown-hollow.svg"
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="true"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="5.6568542"
inkscape:cx="109.68994"
inkscape:cy="68.942911"
inkscape:window-width="1916"
inkscape:window-height="1404"
inkscape:window-x="1280"
inkscape:window-y="16"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<rect
fill="none"
stroke-width="10"
stroke="#000000"
ry="10"
y="-27"
x="-26.200001"
height="118"
width="198"
id="rect1"
style="stroke:#ffffff;stroke-opacity:1" />
<path
d="M -1.2000003,66 V -2 H 18.8 l 20,25 20,-25 h 20 v 68 h -20 V 27 l -20,25 -20,-25 V 66 Z M 123.8,66 93.8,33 h 20 V -2 h 20 v 35 h 20 z"
id="path1"
style="fill:#ffffff;fill-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="23.835697mm"
height="26.815161mm"
viewBox="0 0 23.835696 26.815162"
version="1.1"
id="svg8"
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
sodipodi:docname="trashcan.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="45.5"
inkscape:cy="51"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="1916"
inkscape:window-height="1044"
inkscape:window-x="1920"
inkscape:window-y="1096"
inkscape:window-maximized="0"
inkscape:showpageshadow="true"
inkscape:pagecheckerboard="false"
inkscape:deskcolor="#dddddd"
showborder="true" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-81.215483,-137.6695)">
<path
fill="currentColor"
d="m 88.66414,137.6695 v 1.48977 h -7.448657 v 2.97942 h 1.489734 v 19.36654 a 2.9794621,2.9794621 0 0 0 2.979459,2.97943 h 14.897314 a 2.9794621,2.9794621 0 0 0 2.97946,-2.97943 v -19.36654 h 1.48973 v -2.97942 H 97.602526 V 137.6695 H 88.66414 m -2.979464,4.46919 h 14.897314 v 19.36654 H 85.684676 v -19.36654 m 2.979464,2.97948 v 13.40758 h 2.979464 V 145.11817 H 88.66414 m 5.958922,0 v 13.40758 h 2.979464 v -13.40758 z"
id="path1"
style="stroke-width:1.48973;fill:#d35f5f" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -20,12 +20,14 @@
"node": "/js/{{ .VERSION }}/node.mjs",
"key": "/js/{{ .VERSION }}/key.mjs",
"crypto": "/js/{{ .VERSION }}/crypto.mjs",
"checklist": "/js/{{ .VERSION }}/checklist.mjs",
"ws": "/_js/{{ .VERSION }}/websocket.mjs"
}
}
</script>
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/sjcl.js"></script>
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/node_modules/marked/marked.min.js"></script>
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/fullcalendar.min.js"></script>
</head>
<body>

View file

@ -1,10 +1,12 @@
import 'preact/debug'
import 'preact/devtools'
import { signal } from 'preact/signals'
import { h, Component, render, createRef } from 'preact'
import htm from 'htm'
import { Session } from 'session'
import { Node, NodeUI } from 'node'
import { Websocket } from 'ws'
import { signal } from 'preact/signals'
const html = htm.bind(h)
class App extends Component {
@ -46,7 +48,10 @@ class App extends Component {
responseError({ comm, app, upload }) {//{{{
if (comm !== undefined) {
comm.text().then(body => alert(body))
if (typeof comm.text === 'function')
comm.text().then(body => alert(body))
else
alert(comm)
return
}
@ -108,7 +113,7 @@ class App extends Component {
this.websocket.register('open', ()=>console.log('websocket connected'))
this.websocket.register('close', ()=>console.log('websocket disconnected'))
this.websocket.register('error', msg=>console.log(msg))
this.websocket.register('message', this.websocketMessage)
this.websocket.register('message', msg=>this.websocketMessage(msg))
this.websocket.start()
}//}}}
websocketMessage(data) {//{{{
@ -116,7 +121,7 @@ class App extends Component {
switch (msg.Op) {
case 'css_reload':
refreshCSS()
this.websocket.refreshCSS()
break;
}
}//}}}

472
static/js/checklist.mjs Normal file
View file

@ -0,0 +1,472 @@
import { h, Component, createRef } from 'preact'
import htm from 'htm'
import { signal } from 'preact/signals'
const html = htm.bind(h)
export class ChecklistGroup {
static sort(a, b) {//{{{
if (a.Order < b.Order) return -1
if (a.Order > b.Order) return 1
return 0
}//}}}
constructor(data) {//{{{
Object.keys(data).forEach(key => {
if (key == 'Items')
this.items = data[key].map(itemData => {
let item = new ChecklistItem(itemData)
item.checklistGroup = this
return item
})
else
this[key] = data[key]
})
}//}}}
addItem(label, okCallback) {//{{{
window._app.current.request('/node/checklist_group/item_add', {
ChecklistGroupID: this.ID,
Label: label,
})
.then(json => {
let item = new ChecklistItem(json.Item)
item.checklistGroup = this
this.items.push(item)
okCallback()
})
.catch(window._app.current.responseError)
return
}//}}}
updateLabel(newLabel, okCallback, errCallback) {//{{{
window._app.current.request('/node/checklist_group/label', {
ChecklistGroupID: this.ID,
Label: newLabel,
})
.then(okCallback)
.catch(errCallback)
}//}}}
delete(okCallback, errCallback) {//{{{
window._app.current.request('/node/checklist_group/delete', {
ChecklistGroupID: this.ID,
})
.then(() => {
okCallback()
})
.catch(errCallback)
}//}}}
}
export class ChecklistItem {
static sort(a, b) {//{{{
if (a.Order < b.Order) return -1
if (a.Order > b.Order) return 1
return 0
}//}}}
constructor(data) {//{{{
Object.keys(data).forEach(key => {
this[key] = data[key]
})
}//}}}
updateState(newState, okCallback, errCallback) {//{{{
window._app.current.request('/node/checklist_item/state', {
ChecklistItemID: this.ID,
State: newState,
})
.then(okCallback)
.catch(errCallback)
}//}}}
updateLabel(newLabel, okCallback, errCallback) {//{{{
window._app.current.request('/node/checklist_item/label', {
ChecklistItemID: this.ID,
Label: newLabel,
})
.then(okCallback)
.catch(errCallback)
}//}}}
delete(okCallback, errCallback) {//{{{
window._app.current.request('/node/checklist_item/delete', {
ChecklistItemID: this.ID,
})
.then(() => {
this.checklistGroup.items = this.checklistGroup.items.filter(item => item.ID != this.ID)
okCallback()
})
.catch(errCallback)
}//}}}
move(to, okCallback) {//{{{
window._app.current.request('/node/checklist_item/move', {
ChecklistItemID: this.ID,
AfterItemID: to.ID,
})
.then(okCallback)
.catch(_app.current.responseError)
}//}}}
}
export class Checklist extends Component {
constructor() {//{{{
super()
this.edit = signal(false)
this.dragItemSource = null
this.dragItemTarget = null
this.groupElements = {}
this.state = {
confirmDeletion: true,
continueAddingItems: true,
}
window._checklist = this
}//}}}
render({ ui, groups }, { confirmDeletion, continueAddingItems }) {//{{{
this.groupElements = {}
if (groups.length == 0 && !ui.node.value.ShowChecklist.value)
return
if (typeof groups.sort != 'function')
groups = []
groups.sort(ChecklistGroup.sort)
let groupElements = groups.map(group => {
this.groupElements[group.ID] = createRef()
return html`<${ChecklistGroupElement} ref=${this.groupElements[group.ID]} key="group-${group.ID}" ui=${this} group=${group} />`
})
let edit = 'edit-list-gray.svg'
let confirmDeletionEl = ''
if (this.edit.value) {
edit = 'edit-list.svg'
confirmDeletionEl = html`
<div>
<input type="checkbox" id="confirm-checklist-delete" checked=${confirmDeletion} onchange=${() => this.setState({ confirmDeletion: !confirmDeletion })} />
<label for="confirm-checklist-delete">Confirm checklist deletion</label>
</div>
<div>
<input type="checkbox" id="continue-adding-items" checked=${continueAddingItems} onchange=${() => this.setState({ continueAddingItems: !continueAddingItems })} />
<label for="continue-adding-items">Continue adding items</label>
</div>
`
}
let addGroup = () => {
if (this.edit.value)
return html`<img src="/images/${_VERSION}/add-gray.svg" onclick=${() => this.addGroup()} />`
}
return html`
<div id="checklist">
<div class="header">
<h1>Checklist</h1>
<img src="/images/${_VERSION}/${edit}" onclick=${() => this.toggleEdit()} />
<${addGroup} />
</div>
${confirmDeletionEl}
${groupElements}
</div>
`
}//}}}
toggleEdit() {//{{{
this.edit.value = !this.edit.value
}//}}}
addGroup() {//{{{
let label = prompt("Create a new group")
if (label === null)
return
label = label.trim()
if (label == '')
return
window._app.current.request('/node/checklist_group/add', {
NodeID: window._app.current.nodeUI.current.node.value.ID,
Label: label,
})
.then(json => {
let group = new ChecklistGroup(json.Group)
this.props.groups.push(group)
this.forceUpdate()
})
.catch(window._app.current.responseError)
return
}//}}}
dragTarget(target) {//{{{
if (this.dragItemTarget)
this.dragItemTarget.setDragTarget(false)
this.dragItemTarget = target
target.setDragTarget(true)
}//}}}
dragReset() {//{{{
if (this.dragItemTarget) {
this.dragItemTarget.setDragTarget(false)
this.dragItemTarget = null
}
}//}}}
}
class InputElement extends Component {
render({ placeholder, label }) {//{{{
return html`
<dialog id="input-text">
<div class="container">
<div class="label">${label}</div>
<input id="input-text-el" type="text" placeholder=${placeholder} />
<div class="buttons">
<div></div>
<button onclick=${()=>this.cancel()}>Cancel</button>
<button onclick=${()=>this.ok()}>OK</button>
</div>
</div>
</dialog>
`
}//}}}
componentDidMount() {//{{{
const dlg = document.getElementById('input-text')
const input = document.getElementById('input-text-el')
dlg.showModal()
dlg.addEventListener("keydown", evt => this.keyhandler(evt))
input.addEventListener("keydown", evt => this.keyhandler(evt))
input.focus()
}//}}}
ok() {//{{{
const input = document.getElementById('input-text-el')
this.props.callback(true, input.value)
}//}}}
cancel() {//{{{
this.props.callback(false)
}//}}}
keyhandler(evt) {//{{{
let handled = true
switch (evt.key) {
case 'Enter':
this.ok()
break;
case 'Escape':
this.cancel()
break;
default:
handled = false
}
if (handled) {
evt.stopPropagation()
evt.preventDefault()
}
}//}}}
}
class ChecklistGroupElement extends Component {
constructor() {//{{{
super()
this.label = createRef()
this.addingItem = signal(false)
}//}}}
render({ ui, group }) {//{{{
let items = ({ ui, group }) =>
group.items
.sort(ChecklistItem.sort)
.map(item => html`<${ChecklistItemElement} key="item-${item.ID}" ui=${ui} group=${this} item=${item} />`)
let label = () => html`<div class="label" style="cursor: pointer" ref=${this.label} onclick=${() => this.editLabel()}>${group.Label}</div>`
let addItem = () => {
if (this.addingItem.value)
return html`<${InputElement} label="New item" callback=${(ok, val) => this.addItem(ok, val)} />`
}
return html`
<${addItem} />
<div class="checklist-group-container">
<div class="checklist-group ${ui.edit.value ? 'edit' : ''}">
<div class="reorder" style="cursor: grab"></div>
<img src="/images/${_VERSION}/trashcan.svg" onclick=${() => this.delete()} />
<${label} />
<img src="/images/${_VERSION}/add-gray.svg" onclick=${() => this.addingItem.value = true} />
</div>
<${items} ui=${ui} group=${group} />
</div>
`
}//}}}
addItem(ok, label) {//{{{
if (!ok) {
this.addingItem.value = false
return
}
label = label.trim()
if (label == '') {
this.addingItem.value = false
return
}
this.props.group.addItem(label, () => {
this.forceUpdate()
})
if (!this.props.ui.state.continueAddingItems)
this.addingItem.value = false
}//}}}
editLabel() {//{{{
let label = prompt('Edit label', this.props.group.Label)
if (label === null)
return
label = label.trim()
if (label == '') {
alert(`A label can't be empty.`)
return
}
this.label.current.classList.remove('error')
this.props.group.updateLabel(label, () => {
this.props.group.Label = label
this.label.current.innerHTML = label
this.label.current.classList.add('ok')
this.forceUpdate()
setTimeout(() => this.label.current.classList.remove('ok'), 500)
}, () => {
this.label.current.classList.add('error')
})
}//}}}
delete() {//{{{
if (this.props.ui.state.confirmDeletion) {
if (!confirm(`Delete '${this.props.group.Label}'?`))
return
}
this.props.group.delete(() => {
this.props.ui.props.groups = this.props.ui.props.groups.filter(g => g.ID != this.props.group.ID)
this.props.ui.forceUpdate()
}, err => {
console.log(err)
console.log('error')
})
}//}}}
}
class ChecklistItemElement extends Component {
constructor(props) {//{{{
super(props)
this.state = {
checked: props.item.Checked,
dragTarget: false,
}
this.checkbox = createRef()
this.label = createRef()
}//}}}
render({ ui, item }, { checked, dragTarget }) {//{{{
let checkbox = () => {
if (ui.edit.value)
return html`<label ref=${this.label} onclick=${() => this.editLabel()} style="cursor: pointer">${item.Label}</label>`
else
return html`
<input type="checkbox" ref=${this.checkbox} key="checkbox-${item.ID}" id="checkbox-${item.ID}" checked=${checked} onchange=${evt => this.update(evt.target.checked)} />
<label ref=${this.label} for="checkbox-${item.ID}">${item.Label}</label>
`
}
return html`
<div class="checklist-item ${checked ? 'checked' : ''} ${ui.edit.value ? 'edit' : ''} ${dragTarget ? 'drag-target' : ''}" draggable=true>
<div class="reorder" style="user-select: none;"></div>
<img src="/images/${_VERSION}/trashcan.svg" onclick=${() => this.delete()} />
<${checkbox} />
</div>
`
}//}}}
componentDidMount() {//{{{
this.base.addEventListener('dragstart', evt => this.dragStart(evt))
this.base.addEventListener('dragend', () => this.dragEnd())
this.base.addEventListener('dragenter', evt => this.dragEnter(evt))
}//}}}
update(checked) {//{{{
this.setState({ checked })
this.checkbox.current.classList.remove('error')
this.props.item.updateState(checked, () => {
this.checkbox.current.classList.add('ok')
setTimeout(() => this.checkbox.current.classList.remove('ok'), 500)
}, () => {
this.checkbox.current.classList.add('error')
})
}//}}}
editLabel() {//{{{
let label = prompt('Edit label', this.props.item.Label)
if (label === null)
return
label = label.trim()
if (label == '') {
alert(`A label can't be empty.`)
return
}
this.label.current.classList.remove('error')
this.props.item.updateLabel(label, () => {
this.props.item.Label = label
this.label.current.innerHTML = label
this.label.current.classList.add('ok')
setTimeout(() => this.label.current.classList.remove('ok'), 500)
}, () => {
this.label.current.classList.add('error')
})
}//}}}
delete() {//{{{
if (this.props.ui.state.confirmDeletion) {
if (!confirm(`Delete '${this.props.item.Label}'?`))
return
}
this.props.item.delete(() => {
this.props.group.forceUpdate()
}, err => {
console.log(err)
console.log('error')
})
}//}}}
setDragTarget(state) {//{{{
this.setState({ dragTarget: state })
}//}}}
dragStart(evt) {//{{{
// Shouldn't be needed, but in case the previous drag was bungled up, we reset.
this.props.ui.dragReset()
this.props.ui.dragItemSource = this
const img = new Image();
evt.dataTransfer.setDragImage(img, 10, 10);
}//}}}
dragEnter(evt) {//{{{
evt.preventDefault()
this.props.ui.dragTarget(this)
}//}}}
dragEnd() {//{{{
let groups = this.props.ui.props.groups
let from = this.props.ui.dragItemSource.props.item
let to = this.props.ui.dragItemTarget.props.item
this.props.ui.dragReset()
if (from.ID == to.ID)
return
let fromGroup = groups.find(g => g.ID == from.GroupID)
let toGroup = groups.find(g => g.ID == to.GroupID)
from.Order = to.Order
from.GroupID = toGroup.ID
toGroup.items.forEach(i => {
if (i.ID == from.ID)
return
if (i.Order <= to.Order)
i.Order--
})
if (fromGroup.ID != toGroup.ID) {
fromGroup.items = fromGroup.items.filter(i => i.ID != from.ID)
toGroup.items.push(from)
}
this.props.ui.groupElements[fromGroup.ID].current.forceUpdate()
this.props.ui.groupElements[toGroup.ID].current.forceUpdate()
from.move(to, () => {})
}//}}}
}
// vim: foldmethod=marker

6
static/js/lib/fullcalendar.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -3,7 +3,7 @@ import htm from 'htm'
import { signal } from 'preact/signals'
import { Keys, Key } from 'key'
import Crypto from 'crypto'
//import { marked } from 'marked'
import { Checklist, ChecklistGroup } from 'checklist'
const html = htm.bind(h)
export class NodeUI extends Component {
@ -12,8 +12,8 @@ export class NodeUI extends Component {
this.menu = signal(false)
this.node = signal(null)
this.nodeContent = createRef()
this.nodeProperties = createRef()
this.keys = signal([])
this.page = signal('node')
window.addEventListener('popstate', evt => {
if (evt.state && evt.state.hasOwnProperty('nodeID'))
@ -58,18 +58,22 @@ export class NodeUI extends Component {
case 'node':
if (node.ID == 0) {
page = html`
<div style="cursor: pointer; color: #000; text-align: center;" onclick=${() => this.page.value = 'schedule-events'}>Schedule events</div>
${children.length > 0 ? html`<div class="child-nodes">${children}</div><div id="notes-version">Notes version ${window._VERSION}</div>` : html``}
`
} else {
let padlock = ''
if (node.CryptoKeyID > 0)
padlock = html`<img src="/images/${window._VERSION}/padlock-black.svg" style="height: 24px;" />`
page = html`
${children.length > 0 ? html`<div class="child-nodes">${children}</div>` : html``}
<div class="node-name">
${node.Name} ${padlock}
</div>
<${NodeContent} key=${node.ID} node=${node} ref=${this.nodeContent} />
<${NodeEvents} events=${node.ScheduleEvents.value} />
<${Checklist} ui=${this} groups=${node.ChecklistGroups} />
<${NodeFiles} node=${this.node.value} />
`
}
@ -80,7 +84,7 @@ export class NodeUI extends Component {
break
case 'node-properties':
page = html`<${NodeProperties} nodeui=${this} />`
page = html`<${NodeProperties} ref=${this.nodeProperties} nodeui=${this} />`
break
case 'keys':
@ -94,18 +98,26 @@ export class NodeUI extends Component {
case 'search':
page = html`<${Search} nodeui=${this} />`
break
case 'schedule-events':
page = html`<${ScheduleEventList} nodeui=${this} />`
break
}
let menu = ''
if (this.menu.value)
menu = html`<${Menu} nodeui=${this} />`
let menu = () => (this.menu.value ? html`<${Menu} nodeui=${this} />` : null)
let checklist = () =>
html`
<div class="checklist" onclick=${evt => { evt.stopPropagation(); this.toggleChecklist() }}>
<img src="/images/${window._VERSION}/${this.showChecklist() ? 'checklist-on.svg' : 'checklist-off.svg'}" />
</div>`
return html`
${menu}
<${menu} />
<header class="${modified}" onclick=${() => this.saveNode()}>
<div class="tree"><img src="/images/${window._VERSION}/tree.svg" onclick=${() => document.getElementById('app').classList.toggle('toggle-tree')} /></div>
<div class="name">Notes</div>
<div class="markdown" onclick=${evt => { evt.stopPropagation(); this.toggleMarkdown() }}><img src="/images/${window._VERSION}/markdown.svg" /></div>
<div class="markdown" onclick=${evt => { evt.stopPropagation(); this.toggleMarkdown() }}><img src="/images/${window._VERSION}/${node.RenderMarkdown.value ? 'markdown.svg' : 'markdown-hollow.svg'}" /></div>
<${checklist} />
<div class="search" onclick=${evt => { evt.stopPropagation(); this.showPage('search') }}><img src="/images/${window._VERSION}/search.svg" /></div>
<div class="add" onclick=${evt => this.createNode(evt)}><img src="/images/${window._VERSION}/add.svg" /></div>
<div class="keys" onclick=${evt => { evt.stopPropagation(); this.showPage('keys') }}><img src="/images/${window._VERSION}/padlock.svg" /></div>
@ -143,6 +155,10 @@ export class NodeUI extends Component {
return
switch (evt.key.toUpperCase()) {
case 'C':
this.showPage('node')
break
case 'E':
this.showPage('keys')
break
@ -160,7 +176,10 @@ export class NodeUI extends Component {
break
case 'S':
this.saveNode()
if (this.page.value == 'node')
this.saveNode()
else if (this.page.value == 'node-properties')
this.nodeProperties.current.save()
break
case 'U':
@ -231,15 +250,12 @@ export class NodeUI extends Component {
})
}//}}}
saveNode() {//{{{
/*
let nodeContent = this.nodeContent.current
if (this.page.value != 'node' || nodeContent === null)
return
*/
let content = this.node.value.content()
this.node.value.setContent(content)
this.node.value.save(() => this.props.app.nodeModified.value = false)
this.node.value.save(() => {
this.props.app.nodeModified.value = false
this.node.value.retrieve()
})
}//}}}
renameNode() {//{{{
let name = prompt("New name")
@ -286,8 +302,14 @@ export class NodeUI extends Component {
showPage(pg) {//{{{
this.page.value = pg
}//}}}
showChecklist() {//{{{
return (this.node.value.ChecklistGroups && this.node.value.ChecklistGroups.length > 0) | this.node.value.ShowChecklist.value
}//}}}
toggleChecklist() {//{{{
this.node.value.ShowChecklist.value = !this.node.value.ShowChecklist.value
}//}}}
toggleMarkdown() {//{{{
this.node.value.RenderMarkdown.value = !this.node.value.RenderMarkdown.value
this.node.value.RenderMarkdown.value = !this.node.value.RenderMarkdown.value
}//}}}
}
@ -311,7 +333,7 @@ class NodeContent extends Component {
var element
if (node.RenderMarkdown.value)
element = html`<div id="markdown"></div>`
element = html`<${MarkdownContent} key='markdown-content' content=${content} />`
else
element = html`
<div class="grow-wrap">
@ -322,18 +344,10 @@ class NodeContent extends Component {
return element
}//}}}
componentDidMount() {//{{{
const markdown = document.getElementById('markdown')
if (markdown)
markdown.innerHTML = marked.parse(this.props.node.content())
this.resize()
window.addEventListener('resize', () => this.resize())
}//}}}
componentDidUpdate() {//{{{
const markdown = document.getElementById('markdown')
if (markdown)
markdown.innerHTML = marked.parse(this.props.node.content())
this.resize()
}//}}}
contentChanged(evt) {//{{{
@ -346,16 +360,6 @@ class NodeContent extends Component {
let textarea = document.getElementById('node-content')
if (textarea)
textarea.parentNode.dataset.replicatedValue = textarea.value
let crumbsEl = document.getElementById('crumbs')
let markdown = document.getElementById('markdown')
if (markdown) {
let margins = (crumbsEl.clientWidth - 900) / 2.0
if (margins < 0)
margins = 0
markdown.style.marginLeft = `${margins}px`
markdown.style.marginRight = `${margins}px`
}
}//}}}
unlock() {//{{{
let pass = prompt(`Password for "${this.props.model.description}"`)
@ -371,6 +375,35 @@ class NodeContent extends Component {
}//}}}
}
class MarkdownContent extends Component {
render({ content }) {//{{{
return html`<div id="markdown"></div>`
}//}}}
componentDidMount() {//{{{
const markdown = document.getElementById('markdown')
if (markdown)
markdown.innerHTML = marked.parse(this.props.content)
}//}}}
}
class NodeEvents extends Component {
render({ events }) {//{{{
if (events.length == 0)
return html``
const eventElements = events.map(evt => {
const dt = evt.Time.split('T')
return html`<div>${dt[0]} ${dt[1].slice(0, 5)}</div>`
})
return html`
<div id="schedule-section">
<div class="header">Schedule events</div>
${eventElements}
</div>
`
}//}}}
}
class NodeFiles extends Component {
render({ node }) {//{{{
if (node.Files === null || node.Files.length == 0)
@ -417,16 +450,24 @@ export class Node {
this.Name = ''
this.RenderMarkdown = signal(false)
this.Markdown = false
this.ShowChecklist = signal(false)
this._content = ''
this.Children = []
this.Crumbs = []
this.Files = []
this._decrypted = false
this._expanded = false // start value for the TreeNode component,
this.ChecklistGroups = {}
this.ScheduleEvents = signal([])
// it doesn't control it afterwards.
// Used to expand the crumbs upon site loading.
}//}}}
retrieve(callback) {//{{{
this.app.request('/schedule/list', { NodeID: this.ID })
.then(res => {
this.ScheduleEvents.value = res.ScheduleEvents
})
this.app.request('/node/retrieve', { ID: this.ID })
.then(res => {
this.ParentID = res.Node.ParentID
@ -439,6 +480,7 @@ export class Node {
this.Files = res.Node.Files
this.Markdown = res.Node.Markdown
this.RenderMarkdown.value = this.Markdown
this.initChecklist(res.Node.ChecklistGroups)
callback(this)
})
.catch(this.app.responseError)
@ -469,6 +511,7 @@ export class Node {
Content: this._content,
CryptoKeyID: this.CryptoKeyID,
Markdown: this.Markdown,
TimeOffset: -(new Date().getTimezoneOffset()),
}
this.app.request('/node/update', req)
.then(callback)
@ -598,6 +641,13 @@ export class Node {
this._decrypted = false
return this._content
}//}}}
initChecklist(checklistData) {//{{{
if (checklistData === undefined || checklistData === null)
return
this.ChecklistGroups = checklistData.map(groupData => {
return new ChecklistGroup(groupData)
})
}//}}}
}
class Menu extends Component {
@ -751,10 +801,13 @@ class NodeProperties extends Component {
<div id="properties">
<h1>Note properties</h1>
These properties are only for this note.
<div style="margin-bottom: 16px">These properties are only for this note.</div>
<h2>Markdown</h2>
<input type="checkbox" id="render-markdown" checked=${nodeui.node.value.Markdown} onchange=${evt=>nodeui.node.value.Markdown = evt.target.checked} /> <label for="render-markdown">Render this node with markdown.</label>
<div class="checks">
<input type="checkbox" id="render-markdown" checked=${nodeui.node.value.Markdown} onchange=${evt => nodeui.node.value.Markdown = evt.target.checked} />
<label for="render-markdown">Markdown view</label>
</div>
<h2>Encryption</h2>
<div class="key">
@ -932,4 +985,133 @@ class ProfileSettings extends Component {
}//}}}
}
class ScheduleEventList extends Component {
static CALENDAR = Symbol('CALENDAR')
static LIST = Symbol('LIST')
constructor() {//{{{
super()
this.tab = signal(ScheduleEventList.CALENDAR)
}//}}}
render() {//{{{
var tab
switch (this.tab.value) {
case ScheduleEventList.CALENDAR:
tab = html`<${ScheduleCalendarTab} />`
break;
case ScheduleEventList.LIST:
tab = html`<${ScheduleEventListTab} />`
break;
}
return html`
<div style="margin: 32px">
<div class="folder">
<div class="tabs">
<div onclick=${() => this.tab.value = ScheduleEventList.CALENDAR} class="tab ${this.tab.value == ScheduleEventList.CALENDAR ? 'selected' : ''}">Calendar</div>
<div onclick=${() => this.tab.value = ScheduleEventList.LIST} class="tab ${this.tab.value == ScheduleEventList.LIST ? 'selected' : ''}">List</div>
<div class="hack"></div>
</div>
<div class="content">
${tab}
</div>
</div>
</div>
`
}//}}}
}
class ScheduleEventListTab extends Component {
constructor() {//{{{
super()
this.events = signal(null)
this.retrieveFutureEvents()
}//}}}
render() {//{{{
if (this.events.value === null)
return
let events = this.events.value.sort((a, b) => {
if (a.Time < b.Time) return -1
if (a.Time > b.Time) return 1
return 0
}).map(evt => {
const dt = evt.Time.split('T')
const remind = () => {
if (evt.RemindMinutes > 0)
return html`${evt.RemindMinutes} min`
}
const nodeLink = () => html`<a href="/?node=${evt.Node.ID}">${evt.Node.Name}</a>`
return html`
<div class="date">${dt[0]}</div>
<div class="time">${dt[1].slice(0, 5)}</div>
<div class="remind"><${remind} /></div>
<div class="description">${evt.Description}</div>
<div class="node"><${nodeLink} /></div>
`
})
return html`
<div id="schedule-events">
<div class="header">Date</div>
<div class="header">Time</div>
<div class="header">Reminder</div>
<div class="header">Event</div>
<div class="header">Node</div>
${events}
</div>
`
}//}}}
retrieveFutureEvents() {//{{{
_app.current.request('/schedule/list')
.then(data => {
this.events.value = data.ScheduleEvents
})
}//}}}
}
class ScheduleCalendarTab extends Component {
constructor() {//{{{
super()
}//}}}
componentDidMount() {
let calendarEl = document.getElementById('fullcalendar');
this.calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
events: this.events,
eventTimeFormat: {
hour12: false,
hour: '2-digit',
minute: '2-digit',
},
firstDay: 1,
aspectRatio: 2.5,
});
this.calendar.render();
}
render() {
return html`<div id="fullcalendar"></div>`
}
events(info, successCallback, failureCallback) {
const req = {
StartDate: info.startStr,
EndDate: info.endStr,
}
_app.current.request('/schedule/list', req)
.then(data => {
const fullcalendarEvents = data.ScheduleEvents.map(sch => {
return {
title: sch.Description,
start: sch.Time,
url: `/?node=${sch.Node.ID}`,
}
})
successCallback(fullcalendarEvents)
})
.catch(err=>failureCallback(err))
}
}
// vim: foldmethod=marker

View file

@ -8,10 +8,18 @@ html {
box-sizing: inherit;
}
*,*:focus,*:hover{
outline:none;
}
[onClick] {
cursor: pointer;
}
label {
user-select: none;
}
html, body {
margin: 0px;
padding: 0px;
@ -23,13 +31,18 @@ html, body {
}
h1 {
margin-top: 0px;
font-size: 1.5em;
font-size: 1.25em;
color: @header_1;
border-bottom: 1px solid #ccc;
}
h2 {
margin-top: 32px;
font-size: 1.25em;
font-size: 1.0em;
color: @header_1;
}
h3 {
font-size: 1.0em;
}
button {
@ -157,12 +170,18 @@ button {
margin-left: 8px;
}
}
.checks {
display: grid;
grid-template-columns: min-content 1fr;
grid-gap: 4px 8px;
}
}
header {
display: grid;
grid-area: header;
grid-template-columns: min-content 1fr repeat(4, min-content);
grid-template-columns: min-content 1fr repeat(6, min-content);
align-items: center;
padding: 8px 0px;
color: darken(@accent_1, 35%);
@ -189,7 +208,7 @@ header {
font-size: 1.25em;
}
.markdown, .search, .add, .keys {
.markdown, .checklist, .search, .add, .keys {
padding-right: 16px;
img {
@ -212,6 +231,7 @@ header {
padding: 16px;
background-color: #333;
color: #ddd;
z-index: 100; // Over crumbs shadow
.node {
display: grid;
@ -352,10 +372,17 @@ header {
}
#markdown {
padding: 16px;
color: #333;
grid-area: content;
justify-self: center;
width: calc(100% - 32px);
max-width: 900px;
padding: 0.5rem;
border-radius: 8px;
margin-top: 8px;
margin-bottom: 0px;
table {
border-collapse: collapse;
@ -365,6 +392,191 @@ header {
padding: 4px 8px;
}
}
code {
background: #e6eeee;
padding: 4px;
border-radius: 4px;
}
pre {
background: #f5f5f5;
padding: 8px;
border-radius: 8px;
}
pre > code {
background: unset;
padding: 0px;
border-radius: 0px;
}
}
#checklist {
grid-area: checklist;
color: #333;
justify-self: center;
width: calc(100% - 32px);
max-width: 900px;
padding: 0.5rem;
border-radius: 8px;
margin-top: 8px;
margin-bottom: 0px;
.header {
display: grid;
grid-template-columns: repeat(3, min-content);
align-items: center;
grid-gap: 0 16px;
img {
height: 20px;
cursor: pointer;
}
}
.header + .checklist-group.edit {
margin-top: 16px;
}
.checklist-group {
display: grid;
grid-template-columns: repeat(4, min-content);
align-items: center;
grid-gap: 0 8px;
margin-top: 1em;
margin-bottom: 8px;
font-weight: bold;
.label {
white-space: nowrap;
}
.label.ok {
color: #54b356;
}
.label.error {
color: #d13636;
}
&.edit {
margin-top: 32px;
border-bottom: 1px solid #aaa;
padding-bottom: 8px;
}
&:not(.edit) {
.reorder { display: none; }
img { display: none; }
}
img {
height: 14px;
cursor: pointer;
}
}
.checklist-item {
transform: translate(0, 0);
display: grid;
grid-template-columns: repeat(3, min-content);
grid-gap: 0 8px;
align-items: center;
padding: 4px 0;
border-bottom: 2px solid #fff;
&.checked {
text-decoration: line-through;
color: #aaa;
}
&.drag-target {
border-bottom: 2px solid #71c837;
}
input[type="checkbox"] {
-webkit-appearance: none;
appearance: none;
background-color: #fff;
margin: 0 2px 0 0;
font: inherit;
color: currentColor;
width: 1.25em;
height: 1.25em;
border: 0.15em solid currentColor;
border-radius: 0.15em;
transform: translateY(-0.075em);
display: grid;
place-content: center;
}
label.ok {
color: #54b356;
}
label.error {
color: #d13636;
}
input[type="checkbox"].ok {
border: 0.15em solid #54b356;
}
input[type="checkbox"].ok::before {
box-shadow: inset 1em 1em #54b356;
}
input[type="checkbox"].error {
border: 0.15em solid #d13636;
}
input[type="checkbox"].error::before {
box-shadow: inset 1em 1em #d13636;
}
input[type="checkbox"]::before {
content: "";
width: 0.70em;
height: 0.70em;
transform: scale(0);
transition: 120ms transform ease-in-out;
box-shadow: inset 1em 1em @checkbox_1;
}
input[type="checkbox"]:checked::before {
transform: scale(1);
}
&.edit {
input[type="checkbox"] {
margin-left: 8px;
}
}
&:not(.edit) {
.reorder {
display: none;
}
img {
display: none;
}
}
img {
height: 14px;
cursor: pointer;
}
label {
user-select: none;
white-space: nowrap;
}
}
}
/* ============================================================= *
@ -410,7 +622,7 @@ header {
}
/* ============================================================= */
#file-section {
#schedule-section {
grid-area: files;
justify-self: center;
width: calc(100% - 32px);
@ -420,6 +632,25 @@ header {
border-radius: 8px;
margin-top: 32px;
margin-bottom: 32px;
color: #000;
.header {
font-weight: bold;
color: #000;
margin-bottom: 16px;
}
}
#file-section {
grid-area: schedule;
justify-self: center;
width: calc(100% - 32px);
max-width: 900px;
padding: 32px;
background: #f5f5f5;
border-radius: 8px;
margin-top: 32px;
margin-bottom: 16px;
.header {
font-weight: bold;
@ -543,6 +774,8 @@ header {
"tree child-nodes"
"tree name"
"tree content"
"tree checklist"
"tree schedule"
"tree files"
"tree blank"
;
@ -553,6 +786,8 @@ header {
min-content /* child-nodes */
min-content /* name */
min-content /* content */
min-content /* checklist */
min-content /* schedule */
min-content /* files */
1fr; /* blank */
color: #fff;
@ -585,6 +820,8 @@ header {
"child-nodes"
"name"
"content"
"checklist"
"schedule"
"files"
"blank"
;
@ -595,9 +832,15 @@ header {
min-content /* child-nodes */
min-content /* name */
min-content /* content */
min-content /* checklist */
min-content /* schedule */
min-content /* files */
1fr; /* blank */
#tree { display: none }
#checklist {
padding: 16px;
}
}// }}}
.layout-keys {
display: grid;
@ -654,6 +897,82 @@ header {
}
}
#schedule-events {
display: grid;
grid-template-columns: repeat(5, min-content);
grid-gap: 4px 12px;
margin: 32px;
color: #000;
white-space: nowrap;
.header {
font-weight: bold;
}
}
#input-text {
border: 1px solid #000 !important;
padding: 16px;
width: 300px;
.label {
margin-bottom: 4px;
}
input[type=text] {
width: 100%;
padding: 4px;
}
.buttons {
display: grid;
grid-template-columns: 1fr 64px 64px;
grid-gap: 8px;
margin-top: 8px;
}
}
#fullcalendar {
margin: 32px;
color: #444;
}
.folder {
.tabs {
border-left: 1px solid #888;
display: flex;
.tab {
padding: 16px 32px;
border-top: 1px solid #888;
border-bottom: 1px solid #888;
border-right: 1px solid #888;
color: #444;
background: #eee;
cursor: pointer;
&.selected {
border-bottom: none;
background: #fff;
}
}
.hack {
border-bottom: 1px solid #888;
width: 100%;
}
}
.content {
padding-top: 1px;
border-left: 1px solid #888;
border-right: 1px solid #888;
border-bottom: 1px solid #888;
padding-bottom: 1px;
}
}
@media only screen and (max-width: 932px) {
#app.node {
.layout-crumbs();
@ -669,7 +988,7 @@ header {
justify-self: start;
}
#file-section {
#file-section, #checklist, #markdown {
width: calc(100% - 32px);
padding: 16px;
margin-left: 16px;

View file

@ -4,6 +4,11 @@
@accent_2: #ecbf00;
@accent_3: #c84a37;
@header_1: #518048;
@header_2: #518048;
@checkbox_1: #666;
/*
@theme_gradient: linear-gradient(to right, #009fff, #ec2f4b);
@theme_gradient: linear-gradient(to right, #f5af19, #f12711);

View file

@ -1 +1 @@
v16
v29