diff --git a/README.md b/README.md index 9de30de..29b7ff9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config.go b/config.go index 56fd2ff..4860517 100644 --- a/config.go +++ b/config.go @@ -28,6 +28,7 @@ type Config struct { Static string Upload string } + NotificationBaseURL string } Session struct { diff --git a/db.go b/db.go new file mode 100644 index 0000000..2046724 --- /dev/null +++ b/db.go @@ -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 +} diff --git a/go.mod b/go.mod index a0791a8..59b3275 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index bbaf4e1..6466326 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/main.go b/main.go index f6b8e01..1b1b6a1 100644 --- a/main.go +++ b/main.go @@ -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 { @@ -104,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 { @@ -114,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) @@ -121,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 @@ -229,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 @@ -477,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") @@ -534,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 diff --git a/node.go b/node.go index 941fb4a..a78785b 100644 --- a/node.go +++ b/node.go @@ -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 diff --git a/notification/factory.go b/notification/factory.go new file mode 100644 index 0000000..cc4d145 --- /dev/null +++ b/notification/factory.go @@ -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") +} diff --git a/notification/ntfy.go b/notification/ntfy.go new file mode 100644 index 0000000..91a5a55 --- /dev/null +++ b/notification/ntfy.go @@ -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 +} diff --git a/notification/pkg.go b/notification/pkg.go new file mode 100644 index 0000000..b74a6b1 --- /dev/null +++ b/notification/pkg.go @@ -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 +} diff --git a/notification_manager.go b/notification_manager.go new file mode 100644 index 0000000..5d674da --- /dev/null +++ b/notification_manager.go @@ -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 +}// }}} diff --git a/request_response.go b/request_response.go index c2c6ba5..5eaea5b 100644 --- a/request_response.go +++ b/request_response.go @@ -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) } diff --git a/schedule.go b/schedule.go new file mode 100644 index 0000000..fe4b7b5 --- /dev/null +++ b/schedule.go @@ -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 +} // }}} diff --git a/sql/00014.sql b/sql/00014.sql new file mode 100644 index 0000000..3a1123c --- /dev/null +++ b/sql/00014.sql @@ -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 +) diff --git a/sql/00015.sql b/sql/00015.sql new file mode 100644 index 0000000..c39cc4f --- /dev/null +++ b/sql/00015.sql @@ -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 +); diff --git a/sql/00016.sql b/sql/00016.sql new file mode 100644 index 0000000..8a98bf1 --- /dev/null +++ b/sql/00016.sql @@ -0,0 +1 @@ +ALTER TABLE public.schedule ADD CONSTRAINT schedule_event UNIQUE (user_id, node_id, "time", description); diff --git a/sql/00017.sql b/sql/00017.sql new file mode 100644 index 0000000..52009bf --- /dev/null +++ b/sql/00017.sql @@ -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 +); diff --git a/sql/00018.sql b/sql/00018.sql new file mode 100644 index 0000000..261a5b8 --- /dev/null +++ b/sql/00018.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.schedule ALTER COLUMN "time" TYPE timestamptz USING "time"::timestamptz; + diff --git a/sql/00019.sql b/sql/00019.sql new file mode 100644 index 0000000..6b170a3 --- /dev/null +++ b/sql/00019.sql @@ -0,0 +1 @@ +ALTER TABLE public.schedule ADD COLUMN remind_minutes int NOT NULL DEFAULT 0; diff --git a/sql/00020.sql b/sql/00020.sql new file mode 100644 index 0000000..bfe8680 --- /dev/null +++ b/sql/00020.sql @@ -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; diff --git a/sql/00021.sql b/sql/00021.sql new file mode 100644 index 0000000..88d7364 --- /dev/null +++ b/sql/00021.sql @@ -0,0 +1 @@ +ALTER TABLE public.node ALTER COLUMN updated TYPE timestamptz USING updated::timestamptz; diff --git a/static/css/main.css b/static/css/main.css index 63c1a4e..2617798 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -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(5, 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; diff --git a/static/images/add-gray.svg b/static/images/add-gray.svg new file mode 100644 index 0000000..b876269 --- /dev/null +++ b/static/images/add-gray.svg @@ -0,0 +1,76 @@ + + + + diff --git a/static/images/checklist-off.svg b/static/images/checklist-off.svg new file mode 100644 index 0000000..bea3071 --- /dev/null +++ b/static/images/checklist-off.svg @@ -0,0 +1,71 @@ + + + + diff --git a/static/images/checklist-on.svg b/static/images/checklist-on.svg new file mode 100644 index 0000000..3bd1daa --- /dev/null +++ b/static/images/checklist-on.svg @@ -0,0 +1,73 @@ + + + + diff --git a/static/images/edit-list-gray.svg b/static/images/edit-list-gray.svg new file mode 100644 index 0000000..8289ef3 --- /dev/null +++ b/static/images/edit-list-gray.svg @@ -0,0 +1,66 @@ + + + + diff --git a/static/images/edit-list.svg b/static/images/edit-list.svg new file mode 100644 index 0000000..3b28876 --- /dev/null +++ b/static/images/edit-list.svg @@ -0,0 +1,66 @@ + + + + diff --git a/static/images/edit.svg b/static/images/edit.svg new file mode 100644 index 0000000..81014fe --- /dev/null +++ b/static/images/edit.svg @@ -0,0 +1,66 @@ + + + + diff --git a/static/images/markdown-hollow.svg b/static/images/markdown-hollow.svg new file mode 100644 index 0000000..bd2e424 --- /dev/null +++ b/static/images/markdown-hollow.svg @@ -0,0 +1,49 @@ + + diff --git a/static/images/trashcan.svg b/static/images/trashcan.svg new file mode 100644 index 0000000..120cda9 --- /dev/null +++ b/static/images/trashcan.svg @@ -0,0 +1,66 @@ + + + + diff --git a/static/index.html b/static/index.html index 7ef717b..5ed40a5 100644 --- a/static/index.html +++ b/static/index.html @@ -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" } } +
diff --git a/static/js/app.mjs b/static/js/app.mjs index 3854b9c..ff2e7b8 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -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; } }//}}} diff --git a/static/js/checklist.mjs b/static/js/checklist.mjs new file mode 100644 index 0000000..b84cd8b --- /dev/null +++ b/static/js/checklist.mjs @@ -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` +