Compare commits

...

29 Commits
v22 ... main

Author SHA1 Message Date
Magnus Åhall
60f20a754f Removed unnecessary debug log 2024-04-19 18:44:12 +02:00
Magnus Åhall
c132f495b4 Added back a deleted URL character 2024-04-19 18:42:41 +02:00
Magnus Åhall
bcce516c66 Fixed calendar aspect ratio to my liking 2024-04-19 18:36:41 +02:00
Magnus Åhall
e9967ebdc6 Bumped to v29 2024-04-19 18:32:44 +02:00
Magnus Åhall
0a6eaed89f #8, added fullcalendar 2024-04-19 18:32:37 +02:00
Magnus Åhall
1a9d532d02 Fixed #7 2024-04-19 15:35:16 +02:00
d9fa6fd477 Update README.md 2024-04-19 13:34:30 +00:00
Magnus Åhall
8039dfaf42 Bumped to v28 2024-04-18 20:08:25 +02:00
Magnus Åhall
0dcc1e1fd9 Fixed bug deleting all schedules 2024-04-18 20:08:07 +02:00
Magnus Åhall
9d45d87ef3 #6, initial continuous adding of items 2024-04-18 07:47:35 +02:00
Magnus Åhall
2c16d7af60 Removed unnecessary logging 2024-04-17 18:48:25 +02:00
Magnus Åhall
b0496c8de1 Bumped to v27 2024-04-17 18:44:22 +02:00
Magnus Åhall
3b8c6432b6 Event schedule 2024-04-17 18:43:24 +02:00
Magnus Åhall
d186489f28 Fix crumbs shadow over node tree 2024-04-05 09:31:41 +02:00
Magnus Åhall
6a757c94b0 Fixed automatic CSS refresh 2024-04-05 09:27:47 +02:00
Magnus Åhall
3669b7e6ec Added wrappederror explicit logging 2024-04-05 09:01:59 +02:00
Magnus Åhall
566cff5e94 Smaller ui changes 2024-04-05 09:01:59 +02:00
Magnus Åhall
83e1ce5ffe Added SQL 2024-04-05 09:01:54 +02:00
Magnus Åhall
48c1227d9f wip 2024-04-05 08:59:59 +02:00
Magnus Åhall
49fd943110 Added timezone to user 2024-04-03 17:37:32 +02:00
Magnus Åhall
f9f083367e Fixed #1 2024-03-30 22:26:26 +01:00
Magnus Åhall
eea0b1c0f6 Bumped to v26 2024-03-30 17:59:15 +01:00
Magnus Åhall
90b73edc34 Fixed CORS 2024-03-30 17:58:51 +01:00
Magnus Åhall
6782362af1 Bumped to v25 2024-03-30 17:43:34 +01:00
Magnus Åhall
c352176417 Implemented reminder 2024-03-30 17:43:21 +01:00
Magnus Åhall
d89d63803f Bumped to v24 2024-03-30 09:57:25 +01:00
Magnus Åhall
27e493945a Better timezone handling 2024-03-30 09:46:48 +01:00
Magnus Åhall
aee1c25f54 Bumped to v23 2024-03-29 20:54:51 +01:00
Magnus Åhall
7de9feea58 Restore minute-based schedule 2024-03-29 20:54:42 +01:00
20 changed files with 720 additions and 116 deletions

View File

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

64
main.go
View File

