Compare commits
25 commits
Author | SHA1 | Date | |
---|---|---|---|
|
60f20a754f | ||
|
c132f495b4 | ||
|
bcce516c66 | ||
|
e9967ebdc6 | ||
|
0a6eaed89f | ||
|
1a9d532d02 | ||
d9fa6fd477 | |||
|
8039dfaf42 | ||
|
0dcc1e1fd9 | ||
|
9d45d87ef3 | ||
|
2c16d7af60 | ||
|
b0496c8de1 | ||
|
3b8c6432b6 | ||
|
d186489f28 | ||
|
6a757c94b0 | ||
|
3669b7e6ec | ||
|
566cff5e94 | ||
|
83e1ce5ffe | ||
|
48c1227d9f | ||
|
49fd943110 | ||
|
f9f083367e | ||
|
eea0b1c0f6 | ||
|
90b73edc34 | ||
|
6782362af1 | ||
|
c352176417 |
18 changed files with 711 additions and 121 deletions
|
@ -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:
|
Create a configuration file `$HOME/.config/notes.yaml` with the following content:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
network:
|
||||||
|
address: '[::]'
|
||||||
|
port: 1371
|
||||||
|
|
||||||
websocket:
|
websocket:
|
||||||
domains:
|
domains:
|
||||||
- notes.com
|
- notes.com
|
||||||
|
|
59
main.go
59
main.go
|
@ -67,7 +67,7 @@ func logCallback(e WrappedError.Error) { // {{{
|
||||||
Month int
|
Month int
|
||||||
Day int
|
Day int
|
||||||
Time string
|
Time string
|
||||||
Error any
|
Error error
|
||||||
}{now.Year(), int(now.Month()), now.Day(), now.Format("15:04:05"), e}
|
}{now.Year(), int(now.Month()), now.Day(), now.Format("15:04:05"), e}
|
||||||
|
|
||||||
j, _ := json.Marshal(out)
|
j, _ := json.Marshal(out)
|
||||||
|
@ -147,6 +147,7 @@ func main() { // {{{
|
||||||
service.Register("/key/create", true, true, keyCreate)
|
service.Register("/key/create", true, true, keyCreate)
|
||||||
service.Register("/key/counter", true, true, keyCounter)
|
service.Register("/key/counter", true, true, keyCounter)
|
||||||
service.Register("/notification/ack", false, false, notificationAcknowledge)
|
service.Register("/notification/ack", false, false, notificationAcknowledge)
|
||||||
|
service.Register("/schedule/list", true, true, scheduleList)
|
||||||
service.Register("/", false, false, service.StaticHandler)
|
service.Register("/", false, false, service.StaticHandler)
|
||||||
|
|
||||||
if flagCreateUser {
|
if flagCreateUser {
|
||||||
|
@ -173,28 +174,6 @@ func main() { // {{{
|
||||||
os.Exit(1)
|
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))
|
|
||||||
tick := time.NewTicker(time.Minute)
|
|
||||||
for {
|
|
||||||
<-tick.C
|
|
||||||
for _, event := range ExpiredSchedules() {
|
|
||||||
notificationManager.Send(
|
|
||||||
event.UserID,
|
|
||||||
event.ScheduleUUID,
|
|
||||||
[]byte(
|
|
||||||
fmt.Sprintf(
|
|
||||||
"%s\n%s",
|
|
||||||
event.Time.Format("2006-01-02 15:04"),
|
|
||||||
event.Description,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} // }}}
|
|
||||||
|
|
||||||
func nodeTree(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
|
func nodeTree(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
|
||||||
logger.Info("webserver", "request", "/node/tree")
|
logger.Info("webserver", "request", "/node/tree")
|
||||||
|
@ -764,7 +743,7 @@ func keyCounter(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{
|
||||||
func notificationAcknowledge(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")
|
logger.Info("webserver", "request", "/notification/ack")
|
||||||
var err error
|
var err error
|
||||||
|
w.Header().Add("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
err = AcknowledgeNotification(r.URL.Query().Get("uuid"))
|
err = AcknowledgeNotification(r.URL.Query().Get("uuid"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -776,4 +755,36 @@ func notificationAcknowledge(w http.ResponseWriter, r *http.Request, sess *sessi
|
||||||
"OK": true,
|
"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
|
// vim: foldmethod=marker
|
||||||
|
|
15
node.go
15
node.go
|
@ -3,6 +3,7 @@ package main
|
||||||
import (
|
import (
|
||||||
// External
|
// External
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
|
werr "git.gibonuddevalla.se/go/wrappederror"
|
||||||
|
|
||||||
// Standard
|
// Standard
|
||||||
"time"
|
"time"
|
||||||
|
@ -324,8 +325,20 @@ func CreateNode(userID, parentID int, name string) (node Node, err error) { // {
|
||||||
return
|
return
|
||||||
} // }}}
|
} // }}}
|
||||||
func UpdateNode(userID, nodeID, timeOffset 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
|
var scannedSchedules, dbSchedules, add, remove []Schedule
|
||||||
scannedSchedules = ScanForSchedules(timeOffset, content)
|
scannedSchedules = ScanForSchedules(timezone, content)
|
||||||
for i := range scannedSchedules {
|
for i := range scannedSchedules {
|
||||||
scannedSchedules[i].Node.ID = nodeID
|
scannedSchedules[i].Node.ID = nodeID
|
||||||
scannedSchedules[i].UserID = userID
|
scannedSchedules[i].UserID = userID
|
||||||
|
|
|
@ -44,6 +44,8 @@ func (ntfy NTFY) Send(uuid string, msg []byte) (err error) {
|
||||||
|
|
||||||
ackURL := fmt.Sprintf("http, OK, %s/notification/ack?uuid=%s", ntfy.AcknowledgeURL, uuid)
|
ackURL := fmt.Sprintf("http, OK, %s/notification/ack?uuid=%s", ntfy.AcknowledgeURL, uuid)
|
||||||
req.Header.Add("X-Actions", ackURL)
|
req.Header.Add("X-Actions", ackURL)
|
||||||
|
req.Header.Add("X-Priority", "5")
|
||||||
|
req.Header.Add("X-Tags", "calendar")
|
||||||
|
|
||||||
res, err = http.DefaultClient.Do(req)
|
res, err = http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -37,7 +37,7 @@ func InitNotificationManager() (err error) {// {{{
|
||||||
user_id ASC,
|
user_id ASC,
|
||||||
prio ASC
|
prio ASC
|
||||||
)
|
)
|
||||||
SELECT jsonb_agg(s.*)
|
SELECT COALESCE(jsonb_agg(s.*), '[]')
|
||||||
FROM services s
|
FROM services s
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
// External
|
||||||
|
werr "git.gibonuddevalla.se/go/wrappederror"
|
||||||
|
|
||||||
// Standard
|
// Standard
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
@ -18,6 +21,8 @@ func responseError(w http.ResponseWriter, err error) {
|
||||||
}
|
}
|
||||||
resJSON, _ := json.Marshal(res)
|
resJSON, _ := json.Marshal(res)
|
||||||
|
|
||||||
|
|
||||||
|
werr.Wrap(err).Log()
|
||||||
w.Header().Add("Content-Type", "application/json")
|
w.Header().Add("Content-Type", "application/json")
|
||||||
w.Write(resJSON)
|
w.Write(resJSON)
|
||||||
}
|
}
|
||||||
|
|
184
schedule.go
184
schedule.go
|
@ -8,6 +8,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
@ -22,14 +23,40 @@ type Schedule struct {
|
||||||
Node Node
|
Node Node
|
||||||
ScheduleUUID string `db:"schedule_uuid"`
|
ScheduleUUID string `db:"schedule_uuid"`
|
||||||
Time time.Time
|
Time time.Time
|
||||||
|
RemindMinutes int `db:"remind_minutes"`
|
||||||
Description string
|
Description string
|
||||||
Acknowledged bool
|
Acknowledged bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func ScanForSchedules(timeOffset int, content string) (schedules []Schedule) {// {{{
|
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{}
|
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*\}`)
|
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)
|
foundSchedules := rxp.FindAllStringSubmatch(content, -1)
|
||||||
|
|
||||||
for _, data := range foundSchedules {
|
for _, data := range foundSchedules {
|
||||||
|
@ -38,25 +65,35 @@ func ScanForSchedules(timeOffset int, content string) (schedules []Schedule) {//
|
||||||
data[1] = data[1] + ":00"
|
data[1] = data[1] + ":00"
|
||||||
}
|
}
|
||||||
|
|
||||||
var timeTZ string
|
logger.Info("time", "parse", data[1])
|
||||||
if timeOffset < 0 {
|
location, err := time.LoadLocation(timezone)
|
||||||
hours := (-timeOffset) / 60
|
if err != nil {
|
||||||
mins := (-timeOffset) % 60
|
err = werr.Wrap(err).WithCode("002-00010")
|
||||||
timeTZ = fmt.Sprintf("%s -%02d%02d", data[1], hours, mins)
|
return
|
||||||
} else {
|
|
||||||
hours := timeOffset / 60
|
|
||||||
mins := timeOffset % 60
|
|
||||||
timeTZ = fmt.Sprintf("%s +%02d%02d", data[1], hours, mins)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
timestamp, err := time.Parse("2006-01-02 15:04:05 -0700", timeTZ)
|
timestamp, err := time.ParseInLocation("2006-01-02 15:04:05", data[1], location)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
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{
|
schedule := Schedule{
|
||||||
Time: timestamp,
|
Time: timestamp,
|
||||||
Description: data[2],
|
RemindMinutes: remindMinutes,
|
||||||
|
Description: data[4],
|
||||||
}
|
}
|
||||||
schedules = append(schedules, schedule)
|
schedules = append(schedules, schedule)
|
||||||
}
|
}
|
||||||
|
@ -69,14 +106,15 @@ func RetrieveSchedules(userID int, nodeID int) (schedules []Schedule, err error)
|
||||||
res := service.Db.Conn.QueryRow(`
|
res := service.Db.Conn.QueryRow(`
|
||||||
WITH schedule_events AS (
|
WITH schedule_events AS (
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
s.id,
|
||||||
user_id,
|
s.user_id,
|
||||||
json_build_object('id', node_id) AS node,
|
json_build_object('id', s.node_id) AS node,
|
||||||
schedule_uuid,
|
s.schedule_uuid,
|
||||||
time,
|
(time - MAKE_INTERVAL(mins => s.remind_minutes)) AT TIME ZONE u.timezone AS time,
|
||||||
description,
|
s.description,
|
||||||
acknowledged
|
s.acknowledged
|
||||||
FROM schedule
|
FROM schedule s
|
||||||
|
INNER JOIN _webservice.user u ON s.user_id = u.id
|
||||||
WHERE
|
WHERE
|
||||||
user_id=$1 AND
|
user_id=$1 AND
|
||||||
CASE
|
CASE
|
||||||
|
@ -95,6 +133,7 @@ func RetrieveSchedules(userID int, nodeID int) (schedules []Schedule, err error)
|
||||||
var data []byte
|
var data []byte
|
||||||
err = res.Scan(&data)
|
err = res.Scan(&data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
err = werr.Wrap(err).WithCode("002-000E")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -106,21 +145,27 @@ func (a Schedule) IsEqual(b Schedule) bool {// {{{
|
||||||
return a.UserID == b.UserID &&
|
return a.UserID == b.UserID &&
|
||||||
a.Node.ID == b.Node.ID &&
|
a.Node.ID == b.Node.ID &&
|
||||||
a.Time.Equal(b.Time) &&
|
a.Time.Equal(b.Time) &&
|
||||||
|
a.RemindMinutes == b.RemindMinutes &&
|
||||||
a.Description == b.Description
|
a.Description == b.Description
|
||||||
} // }}}
|
} // }}}
|
||||||
func (s *Schedule) Insert(queryable Queryable) error { // {{{
|
func (s *Schedule) Insert(queryable Queryable) error { // {{{
|
||||||
res := queryable.QueryRow(`
|
res := queryable.QueryRow(`
|
||||||
INSERT INTO schedule(user_id, node_id, time, description)
|
INSERT INTO schedule(user_id, node_id, time, remind_minutes, description)
|
||||||
VALUES($1, $2, $3, $4)
|
VALUES($1, $2, $3, $4, $5)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
s.UserID,
|
s.UserID,
|
||||||
s.Node.ID,
|
s.Node.ID,
|
||||||
s.Time,
|
s.Time.Format("2006-01-02 15:04:05"),
|
||||||
|
s.RemindMinutes,
|
||||||
s.Description,
|
s.Description,
|
||||||
)
|
)
|
||||||
|
|
||||||
return res.Scan(&s.ID)
|
err := res.Scan(&s.ID)
|
||||||
|
if err != nil {
|
||||||
|
err = werr.Wrap(err).WithCode("002-000D")
|
||||||
|
}
|
||||||
|
return err
|
||||||
} // }}}
|
} // }}}
|
||||||
func (s *Schedule) Delete(queryable Queryable) error { // {{{
|
func (s *Schedule) Delete(queryable Queryable) error { // {{{
|
||||||
_, err := queryable.Exec(`
|
_, err := queryable.Exec(`
|
||||||
|
@ -140,15 +185,16 @@ func ExpiredSchedules() (schedules []Schedule) {// {{{
|
||||||
|
|
||||||
res, err := service.Db.Conn.Queryx(`
|
res, err := service.Db.Conn.Queryx(`
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
s.id,
|
||||||
user_id,
|
s.user_id,
|
||||||
node_id,
|
s.node_id,
|
||||||
schedule_uuid,
|
s.schedule_uuid,
|
||||||
time,
|
(s.time - MAKE_INTERVAL(mins => s.remind_minutes)) AT TIME ZONE u.timezone AS time,
|
||||||
description
|
s.description
|
||||||
FROM schedule
|
FROM schedule s
|
||||||
|
INNER JOIN _webservice.user u ON s.user_id = u.id
|
||||||
WHERE
|
WHERE
|
||||||
time < NOW() AND
|
(time - MAKE_INTERVAL(mins => remind_minutes)) AT TIME ZONE u.timezone < NOW() AND
|
||||||
NOT acknowledged
|
NOT acknowledged
|
||||||
ORDER BY
|
ORDER BY
|
||||||
time ASC
|
time ASC
|
||||||
|
@ -169,3 +215,75 @@ func ExpiredSchedules() (schedules []Schedule) {// {{{
|
||||||
}
|
}
|
||||||
return
|
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
|
||||||
|
} // }}}
|
||||||
|
|
1
sql/00019.sql
Normal file
1
sql/00019.sql
Normal file
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE public.schedule ADD COLUMN remind_minutes int NOT NULL DEFAULT 0;
|
2
sql/00020.sql
Normal file
2
sql/00020.sql
Normal 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
1
sql/00021.sql
Normal file
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE public.node ALTER COLUMN updated TYPE timestamptz USING updated::timestamptz;
|
|
@ -13,6 +13,11 @@ html {
|
||||||
*:after {
|
*:after {
|
||||||
box-sizing: inherit;
|
box-sizing: inherit;
|
||||||
}
|
}
|
||||||
|
*,
|
||||||
|
*:focus,
|
||||||
|
*:hover {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
[onClick] {
|
[onClick] {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
@ -29,12 +34,16 @@ body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 1.5em;
|
|
||||||
color: #518048;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-size: 1.25em;
|
font-size: 1.25em;
|
||||||
color: #518048;
|
color: #518048;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 1em;
|
||||||
|
color: #518048;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 1em;
|
||||||
}
|
}
|
||||||
button {
|
button {
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
|
@ -203,6 +212,7 @@ header .menu {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background-color: #333;
|
background-color: #333;
|
||||||
color: #ddd;
|
color: #ddd;
|
||||||
|
z-index: 100;
|
||||||
}
|
}
|
||||||
#tree .node {
|
#tree .node {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
@ -408,6 +418,7 @@ header .menu {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
#checklist .checklist-item {
|
#checklist .checklist-item {
|
||||||
|
transform: translate(0, 0);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, min-content);
|
grid-template-columns: repeat(3, min-content);
|
||||||
grid-gap: 0 8px;
|
grid-gap: 0 8px;
|
||||||
|
@ -521,7 +532,7 @@ header .menu {
|
||||||
grid-area: 1 / 1 / 2 / 2;
|
grid-area: 1 / 1 / 2 / 2;
|
||||||
}
|
}
|
||||||
/* ============================================================= */
|
/* ============================================================= */
|
||||||
#file-section {
|
#schedule-section {
|
||||||
grid-area: files;
|
grid-area: files;
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
width: calc(100% - 32px);
|
width: calc(100% - 32px);
|
||||||
|
@ -531,6 +542,23 @@ header .menu {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-top: 32px;
|
margin-top: 32px;
|
||||||
margin-bottom: 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 {
|
#file-section .header {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
@ -629,9 +657,9 @@ header .menu {
|
||||||
}
|
}
|
||||||
.layout-tree {
|
.layout-tree {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas: "header header" "tree crumbs" "tree child-nodes" "tree name" "tree content" "tree checklist" "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-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 /* checklist */ 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 */
|
/* blank */
|
||||||
color: #fff;
|
color: #fff;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
|
@ -664,9 +692,9 @@ header .menu {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.layout-crumbs {
|
.layout-crumbs {
|
||||||
grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "files" "blank";
|
grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "schedule" "files" "blank";
|
||||||
grid-template-columns: 1fr;
|
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 /* checklist */ 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 */
|
/* blank */
|
||||||
}
|
}
|
||||||
.layout-crumbs #tree {
|
.layout-crumbs #tree {
|
||||||
|
@ -713,17 +741,17 @@ header .menu {
|
||||||
}
|
}
|
||||||
#app.node {
|
#app.node {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas: "header header" "tree crumbs" "tree child-nodes" "tree name" "tree content" "tree checklist" "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-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 /* checklist */ 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 */
|
/* blank */
|
||||||
color: #fff;
|
color: #fff;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
#app.node.toggle-tree {
|
#app.node.toggle-tree {
|
||||||
grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "files" "blank";
|
grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "schedule" "files" "blank";
|
||||||
grid-template-columns: 1fr;
|
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 /* checklist */ 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 */
|
/* blank */
|
||||||
}
|
}
|
||||||
#app.node.toggle-tree #tree {
|
#app.node.toggle-tree #tree {
|
||||||
|
@ -745,11 +773,72 @@ header .menu {
|
||||||
#profile-settings .passwords div {
|
#profile-settings .passwords div {
|
||||||
white-space: nowrap;
|
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) {
|
@media only screen and (max-width: 932px) {
|
||||||
#app.node {
|
#app.node {
|
||||||
grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "files" "blank";
|
grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "schedule" "files" "blank";
|
||||||
grid-template-columns: 1fr;
|
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 /* checklist */ 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 */
|
/* blank */
|
||||||
}
|
}
|
||||||
#app.node #tree {
|
#app.node #tree {
|
||||||
|
|
|
@ -27,6 +27,7 @@
|
||||||
</script>
|
</script>
|
||||||
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/sjcl.js"></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/node_modules/marked/marked.min.js"></script>
|
||||||
|
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/fullcalendar.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
|
@ -113,7 +113,7 @@ class App extends Component {
|
||||||
this.websocket.register('open', ()=>console.log('websocket connected'))
|
this.websocket.register('open', ()=>console.log('websocket connected'))
|
||||||
this.websocket.register('close', ()=>console.log('websocket disconnected'))
|
this.websocket.register('close', ()=>console.log('websocket disconnected'))
|
||||||
this.websocket.register('error', msg=>console.log(msg))
|
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()
|
this.websocket.start()
|
||||||
}//}}}
|
}//}}}
|
||||||
websocketMessage(data) {//{{{
|
websocketMessage(data) {//{{{
|
||||||
|
@ -121,7 +121,7 @@ class App extends Component {
|
||||||
|
|
||||||
switch (msg.Op) {
|
switch (msg.Op) {
|
||||||
case 'css_reload':
|
case 'css_reload':
|
||||||
refreshCSS()
|
this.websocket.refreshCSS()
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}//}}}
|
}//}}}
|
||||||
|
|
|
@ -110,10 +110,11 @@ export class Checklist extends Component {
|
||||||
this.groupElements = {}
|
this.groupElements = {}
|
||||||
this.state = {
|
this.state = {
|
||||||
confirmDeletion: true,
|
confirmDeletion: true,
|
||||||
|
continueAddingItems: true,
|
||||||
}
|
}
|
||||||
window._checklist = this
|
window._checklist = this
|
||||||
}//}}}
|
}//}}}
|
||||||
render({ ui, groups }, { confirmDeletion }) {//{{{
|
render({ ui, groups }, { confirmDeletion, continueAddingItems }) {//{{{
|
||||||
this.groupElements = {}
|
this.groupElements = {}
|
||||||
if (groups.length == 0 && !ui.node.value.ShowChecklist.value)
|
if (groups.length == 0 && !ui.node.value.ShowChecklist.value)
|
||||||
return
|
return
|
||||||
|
@ -136,6 +137,10 @@ export class Checklist extends Component {
|
||||||
<input type="checkbox" id="confirm-checklist-delete" checked=${confirmDeletion} onchange=${() => this.setState({ confirmDeletion: !confirmDeletion })} />
|
<input type="checkbox" id="confirm-checklist-delete" checked=${confirmDeletion} onchange=${() => this.setState({ confirmDeletion: !confirmDeletion })} />
|
||||||
<label for="confirm-checklist-delete">Confirm checklist deletion</label>
|
<label for="confirm-checklist-delete">Confirm checklist deletion</label>
|
||||||
</div>
|
</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>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -194,10 +199,62 @@ export class Checklist extends Component {
|
||||||
}//}}}
|
}//}}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
class ChecklistGroupElement extends Component {
|
||||||
constructor() {//{{{
|
constructor() {//{{{
|
||||||
super()
|
super()
|
||||||
this.label = createRef()
|
this.label = createRef()
|
||||||
|
this.addingItem = signal(false)
|
||||||
}//}}}
|
}//}}}
|
||||||
render({ ui, group }) {//{{{
|
render({ ui, group }) {//{{{
|
||||||
let items = ({ ui, group }) =>
|
let items = ({ ui, group }) =>
|
||||||
|
@ -206,30 +263,42 @@ class ChecklistGroupElement extends Component {
|
||||||
.map(item => html`<${ChecklistItemElement} key="item-${item.ID}" ui=${ui} group=${this} item=${item} />`)
|
.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 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`
|
return html`
|
||||||
|
<${addItem} />
|
||||||
<div class="checklist-group-container">
|
<div class="checklist-group-container">
|
||||||
<div class="checklist-group ${ui.edit.value ? 'edit' : ''}">
|
<div class="checklist-group ${ui.edit.value ? 'edit' : ''}">
|
||||||
<div class="reorder" style="cursor: grab">☰</div>
|
<div class="reorder" style="cursor: grab">☰</div>
|
||||||
<img src="/images/${_VERSION}/trashcan.svg" onclick=${() => this.delete()} />
|
<img src="/images/${_VERSION}/trashcan.svg" onclick=${() => this.delete()} />
|
||||||
<${label} />
|
<${label} />
|
||||||
<img src="/images/${_VERSION}/add-gray.svg" onclick=${() => this.addItem()} />
|
<img src="/images/${_VERSION}/add-gray.svg" onclick=${() => this.addingItem.value = true} />
|
||||||
</div>
|
</div>
|
||||||
<${items} ui=${ui} group=${group} />
|
<${items} ui=${ui} group=${group} />
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
}//}}}
|
}//}}}
|
||||||
addItem() {//{{{
|
addItem(ok, label) {//{{{
|
||||||
let label = prompt("Create a new item")
|
if (!ok) {
|
||||||
if (label === null)
|
this.addingItem.value = false
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
label = label.trim()
|
label = label.trim()
|
||||||
if (label == '')
|
if (label == '') {
|
||||||
|
this.addingItem.value = false
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
this.props.group.addItem(label, () => {
|
this.props.group.addItem(label, () => {
|
||||||
this.forceUpdate()
|
this.forceUpdate()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!this.props.ui.state.continueAddingItems)
|
||||||
|
this.addingItem.value = false
|
||||||
}//}}}
|
}//}}}
|
||||||
editLabel() {//{{{
|
editLabel() {//{{{
|
||||||
let label = prompt('Edit label', this.props.group.Label)
|
let label = prompt('Edit label', this.props.group.Label)
|
||||||
|
@ -299,7 +368,7 @@ class ChecklistItemElement extends Component {
|
||||||
`
|
`
|
||||||
}//}}}
|
}//}}}
|
||||||
componentDidMount() {//{{{
|
componentDidMount() {//{{{
|
||||||
this.base.addEventListener('dragstart', () => this.dragStart())
|
this.base.addEventListener('dragstart', evt => this.dragStart(evt))
|
||||||
this.base.addEventListener('dragend', () => this.dragEnd())
|
this.base.addEventListener('dragend', () => this.dragEnd())
|
||||||
this.base.addEventListener('dragenter', evt => this.dragEnter(evt))
|
this.base.addEventListener('dragenter', evt => this.dragEnter(evt))
|
||||||
}//}}}
|
}//}}}
|
||||||
|
@ -353,10 +422,13 @@ class ChecklistItemElement extends Component {
|
||||||
setDragTarget(state) {//{{{
|
setDragTarget(state) {//{{{
|
||||||
this.setState({ dragTarget: state })
|
this.setState({ dragTarget: state })
|
||||||
}//}}}
|
}//}}}
|
||||||
dragStart() {//{{{
|
dragStart(evt) {//{{{
|
||||||
// Shouldn't be needed, but in case the previous drag was bungled up, we reset.
|
// Shouldn't be needed, but in case the previous drag was bungled up, we reset.
|
||||||
this.props.ui.dragReset()
|
this.props.ui.dragReset()
|
||||||
this.props.ui.dragItemSource = this
|
this.props.ui.dragItemSource = this
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
evt.dataTransfer.setDragImage(img, 10, 10);
|
||||||
}//}}}
|
}//}}}
|
||||||
dragEnter(evt) {//{{{
|
dragEnter(evt) {//{{{
|
||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
|
@ -393,7 +465,7 @@ class ChecklistItemElement extends Component {
|
||||||
this.props.ui.groupElements[fromGroup.ID].current.forceUpdate()
|
this.props.ui.groupElements[fromGroup.ID].current.forceUpdate()
|
||||||
this.props.ui.groupElements[toGroup.ID].current.forceUpdate()
|
this.props.ui.groupElements[toGroup.ID].current.forceUpdate()
|
||||||
|
|
||||||
from.move(to, ()=>console.log('ok'))
|
from.move(to, () => {})
|
||||||
}//}}}
|
}//}}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
6
static/js/lib/fullcalendar.min.js
vendored
Normal file
6
static/js/lib/fullcalendar.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
@ -14,7 +14,6 @@ export class NodeUI extends Component {
|
||||||
this.nodeContent = createRef()
|
this.nodeContent = createRef()
|
||||||
this.nodeProperties = createRef()
|
this.nodeProperties = createRef()
|
||||||
this.keys = signal([])
|
this.keys = signal([])
|
||||||
|
|
||||||
this.page = signal('node')
|
this.page = signal('node')
|
||||||
window.addEventListener('popstate', evt => {
|
window.addEventListener('popstate', evt => {
|
||||||
if (evt.state && evt.state.hasOwnProperty('nodeID'))
|
if (evt.state && evt.state.hasOwnProperty('nodeID'))
|
||||||
|
@ -59,6 +58,7 @@ export class NodeUI extends Component {
|
||||||
case 'node':
|
case 'node':
|
||||||
if (node.ID == 0) {
|
if (node.ID == 0) {
|
||||||
page = html`
|
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``}
|
${children.length > 0 ? html`<div class="child-nodes">${children}</div><div id="notes-version">Notes version ${window._VERSION}</div>` : html``}
|
||||||
`
|
`
|
||||||
} else {
|
} else {
|
||||||
|
@ -72,6 +72,7 @@ export class NodeUI extends Component {
|
||||||
${node.Name} ${padlock}
|
${node.Name} ${padlock}
|
||||||
</div>
|
</div>
|
||||||
<${NodeContent} key=${node.ID} node=${node} ref=${this.nodeContent} />
|
<${NodeContent} key=${node.ID} node=${node} ref=${this.nodeContent} />
|
||||||
|
<${NodeEvents} events=${node.ScheduleEvents.value} />
|
||||||
<${Checklist} ui=${this} groups=${node.ChecklistGroups} />
|
<${Checklist} ui=${this} groups=${node.ChecklistGroups} />
|
||||||
<${NodeFiles} node=${this.node.value} />
|
<${NodeFiles} node=${this.node.value} />
|
||||||
`
|
`
|
||||||
|
@ -97,6 +98,10 @@ export class NodeUI extends Component {
|
||||||
case 'search':
|
case 'search':
|
||||||
page = html`<${Search} nodeui=${this} />`
|
page = html`<${Search} nodeui=${this} />`
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case 'schedule-events':
|
||||||
|
page = html`<${ScheduleEventList} nodeui=${this} />`
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
let menu = () => (this.menu.value ? html`<${Menu} nodeui=${this} />` : null)
|
let menu = () => (this.menu.value ? html`<${Menu} nodeui=${this} />` : null)
|
||||||
|
@ -245,15 +250,12 @@ export class NodeUI extends Component {
|
||||||
})
|
})
|
||||||
}//}}}
|
}//}}}
|
||||||
saveNode() {//{{{
|
saveNode() {//{{{
|
||||||
/*
|
|
||||||
let nodeContent = this.nodeContent.current
|
|
||||||
if (this.page.value != 'node' || nodeContent === null)
|
|
||||||
return
|
|
||||||
*/
|
|
||||||
|
|
||||||
let content = this.node.value.content()
|
let content = this.node.value.content()
|
||||||
this.node.value.setContent(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() {//{{{
|
renameNode() {//{{{
|
||||||
let name = prompt("New name")
|
let name = prompt("New name")
|
||||||
|
@ -384,6 +386,24 @@ class MarkdownContent extends Component {
|
||||||
}//}}}
|
}//}}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
class NodeFiles extends Component {
|
||||||
render({ node }) {//{{{
|
render({ node }) {//{{{
|
||||||
if (node.Files === null || node.Files.length == 0)
|
if (node.Files === null || node.Files.length == 0)
|
||||||
|
@ -438,10 +458,16 @@ export class Node {
|
||||||
this._decrypted = false
|
this._decrypted = false
|
||||||
this._expanded = false // start value for the TreeNode component,
|
this._expanded = false // start value for the TreeNode component,
|
||||||
this.ChecklistGroups = {}
|
this.ChecklistGroups = {}
|
||||||
|
this.ScheduleEvents = signal([])
|
||||||
// it doesn't control it afterwards.
|
// it doesn't control it afterwards.
|
||||||
// Used to expand the crumbs upon site loading.
|
// Used to expand the crumbs upon site loading.
|
||||||
}//}}}
|
}//}}}
|
||||||
retrieve(callback) {//{{{
|
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 })
|
this.app.request('/node/retrieve', { ID: this.ID })
|
||||||
.then(res => {
|
.then(res => {
|
||||||
this.ParentID = res.Node.ParentID
|
this.ParentID = res.Node.ParentID
|
||||||
|
@ -959,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
|
// vim: foldmethod=marker
|
||||||
|
|
|
@ -8,6 +8,10 @@ html {
|
||||||
box-sizing: inherit;
|
box-sizing: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
*,*:focus,*:hover{
|
||||||
|
outline:none;
|
||||||
|
}
|
||||||
|
|
||||||
[onClick] {
|
[onClick] {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
@ -27,15 +31,20 @@ html, body {
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 1.5em;
|
font-size: 1.25em;
|
||||||
color: @header_1;
|
color: @header_1;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 1.25em;
|
font-size: 1.0em;
|
||||||
color: @header_1;
|
color: @header_1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.0em;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
|
@ -222,6 +231,7 @@ header {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background-color: #333;
|
background-color: #333;
|
||||||
color: #ddd;
|
color: #ddd;
|
||||||
|
z-index: 100; // Over crumbs shadow
|
||||||
|
|
||||||
.node {
|
.node {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
@ -470,6 +480,7 @@ header {
|
||||||
}
|
}
|
||||||
|
|
||||||
.checklist-item {
|
.checklist-item {
|
||||||
|
transform: translate(0, 0);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, min-content);
|
grid-template-columns: repeat(3, min-content);
|
||||||
grid-gap: 0 8px;
|
grid-gap: 0 8px;
|
||||||
|
@ -611,7 +622,7 @@ header {
|
||||||
}
|
}
|
||||||
/* ============================================================= */
|
/* ============================================================= */
|
||||||
|
|
||||||
#file-section {
|
#schedule-section {
|
||||||
grid-area: files;
|
grid-area: files;
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
width: calc(100% - 32px);
|
width: calc(100% - 32px);
|
||||||
|
@ -621,6 +632,25 @@ header {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-top: 32px;
|
margin-top: 32px;
|
||||||
margin-bottom: 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 {
|
.header {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
@ -745,6 +775,7 @@ header {
|
||||||
"tree name"
|
"tree name"
|
||||||
"tree content"
|
"tree content"
|
||||||
"tree checklist"
|
"tree checklist"
|
||||||
|
"tree schedule"
|
||||||
"tree files"
|
"tree files"
|
||||||
"tree blank"
|
"tree blank"
|
||||||
;
|
;
|
||||||
|
@ -756,6 +787,7 @@ header {
|
||||||
min-content /* name */
|
min-content /* name */
|
||||||
min-content /* content */
|
min-content /* content */
|
||||||
min-content /* checklist */
|
min-content /* checklist */
|
||||||
|
min-content /* schedule */
|
||||||
min-content /* files */
|
min-content /* files */
|
||||||
1fr; /* blank */
|
1fr; /* blank */
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
@ -789,6 +821,7 @@ header {
|
||||||
"name"
|
"name"
|
||||||
"content"
|
"content"
|
||||||
"checklist"
|
"checklist"
|
||||||
|
"schedule"
|
||||||
"files"
|
"files"
|
||||||
"blank"
|
"blank"
|
||||||
;
|
;
|
||||||
|
@ -800,6 +833,7 @@ header {
|
||||||
min-content /* name */
|
min-content /* name */
|
||||||
min-content /* content */
|
min-content /* content */
|
||||||
min-content /* checklist */
|
min-content /* checklist */
|
||||||
|
min-content /* schedule */
|
||||||
min-content /* files */
|
min-content /* files */
|
||||||
1fr; /* blank */
|
1fr; /* blank */
|
||||||
#tree { display: none }
|
#tree { display: none }
|
||||||
|
@ -863,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) {
|
@media only screen and (max-width: 932px) {
|
||||||
#app.node {
|
#app.node {
|
||||||
.layout-crumbs();
|
.layout-crumbs();
|
||||||
|
|
2
version
2
version
|
@ -1 +1 @@
|
||||||
v24
|
v29
|
||||||
|
|
Loading…
Add table
Reference in a new issue