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/main.go b/main.go index eebe749..c78017a 100644 --- a/main.go +++ b/main.go @@ -20,6 +20,7 @@ import ( "path/filepath" "strconv" "strings" + "time" ) const LISTEN_HOST = "0.0.0.0" @@ -36,6 +37,7 @@ var ( static http.Handler config Config logger *slog.Logger + schedulers map[int]Schedule VERSION string //go:embed version sql/* @@ -63,6 +65,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") @@ -122,45 +126,25 @@ func main() { // {{{ os.Exit(0) } + go scheduleHandler() + err = service.Start() if err != nil { logger.Error("webserver", "error", err) os.Exit(1) } } // }}} +func scheduleHandler() { // {{{ + // Wait for the approximate minute. + wait := 60000 - time.Now().Sub(time.Now().Truncate(time.Minute)).Milliseconds() + time.Sleep(time.Millisecond * time.Duration(wait)) -/* -func userPassword(w http.ResponseWriter, r *http.Request) { // {{{ - var err error - var ok bool - var session Session + tick := time.NewTicker(time.Minute) + for { + <-tick.C - 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") diff --git a/node.go b/node.go index c65b4b4..1868122 100644 --- a/node.go +++ b/node.go @@ -6,6 +6,7 @@ import ( // Standard "time" + "database/sql" ) type ChecklistItem struct { @@ -323,8 +324,69 @@ func CreateNode(userID, parentID int, name string) (node Node, err error) { // { return } // }}} func UpdateNode(userID, nodeID int, content string, cryptoKeyID int, markdown bool) (err error) { // {{{ + var scannedSchedules, dbSchedules, add, remove []Schedule + scannedSchedules = ScanForSchedules(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 = '', @@ -345,7 +407,7 @@ 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, @@ -366,6 +428,12 @@ func UpdateNode(userID, nodeID int, content string, cryptoKeyID int, markdown bo markdown, ) } + if err != nil { + tsx.Rollback() + return + } + + err = tsx.Commit() return } // }}} diff --git a/schedule.go b/schedule.go new file mode 100644 index 0000000..d2c7aec --- /dev/null +++ b/schedule.go @@ -0,0 +1,140 @@ +package main + +import ( + // Standard + "encoding/json" + "fmt" + "regexp" + "strings" + "time" +) + +func init() { + fmt.Printf("") +} + +type Schedule struct { + ID int + UserID int `json:"user_id"` + Node Node + ScheduleUUID string `db:"schedule_uuid"` + Time time.Time + Description string + Acknowledged bool +} + +func ScanForSchedules(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*([^\]]+?)\s*\}`) + foundSchedules := rxp.FindAllStringSubmatch(content, -1) + + for _, data := range foundSchedules { + // Missing seconds + if strings.Count(data[1], ":") == 1 { + data[1] = data[1] + ":00" + } + + timestamp, err := time.Parse("2006-01-02 15:04:05", data[1]) + if err != nil { + continue + } + + schedule := Schedule{ + Time: timestamp, + Description: data[2], + } + schedules = append(schedules, schedule) + } + + 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.Description == b.Description +} + +func (s *Schedule) Insert(queryable Queryable) error { + res := queryable.QueryRow(` + INSERT INTO schedule(user_id, node_id, time, description) + VALUES($1, $2, $3, $4) + RETURNING id + `, + s.UserID, + s.Node.ID, + s.Time, + s.Description, + ) + + return res.Scan(&s.ID) +} + +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 RetrieveSchedules(userID int, nodeID int) (schedules []Schedule, err error) { + schedules = []Schedule{} + + res := service.Db.Conn.QueryRow(` + WITH schedule_events AS ( + SELECT + id, + user_id, + json_build_object('id', node_id) AS node, + schedule_uuid, + time::timestamptz, + description, + acknowledged + FROM schedule + 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 { + return + } + + err = json.Unmarshal(data, &schedules) + return +} + +func ExpiredSchedules() []Schedule { + schedules := []Schedule{} + res, err := service.Db.Conn.Query(` + SELECT + * + FROM schedule + WHERE + time < NOW() AND + NOT acknowledged + ORDER BY + time ASC + `) + return schedules +} 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 +);