@ -67,7 +67,7 @@ func logCallback(e WrappedError.Error) { // {{{
Month int
Day int
Time string
Error any
Error error
}{now.Year(), int(now.Month()), now.Day(), now.Format("15:04:05"), e}
j, _ := json.Marshal(out)
@ -147,6 +147,7 @@ func main() { // {{{
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 {
@ -173,29 +174,6 @@ func main() { // {{{
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)
tick = time.NewTicker(time.Second*5)
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) { // {{{
logger.Info("webserver", "request", "/node/tree")
@ -272,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
@ -761,11 +740,10 @@ 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 {
@ -777,4 +755,36 @@ func notificationAcknowledge(w http.ResponseWriter, r *http.Request, sess *sessi
"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

17
node.go
View File

@ -3,6 +3,7 @@ package main
import (
// External
"github.com/jmoiron/sqlx"
werr "git.gibonuddevalla.se/go/wrappederror"
// Standard
"time"
@ -323,9 +324,21 @@ 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(content)
scannedSchedules = ScanForSchedules(timezone, content)
for i := range scannedSchedules {
scannedSchedules[i].Node.ID = nodeID
scannedSchedules[i].UserID = userID

View File

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

View File

@ -44,6 +44,7 @@ func (nm *Manager) AddService(userID int, service Service) {
}
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")

View File

@ -37,7 +37,7 @@ func InitNotificationManager() (err error) {// {{{
user_id ASC,
prio ASC
)
SELECT jsonb_agg(s.*)
SELECT COALESCE(jsonb_agg(s.*), '[]')
FROM services s
`,
)

View File

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

View File

@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
@ -17,19 +18,45 @@ func init() {
}
type Schedule struct {
ID int
UserID int `json:"user_id" db:"user_id"`
Node Node
ScheduleUUID string `db:"schedule_uuid"`
Time time.Time
Description string
Acknowledged bool
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 ScanForSchedules(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{}
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)
for _, data := range foundSchedules {
@ -38,34 +65,56 @@ func ScanForSchedules(content string) (schedules []Schedule) {// {{{
data[1] = data[1] + ":00"
}
timestamp, err := time.Parse("2006-01-02 15:04:05", data[1])
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,
Description: data[2],
Time: timestamp,
RemindMinutes: remindMinutes,
Description: data[4],
}
schedules = append(schedules, schedule)
}
return
}// }}}
func RetrieveSchedules(userID int, nodeID int) (schedules []Schedule, err error) {// {{{
} // }}}
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
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
@ -84,34 +133,41 @@ func RetrieveSchedules(userID int, nodeID int) (schedules []Schedule, err error)
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 {// {{{
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 {// {{{
} // }}}
func (s *Schedule) Insert(queryable Queryable) error { // {{{
res := queryable.QueryRow(`
INSERT INTO schedule(user_id, node_id, time, description)
VALUES($1, $2, $3, $4)
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,
s.Time.Format("2006-01-02 15:04:05"),
s.RemindMinutes,
s.Description,
)
return res.Scan(&s.ID)
}// }}}
func (s *Schedule) Delete(queryable Queryable) error {// {{{
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
@ -122,22 +178,23 @@ func (s *Schedule) Delete(queryable Queryable) error {// {{{
s.ID,
)
return err
}// }}}
} // }}}
func ExpiredSchedules() (schedules []Schedule) {// {{{
func ExpiredSchedules() (schedules []Schedule) { // {{{
schedules = []Schedule{}
res, err := service.Db.Conn.Queryx(`
SELECT
id,
user_id,
node_id,
schedule_uuid,
time,
description
FROM schedule
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 < NOW() AND
(time - MAKE_INTERVAL(mins => remind_minutes)) AT TIME ZONE u.timezone < NOW() AND
NOT acknowledged
ORDER BY
time ASC
@ -157,4 +214,76 @@ func ExpiredSchedules() (schedules []Schedule) {// {{{
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
} // }}}

2
sql/00018.sql Normal file
View File

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

1
sql/00019.sql Normal file
View File

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

2
sql/00020.sql Normal file
View File

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

1
sql/00021.sql Normal file
View File

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

View File

@ -13,6 +13,11 @@ html {
*:after {
box-sizing: inherit;
}
*,
*:focus,
*:hover {
outline: none;
}
[onClick] {
cursor: pointer;
}
@ -29,12 +34,16 @@ body {
height: 100%;
}
h1 {
font-size: 1.5em;
color: #518048;
}
h2 {
font-size: 1.25em;
color: #518048;
border-bottom: 1px solid #ccc;
}
h2 {
font-size: 1em;
color: #518048;
}
h3 {
font-size: 1em;
}
button {
font-size: 1em;
@ -203,6 +212,7 @@ header .menu {
padding: 16px;
background-color: #333;
color: #ddd;
z-index: 100;
}
#tree .node {
display: grid;
@ -408,6 +418,7 @@ header .menu {
cursor: pointer;
}
#checklist .checklist-item {
transform: translate(0, 0);
display: grid;
grid-template-columns: repeat(3, min-content);
grid-gap: 0 8px;
@ -521,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);
@ -531,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;
@ -629,9 +657,9 @@ header .menu {
}
.layout-tree {
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-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 */
color: #fff;
min-height: 100%;
@ -664,9 +692,9 @@ header .menu {
display: block;
}
.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-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 */
}
.layout-crumbs #tree {
@ -713,17 +741,17 @@ header .menu {
}
#app.node {
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-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 */
color: #fff;
min-height: 100%;
}
#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-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 */
}
#app.node.toggle-tree #tree {
@ -745,11 +773,72 @@ 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" "checklist" "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 /* 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 */
}
#app.node #tree {

View File

@ -27,6 +27,7 @@
</script>
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/sjcl.js"></script>
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/node_modules/marked/marked.min.js"></script>
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/fullcalendar.min.js"></script>
</head>
<body>

View File

@ -113,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) {//{{{
@ -121,7 +121,7 @@ class App extends Component {
switch (msg.Op) {
case 'css_reload':
refreshCSS()
this.websocket.refreshCSS()
break;
}
}//}}}

View File

@ -110,10 +110,11 @@ export class Checklist extends Component {
this.groupElements = {}
this.state = {
confirmDeletion: true,
continueAddingItems: true,
}
window._checklist = this
}//}}}
render({ ui, groups }, { confirmDeletion }) {//{{{
render({ ui, groups }, { confirmDeletion, continueAddingItems }) {//{{{
this.groupElements = {}
if (groups.length == 0 && !ui.node.value.ShowChecklist.value)
return
@ -136,6 +137,10 @@ export class Checklist extends Component {
<input type="checkbox" id="confirm-checklist-delete" checked=${confirmDeletion} onchange=${() => this.setState({ confirmDeletion: !confirmDeletion })} />
<label for="confirm-checklist-delete">Confirm checklist deletion</label>
</div>
<div>
<input type="checkbox" id="continue-adding-items" checked=${continueAddingItems} onchange=${() => this.setState({ continueAddingItems: !continueAddingItems })} />
<label for="continue-adding-items">Continue adding items</label>
</div>
`
}
@ -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 {
constructor() {//{{{
super()
this.label = createRef()
this.addingItem = signal(false)
}//}}}
render({ 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} />`)
let label = () => html`<div class="label" style="cursor: pointer" ref=${this.label} onclick=${() => this.editLabel()}>${group.Label}</div>`
let addItem = () => {
if (this.addingItem.value)
return html`<${InputElement} label="New item" callback=${(ok, val) => this.addItem(ok, val)} />`
}
return html`
<${addItem} />
<div class="checklist-group-container">
<div class="checklist-group ${ui.edit.value ? 'edit' : ''}">
<div class="reorder" style="cursor: grab"></div>
<img src="/images/${_VERSION}/trashcan.svg" onclick=${() => this.delete()} />
<${label} />
<img src="/images/${_VERSION}/add-gray.svg" onclick=${() => this.addItem()} />
<img src="/images/${_VERSION}/add-gray.svg" onclick=${() => this.addingItem.value = true} />
</div>
<${items} ui=${ui} group=${group} />
</div>
`
}//}}}
addItem() {//{{{
let label = prompt("Create a new item")
if (label === null)
addItem(ok, label) {//{{{
if (!ok) {
this.addingItem.value = false
return
}
label = label.trim()
if (label == '')
if (label == '') {
this.addingItem.value = false
return
}
this.props.group.addItem(label, () => {
this.forceUpdate()
})
if (!this.props.ui.state.continueAddingItems)
this.addingItem.value = false
}//}}}
editLabel() {//{{{
let label = prompt('Edit label', this.props.group.Label)
@ -299,7 +368,7 @@ class ChecklistItemElement extends Component {
`
}//}}}
componentDidMount() {//{{{
this.base.addEventListener('dragstart', () => this.dragStart())
this.base.addEventListener('dragstart', evt => this.dragStart(evt))
this.base.addEventListener('dragend', () => this.dragEnd())
this.base.addEventListener('dragenter', evt => this.dragEnter(evt))
}//}}}
@ -353,10 +422,13 @@ class ChecklistItemElement extends Component {
setDragTarget(state) {//{{{
this.setState({ dragTarget: state })
}//}}}
dragStart() {//{{{
dragStart(evt) {//{{{
// Shouldn't be needed, but in case the previous drag was bungled up, we reset.
this.props.ui.dragReset()
this.props.ui.dragItemSource = this
const img = new Image();
evt.dataTransfer.setDragImage(img, 10, 10);
}//}}}
dragEnter(evt) {//{{{
evt.preventDefault()
@ -393,7 +465,7 @@ class ChecklistItemElement extends Component {
this.props.ui.groupElements[fromGroup.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

File diff suppressed because one or more lines are too long

View File

@ -14,7 +14,6 @@ export class NodeUI extends Component {
this.nodeContent = createRef()
this.nodeProperties = createRef()
this.keys = signal([])
this.page = signal('node')
window.addEventListener('popstate', evt => {
if (evt.state && evt.state.hasOwnProperty('nodeID'))
@ -59,6 +58,7 @@ export class NodeUI extends Component {
case 'node':
if (node.ID == 0) {
page = html`
<div style="cursor: pointer; color: #000; text-align: center;" onclick=${() => this.page.value = 'schedule-events'}>Schedule events</div>
${children.length > 0 ? html`<div class="child-nodes">${children}</div><div id="notes-version">Notes version ${window._VERSION}</div>` : html``}
`
} else {
@ -72,6 +72,7 @@ export class NodeUI extends Component {
${node.Name} ${padlock}
</div>
<${NodeContent} key=${node.ID} node=${node} ref=${this.nodeContent} />
<${NodeEvents} events=${node.ScheduleEvents.value} />
<${Checklist} ui=${this} groups=${node.ChecklistGroups} />
<${NodeFiles} node=${this.node.value} />
`
@ -97,10 +98,14 @@ export class NodeUI extends Component {
case 'search':
page = html`<${Search} nodeui=${this} />`
break
case 'schedule-events':
page = html`<${ScheduleEventList} nodeui=${this} />`
break
}
let menu = ()=> (this.menu.value ? html`<${Menu} nodeui=${this} />` : null)
let checklist = ()=>
let menu = () => (this.menu.value ? html`<${Menu} nodeui=${this} />` : null)
let checklist = () =>
html`
<div class="checklist" onclick=${evt => { evt.stopPropagation(); this.toggleChecklist() }}>
<img src="/images/${window._VERSION}/${this.showChecklist() ? 'checklist-on.svg' : 'checklist-off.svg'}" />
@ -245,15 +250,12 @@ export class NodeUI extends Component {
})
}//}}}
saveNode() {//{{{
/*
let nodeContent = this.nodeContent.current
if (this.page.value != 'node' || nodeContent === null)
return
*/
let content = this.node.value.content()
this.node.value.setContent(content)
this.node.value.save(() => this.props.app.nodeModified.value = false)
this.node.value.save(() => {
this.props.app.nodeModified.value = false
this.node.value.retrieve()
})
}//}}}
renameNode() {//{{{
let name = prompt("New name")
@ -307,7 +309,7 @@ export class NodeUI extends Component {
this.node.value.ShowChecklist.value = !this.node.value.ShowChecklist.value
}//}}}
toggleMarkdown() {//{{{
this.node.value.RenderMarkdown.value = !this.node.value.RenderMarkdown.value
this.node.value.RenderMarkdown.value = !this.node.value.RenderMarkdown.value
}//}}}
}
@ -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 {
render({ node }) {//{{{
if (node.Files === null || node.Files.length == 0)
@ -438,10 +458,16 @@ export class Node {
this._decrypted = false
this._expanded = false // start value for the TreeNode component,
this.ChecklistGroups = {}
this.ScheduleEvents = signal([])
// it doesn't control it afterwards.
// Used to expand the crumbs upon site loading.
}//}}}
retrieve(callback) {//{{{
this.app.request('/schedule/list', { NodeID: this.ID })
.then(res => {
this.ScheduleEvents.value = res.ScheduleEvents
})
this.app.request('/node/retrieve', { ID: this.ID })
.then(res => {
this.ParentID = res.Node.ParentID
@ -485,6 +511,7 @@ export class Node {
Content: this._content,
CryptoKeyID: this.CryptoKeyID,
Markdown: this.Markdown,
TimeOffset: -(new Date().getTimezoneOffset()),
}
this.app.request('/node/update', req)
.then(callback)
@ -617,7 +644,7 @@ export class Node {
initChecklist(checklistData) {//{{{
if (checklistData === undefined || checklistData === null)
return
this.ChecklistGroups = checklistData.map(groupData=>{
this.ChecklistGroups = checklistData.map(groupData => {
return new ChecklistGroup(groupData)
})
}//}}}
@ -777,7 +804,7 @@ class NodeProperties extends Component {
<div style="margin-bottom: 16px">These properties are only for this note.</div>
<div class="checks">
<input type="checkbox" id="render-markdown" checked=${nodeui.node.value.Markdown} onchange=${evt=>nodeui.node.value.Markdown = evt.target.checked} />
<input type="checkbox" id="render-markdown" checked=${nodeui.node.value.Markdown} onchange=${evt => nodeui.node.value.Markdown = evt.target.checked} />
<label for="render-markdown">Markdown view</label>
</div>
@ -958,4 +985,133 @@ class ProfileSettings extends Component {
}//}}}
}
class ScheduleEventList extends Component {
static CALENDAR = Symbol('CALENDAR')
static LIST = Symbol('LIST')
constructor() {//{{{
super()
this.tab = signal(ScheduleEventList.CALENDAR)
}//}}}
render() {//{{{
var tab
switch (this.tab.value) {
case ScheduleEventList.CALENDAR:
tab = html`<${ScheduleCalendarTab} />`
break;
case ScheduleEventList.LIST:
tab = html`<${ScheduleEventListTab} />`
break;
}
return html`
<div style="margin: 32px">
<div class="folder">
<div class="tabs">
<div onclick=${() => this.tab.value = ScheduleEventList.CALENDAR} class="tab ${this.tab.value == ScheduleEventList.CALENDAR ? 'selected' : ''}">Calendar</div>
<div onclick=${() => this.tab.value = ScheduleEventList.LIST} class="tab ${this.tab.value == ScheduleEventList.LIST ? 'selected' : ''}">List</div>
<div class="hack"></div>
</div>
<div class="content">
${tab}
</div>
</div>
</div>
`
}//}}}
}
class ScheduleEventListTab extends Component {
constructor() {//{{{
super()
this.events = signal(null)
this.retrieveFutureEvents()
}//}}}
render() {//{{{
if (this.events.value === null)
return
let events = this.events.value.sort((a, b) => {
if (a.Time < b.Time) return -1
if (a.Time > b.Time) return 1
return 0
}).map(evt => {
const dt = evt.Time.split('T')
const remind = () => {
if (evt.RemindMinutes > 0)
return html`${evt.RemindMinutes} min`
}
const nodeLink = () => html`<a href="/?node=${evt.Node.ID}">${evt.Node.Name}</a>`
return html`
<div class="date">${dt[0]}</div>
<div class="time">${dt[1].slice(0, 5)}</div>
<div class="remind"><${remind} /></div>
<div class="description">${evt.Description}</div>
<div class="node"><${nodeLink} /></div>
`
})
return html`
<div id="schedule-events">
<div class="header">Date</div>
<div class="header">Time</div>
<div class="header">Reminder</div>
<div class="header">Event</div>
<div class="header">Node</div>
${events}
</div>
`
}//}}}
retrieveFutureEvents() {//{{{
_app.current.request('/schedule/list')
.then(data => {
this.events.value = data.ScheduleEvents
})
}//}}}
}
class ScheduleCalendarTab extends Component {
constructor() {//{{{
super()
}//}}}
componentDidMount() {
let calendarEl = document.getElementById('fullcalendar');
this.calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
events: this.events,
eventTimeFormat: {
hour12: false,
hour: '2-digit',
minute: '2-digit',
},
firstDay: 1,
aspectRatio: 2.5,
});
this.calendar.render();
}
render() {
return html`<div id="fullcalendar"></div>`
}
events(info, successCallback, failureCallback) {
const req = {
StartDate: info.startStr,
EndDate: info.endStr,
}
_app.current.request('/schedule/list', req)
.then(data => {
const fullcalendarEvents = data.ScheduleEvents.map(sch => {
return {
title: sch.Description,
start: sch.Time,
url: `/?node=${sch.Node.ID}`,
}
})
successCallback(fullcalendarEvents)
})
.catch(err=>failureCallback(err))
}
}
// vim: foldmethod=marker

View File

@ -8,6 +8,10 @@ html {
box-sizing: inherit;
}
*,*:focus,*:hover{
outline:none;
}
[onClick] {
cursor: pointer;
}
@ -27,15 +31,20 @@ html, body {
}
h1 {
font-size: 1.5em;
font-size: 1.25em;
color: @header_1;
border-bottom: 1px solid #ccc;
}
h2 {
font-size: 1.25em;
font-size: 1.0em;
color: @header_1;
}
h3 {
font-size: 1.0em;
}
button {
font-size: 1em;
padding: 6px;
@ -222,6 +231,7 @@ header {
padding: 16px;
background-color: #333;
color: #ddd;
z-index: 100; // Over crumbs shadow
.node {
display: grid;
@ -470,6 +480,7 @@ header {
}
.checklist-item {
transform: translate(0, 0);
display: grid;
grid-template-columns: repeat(3, min-content);
grid-gap: 0 8px;
@ -611,7 +622,7 @@ header {
}
/* ============================================================= */
#file-section {
#schedule-section {
grid-area: files;
justify-self: center;
width: calc(100% - 32px);
@ -621,6 +632,25 @@ header {
border-radius: 8px;
margin-top: 32px;
margin-bottom: 32px;
color: #000;
.header {
font-weight: bold;
color: #000;
margin-bottom: 16px;
}
}
#file-section {
grid-area: schedule;
justify-self: center;
width: calc(100% - 32px);
max-width: 900px;
padding: 32px;
background: #f5f5f5;
border-radius: 8px;
margin-top: 32px;
margin-bottom: 16px;
.header {
font-weight: bold;
@ -745,6 +775,7 @@ header {
"tree name"
"tree content"
"tree checklist"
"tree schedule"
"tree files"
"tree blank"
;
@ -756,6 +787,7 @@ header {
min-content /* name */
min-content /* content */
min-content /* checklist */
min-content /* schedule */
min-content /* files */
1fr; /* blank */
color: #fff;
@ -789,6 +821,7 @@ header {
"name"
"content"
"checklist"
"schedule"
"files"
"blank"
;
@ -800,6 +833,7 @@ header {
min-content /* name */
min-content /* content */
min-content /* checklist */
min-content /* schedule */
min-content /* files */
1fr; /* blank */
#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) {
#app.node {
.layout-crumbs();

View File

@ -1 +1 @@
v22
v29