wip
This commit is contained in:
parent
bd4a475923
commit
9a164b984a
@ -189,7 +189,7 @@ func (mngr *Manager) Authenticate(username, password string) (authenticated bool
|
||||
} // }}}
|
||||
func (mngr *Manager) CreateUser(username, password, name string) (alreadyExists bool, err error) { // {{{
|
||||
_, err = mngr.db.Exec(`
|
||||
INSERT INTO public.user(username, password, name, totp)
|
||||
INSERT INTO public.user(username, password, name)
|
||||
VALUES(
|
||||
$1,
|
||||
public.password_hash(
|
||||
@ -199,8 +199,7 @@ func (mngr *Manager) CreateUser(username, password, name string) (alreadyExists
|
||||
/* password */
|
||||
$2::bytea
|
||||
),
|
||||
$3,
|
||||
''
|
||||
$3
|
||||
)
|
||||
`,
|
||||
username,
|
||||
|
22
main.go
22
main.go
@ -163,6 +163,10 @@ func rootHandler(w http.ResponseWriter, r *http.Request) { // {{{
|
||||
|
||||
Webengine.StaticResource(w, r)
|
||||
} // }}}
|
||||
func httpError(w http.ResponseWriter, err error) {// {{{
|
||||
j, _ := json.Marshal(struct { OK bool; Error string }{false, err.Error()})
|
||||
w.Write(j)
|
||||
}// }}}
|
||||
|
||||
func pageServiceWorker(w http.ResponseWriter, r *http.Request) { // {{{
|
||||
w.Header().Add("Content-Type", "text/javascript; charset=utf-8")
|
||||
@ -209,13 +213,19 @@ func pageNotes2(w http.ResponseWriter, r *http.Request) { // {{{
|
||||
} // }}}
|
||||
|
||||
func actionNodeTree(w http.ResponseWriter, r *http.Request) { // {{{
|
||||
user, _ := r.Context().Value(CONTEXT_USER).(User)
|
||||
user := getUser(r)
|
||||
|
||||
nodes, err := NodeTree(user.ID, 0)
|
||||
if err != nil {
|
||||
httpError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
j, _ := json.Marshal(struct {
|
||||
OK bool
|
||||
Foo string
|
||||
User User
|
||||
}{true, "FOO", user})
|
||||
Nodes []Node
|
||||
}{true, nodes})
|
||||
Log.Debug("tree", "nodes", nodes)
|
||||
w.Write(j)
|
||||
} // }}}
|
||||
|
||||
@ -261,3 +271,7 @@ func changePassword(username string) { // {{{
|
||||
|
||||
fmt.Printf("\nPassword changed\n")
|
||||
} // }}}
|
||||
func getUser(r *http.Request) User { // {{{
|
||||
user, _ := r.Context().Value(CONTEXT_USER).(User)
|
||||
return user
|
||||
} // }}}
|
||||
|
@ -1,43 +1,29 @@
|
||||
CREATE TABLE public.user (
|
||||
id serial NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"username" varchar NOT NULL,
|
||||
"password" char(96) NOT NULL,
|
||||
totp varchar NOT NULL,
|
||||
last_login timestamp with time zone NOT NULL DEFAULT '1970-01-01 00:00:00',
|
||||
CONSTRAINT user_pk PRIMARY KEY (id),
|
||||
CONSTRAINT user_un UNIQUE (username)
|
||||
CREATE TABLE public."user" (
|
||||
id SERIAL NOT NULL,
|
||||
username VARCHAR NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
"password" VARCHAR NOT NULL,
|
||||
last_login TIMESTAMP NOT NULL DEFAULT now(),
|
||||
CONSTRAINT newtable_pk PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE public.session (
|
||||
id serial NOT NULL,
|
||||
user_id int4 NULL,
|
||||
"uuid" char(36) NOT NULL,
|
||||
created timestamp with time zone NOT NULL DEFAULT NOW(),
|
||||
last_used timestamp with time zone NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT session_pk PRIMARY KEY (id),
|
||||
CONSTRAINT session_un UNIQUE ("uuid"),
|
||||
CONSTRAINT session_user_fk FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
CREATE TABLE public."session" (
|
||||
uuid UUID NOT NULL,
|
||||
user_id INT4 NULL,
|
||||
created TIMESTAMP NOT NULL DEFAULT now(),
|
||||
CONSTRAINT session_pk PRIMARY KEY (uuid),
|
||||
CONSTRAINT user_session_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto SCHEMA public;
|
||||
CREATE TABLE public.node (
|
||||
id SERIAL NOT NULL,
|
||||
user_id INT4 NOT NULL,
|
||||
parent_id INT4 NULL,
|
||||
"name" VARCHAR(256) NOT NULL DEFAULT '',
|
||||
"content" TEXT NOT NULL DEFAULT '',
|
||||
CONSTRAINT name_length CHECK (LENGTH(TRIM(name)) > 0),
|
||||
CONSTRAINT node_pk PRIMARY KEY (id),
|
||||
CONSTRAINT user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT node_fk FOREIGN KEY (parent_id) REFERENCES public.node(id) ON DELETE RESTRICT ON UPDATE RESTRICT
|
||||
);
|
||||
|
||||
CREATE FUNCTION password_hash(salt_hex char(32), pass bytea)
|
||||
RETURNS char(96)
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT
|
||||
salt_hex ||
|
||||
encode(
|
||||
sha256(
|
||||
decode(salt_hex, 'hex') || /* salt in binary */
|
||||
pass /* password */
|
||||
),
|
||||
'hex'
|
||||
)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
@ -1,33 +1,4 @@
|
||||
CREATE EXTENSION pg_trgm;
|
||||
|
||||
CREATE TABLE public.crypto_key (
|
||||
id serial4 NOT NULL,
|
||||
user_id int4 NOT NULL,
|
||||
description varchar(255) DEFAULT ''::character varying NOT NULL,
|
||||
"key" bpchar(144) NOT NULL,
|
||||
CONSTRAINT crypto_key_pk PRIMARY KEY (id),
|
||||
CONSTRAINT crypto_user_description_un UNIQUE (user_id, description),
|
||||
CONSTRAINT crypto_key_user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE RESTRICT ON UPDATE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TABLE public.node (
|
||||
id serial4 NOT NULL,
|
||||
user_id int4 NOT NULL,
|
||||
parent_id int4 NULL,
|
||||
"name" varchar(256) DEFAULT ''::character varying NOT NULL,
|
||||
"content" text DEFAULT ''::text NOT NULL,
|
||||
updated timestamptz DEFAULT now() NOT NULL,
|
||||
crypto_key_id int4 NULL,
|
||||
content_encrypted text DEFAULT ''::text NOT NULL,
|
||||
markdown bool DEFAULT false NOT NULL,
|
||||
CONSTRAINT name_length CHECK ((length(TRIM(BOTH FROM name)) > 0)),
|
||||
CONSTRAINT node_pk PRIMARY KEY (id),
|
||||
CONSTRAINT crypto_key_fk FOREIGN KEY (crypto_key_id) REFERENCES public.crypto_key(id) ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT node_fk FOREIGN KEY (parent_id) REFERENCES public.node(id) ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT node_user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE RESTRICT ON UPDATE RESTRICT
|
||||
);
|
||||
CREATE INDEX node_search_index ON public.node USING gin (name gin_trgm_ops, content gin_trgm_ops);
|
||||
|
||||
ALTER TABLE node ADD COLUMN updated TIMESTAMP NOT NULL DEFAULT NOW();
|
||||
|
||||
CREATE OR REPLACE FUNCTION node_update_timestamp()
|
||||
RETURNS TRIGGER
|
||||
|
10
sql/00003.sql
Normal file
10
sql/00003.sql
Normal file
@ -0,0 +1,10 @@
|
||||
CREATE TABLE public.file (
|
||||
id serial NOT NULL,
|
||||
user_id int4 NOT NULL,
|
||||
filename varchar(256) NOT NULL DEFAULT '<noname>',
|
||||
"size" int4 NOT NULL DEFAULT 0,
|
||||
mime varchar(256) NOT NULL DEFAULT '',
|
||||
uploaded timestamp NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT file_pk PRIMARY KEY (id),
|
||||
CONSTRAINT file_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE RESTRICT ON UPDATE RESTRICT
|
||||
);
|
1
sql/00004.sql
Normal file
1
sql/00004.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE file ADD COLUMN md5 CHAR(32) DEFAULT ''
|
2
sql/00005.sql
Normal file
2
sql/00005.sql
Normal file
@ -0,0 +1,2 @@
|
||||
ALTER TABLE public.file ADD node_id int4 NOT NULL;
|
||||
ALTER TABLE public.file ADD CONSTRAINT file_node_fk FOREIGN KEY (node_id) REFERENCES public.node(id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
5
sql/00006.sql
Normal file
5
sql/00006.sql
Normal file
@ -0,0 +1,5 @@
|
||||
ALTER TABLE public.file DROP CONSTRAINT file_node_fk;
|
||||
ALTER TABLE public.file ADD CONSTRAINT file_node_fk FOREIGN KEY (node_id) REFERENCES public.node(id) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE public.file DROP CONSTRAINT file_fk;
|
||||
ALTER TABLE public.file ADD CONSTRAINT file_user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE ON UPDATE CASCADE;
|
10
sql/00007.sql
Normal file
10
sql/00007.sql
Normal file
@ -0,0 +1,10 @@
|
||||
CREATE TABLE public.crypto_key (
|
||||
id serial NOT NULL,
|
||||
user_id int4 NOT NULL,
|
||||
description varchar(255) NOT NULL DEFAULT '',
|
||||
"key" char(144) NOT NULL,
|
||||
CONSTRAINT crypto_key_pk PRIMARY KEY (id),
|
||||
CONSTRAINT crypto_key_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN public.crypto_key.key IS 'salt(16 bytes) + [key encrypted with pbkdf2(pass, salt)]';
|
2
sql/00008.sql
Normal file
2
sql/00008.sql
Normal file
@ -0,0 +1,2 @@
|
||||
ALTER TABLE public.node ADD crypto_key_id int4 NULL;
|
||||
ALTER TABLE public.node ADD CONSTRAINT crypto_key_fk FOREIGN KEY (crypto_key_id) REFERENCES public.crypto_key(id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
1
sql/00009.sql
Normal file
1
sql/00009.sql
Normal file
@ -0,0 +1 @@
|
||||
CREATE SEQUENCE aes_ccm_counter AS int8 INCREMENT BY 1 NO CYCLE;
|
1
sql/00010.sql
Normal file
1
sql/00010.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE public.crypto_key ADD CONSTRAINT crypto_user_description_un UNIQUE (user_id, description);
|
5
sql/00011.sql
Normal file
5
sql/00011.sql
Normal file
@ -0,0 +1,5 @@
|
||||
ALTER TABLE node ADD COLUMN content_encrypted TEXT NOT NULL DEFAULT '';
|
||||
UPDATE node SET content_encrypted = content, content = '' WHERE crypto_key_id IS NOT NULL;
|
||||
|
||||
CREATE EXTENSION pg_trgm;
|
||||
CREATE INDEX node_content_index ON node USING gin (content gin_trgm_ops);
|
2
sql/00012.sql
Normal file
2
sql/00012.sql
Normal file
@ -0,0 +1,2 @@
|
||||
DROP INDEX node_content_index;
|
||||
CREATE INDEX node_search_index ON node USING gin (name gin_trgm_ops, content gin_trgm_ops);
|
1
sql/00013.sql
Normal file
1
sql/00013.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE public.node ADD COLUMN markdown bool NOT NULL DEFAULT false;
|
18
sql/00014.sql
Normal file
18
sql/00014.sql
Normal file
@ -0,0 +1,18 @@
|
||||
CREATE TABLE checklist_group (
|
||||
id serial NOT NULL,
|
||||
node_id int4 NOT NULL,
|
||||
"order" int NOT NULL DEFAULT 0,
|
||||
label varchar NOT NULL,
|
||||
CONSTRAINT checklist_group_pk PRIMARY KEY (id),
|
||||
CONSTRAINT checklist_group_node_fk FOREIGN KEY (node_id) REFERENCES public."node"(id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE checklist_item (
|
||||
id serial NOT NULL,
|
||||
checklist_group_id int4 NOT NULL,
|
||||
"order" int NOT NULL DEFAULT 0,
|
||||
label varchar NOT NULL,
|
||||
checked bool NOT NULL DEFAULT false,
|
||||
CONSTRAINT checklist_item_pk PRIMARY KEY (id),
|
||||
CONSTRAINT checklist_group_item_fk FOREIGN KEY (checklist_group_id) REFERENCES public."checklist_group"(id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
)
|
14
sql/00015.sql
Normal file
14
sql/00015.sql
Normal file
@ -0,0 +1,14 @@
|
||||
CREATE TABLE public.schedule (
|
||||
id SERIAL NOT NULL,
|
||||
user_id INT4 NOT NULL,
|
||||
node_id INT4 NOT NULL,
|
||||
schedule_uuid CHAR(36) DEFAULT GEN_RANDOM_UUID() NOT NULL,
|
||||
"time" TIMESTAMP NOT NULL,
|
||||
description VARCHAR DEFAULT '' NOT NULL,
|
||||
acknowledged BOOL DEFAULT false NOT NULL,
|
||||
|
||||
CONSTRAINT schedule_pk PRIMARY KEY (id),
|
||||
CONSTRAINT schedule_uuid UNIQUE (schedule_uuid),
|
||||
CONSTRAINT schedule_node_fk FOREIGN KEY (node_id) REFERENCES public.node(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT schedule_user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
1
sql/00016.sql
Normal file
1
sql/00016.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE public.schedule ADD CONSTRAINT schedule_event UNIQUE (user_id, node_id, "time", description);
|
11
sql/00017.sql
Normal file
11
sql/00017.sql
Normal file
@ -0,0 +1,11 @@
|
||||
CREATE TABLE public.notification (
|
||||
id SERIAl NOT NULL,
|
||||
user_id INT4 NOT NULL,
|
||||
service VARCHAR DEFAULT 'NTFY' NOT NULL,
|
||||
"configuration" JSONB DEFAULT '{}' NOT NULL,
|
||||
prio INT DEFAULT 0 NOT NULL,
|
||||
|
||||
CONSTRAINT notification_pk PRIMARY KEY (id),
|
||||
CONSTRAINT notification_unique UNIQUE (user_id,prio),
|
||||
CONSTRAINT notification_user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
2
sql/00018.sql
Normal file
2
sql/00018.sql
Normal file
@ -0,0 +1,2 @@
|
||||
ALTER TABLE public.schedule ALTER COLUMN "time" TYPE timestamptz USING "time"::timestamptz;
|
||||
|
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 public."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;
|
21
sql/00022.sql
Normal file
21
sql/00022.sql
Normal file
@ -0,0 +1,21 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE FUNCTION public.password_hash(salt_hex char(32), pass bytea)
|
||||
RETURNS char(96)
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT
|
||||
salt_hex ||
|
||||
encode(
|
||||
sha256(
|
||||
decode(salt_hex, 'hex') || /* salt in binary */
|
||||
pass /* password */
|
||||
),
|
||||
'hex'
|
||||
)
|
||||
);
|
||||
END;
|
||||
$$;
|
38
static/css/notes2.css
Normal file
38
static/css/notes2.css
Normal file
@ -0,0 +1,38 @@
|
||||
#tree {
|
||||
grid-area: tree;
|
||||
padding: 16px;
|
||||
background-color: #333;
|
||||
color: #ddd;
|
||||
z-index: 100;
|
||||
}
|
||||
#tree .node {
|
||||
display: grid;
|
||||
grid-template-columns: 24px min-content;
|
||||
grid-template-rows: min-content 1fr;
|
||||
margin-top: 12px;
|
||||
}
|
||||
#tree .node .expand-toggle img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
#tree .node .name {
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
#tree .node .name:hover {
|
||||
color: #fe5f55;
|
||||
}
|
||||
#tree .node .name.selected {
|
||||
color: #fe5f55;
|
||||
font-weight: bold;
|
||||
}
|
||||
#tree .node .children {
|
||||
padding-left: 24px;
|
||||
margin-left: 8px;
|
||||
border-left: 1px solid #555;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
#tree .node .children.collapsed {
|
||||
display: none;
|
||||
}
|
74
static/images/collapsed.svg
Normal file
74
static/images/collapsed.svg
Normal file
@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="399.99997"
|
||||
height="399.99997"
|
||||
viewBox="0 0 105.83332 105.83333"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="1.2.1 (9c6d41e, 2022-07-14)"
|
||||
sodipodi:docname="collapsed.svg"
|
||||
xml:space="preserve"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"><defs
|
||||
id="defs2" /><sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="1.4142136"
|
||||
inkscape:cx="304.05591"
|
||||
inkscape:cy="298.39905"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
inkscape:window-width="1916"
|
||||
inkscape:window-height="1404"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="16"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:showpageshadow="true"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d6d6d6"
|
||||
showborder="true" /><metadata
|
||||
id="metadata5"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-42.756321,-24.613384)"><rect
|
||||
style="color:#000000;overflow:visible;fill:#537979;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.264583;paint-order:markers stroke fill;stop-color:#000000"
|
||||
id="rect5470"
|
||||
width="105.83333"
|
||||
height="105.83333"
|
||||
x="42.756321"
|
||||
y="24.613384"
|
||||
rx="21.166666"
|
||||
ry="21.166666" /><rect
|
||||
style="color:#000000;overflow:visible;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.264583;paint-order:markers stroke fill;stop-color:#000000"
|
||||
id="rect6360"
|
||||
width="63.5"
|
||||
height="18.520834"
|
||||
x="63.922985"
|
||||
y="68.26963"
|
||||
rx="5.2916665"
|
||||
ry="5.2916665" /><rect
|
||||
style="color:#000000;overflow:visible;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.264583;paint-order:markers stroke fill;stop-color:#000000"
|
||||
id="rect10171"
|
||||
width="63.5"
|
||||
height="18.520834"
|
||||
x="-109.28004"
|
||||
y="86.412567"
|
||||
rx="5.2916665"
|
||||
ry="5.2916665"
|
||||
transform="rotate(-90)" /></g></svg>
|
After Width: | Height: | Size: 2.7 KiB |
65
static/images/expanded.svg
Normal file
65
static/images/expanded.svg
Normal file
@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="399.99997"
|
||||
height="399.99997"
|
||||
viewBox="0 0 105.83332 105.83333"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="1.2.1 (9c6d41e, 2022-07-14)"
|
||||
sodipodi:docname="expanded.svg"
|
||||
xml:space="preserve"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"><defs
|
||||
id="defs2" /><sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="1.4142136"
|
||||
inkscape:cx="304.05591"
|
||||
inkscape:cy="298.39905"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
inkscape:window-width="1916"
|
||||
inkscape:window-height="1404"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="16"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:showpageshadow="true"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d6d6d6"
|
||||
showborder="true" /><metadata
|
||||
id="metadata5"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-42.756321,-24.613384)"><rect
|
||||
style="color:#000000;overflow:visible;fill:#537979;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.264583;paint-order:markers stroke fill;stop-color:#000000"
|
||||
id="rect5470"
|
||||
width="105.83333"
|
||||
height="105.83333"
|
||||
x="42.756321"
|
||||
y="24.613384"
|
||||
rx="21.166666"
|
||||
ry="21.166666" /><rect
|
||||
style="color:#000000;overflow:visible;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.264583;paint-order:markers stroke fill;stop-color:#000000"
|
||||
id="rect6360"
|
||||
width="63.5"
|
||||
height="18.520834"
|
||||
x="63.922985"
|
||||
y="68.26963"
|
||||
rx="5.2916665"
|
||||
ry="5.2916665" /></g></svg>
|
After Width: | Height: | Size: 2.4 KiB |
57
static/images/leaf.svg
Normal file
57
static/images/leaf.svg
Normal file
@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="399.99997"
|
||||
height="399.99997"
|
||||
viewBox="0 0 105.83332 105.83333"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="1.2.1 (9c6d41e, 2022-07-14)"
|
||||
sodipodi:docname="leaf.svg"
|
||||
xml:space="preserve"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"><defs
|
||||
id="defs2" /><sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="1.4142136"
|
||||
inkscape:cx="303.34881"
|
||||
inkscape:cy="297.69195"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
inkscape:window-width="1916"
|
||||
inkscape:window-height="1404"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="16"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:showpageshadow="true"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d6d6d6"
|
||||
showborder="true" /><metadata
|
||||
id="metadata5"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-42.756321,-24.613384)"><rect
|
||||
style="color:#000000;overflow:visible;fill:#555555;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.264583;paint-order:markers stroke fill;stop-color:#000000"
|
||||
id="rect5470"
|
||||
width="105.83333"
|
||||
height="105.83333"
|
||||
x="42.756321"
|
||||
y="24.613384"
|
||||
rx="21.166666"
|
||||
ry="21.166666" /></g></svg>
|
After Width: | Height: | Size: 2.0 KiB |
@ -1,23 +1,172 @@
|
||||
import { h, Component, createRef } from 'preact'
|
||||
import { signal } from 'preact/signals'
|
||||
import htm from 'htm'
|
||||
import { API } from 'api'
|
||||
import { Node } from 'node'
|
||||
const html = htm.bind(h)
|
||||
|
||||
export class Notes2 {
|
||||
constructor() {//{{{
|
||||
this.startNode = null
|
||||
this.setStartNode()
|
||||
}//}}}
|
||||
render() {//{{{
|
||||
return html`
|
||||
<button onclick=${()=>API.logout()}>Log out</button>
|
||||
<${Tree} app=${this} />
|
||||
`
|
||||
}//}}}
|
||||
setStartNode() {//{{{
|
||||
let urlParams = new URLSearchParams(window.location.search)
|
||||
let nodeID = urlParams.get('node')
|
||||
this.startNode = new Node(this, nodeID ? parseInt(nodeID) : 0)
|
||||
}//}}}
|
||||
|
||||
treeGet() {
|
||||
const req = {}
|
||||
API.query('POST', '/tree/get', req)
|
||||
API.query('POST', '/node/tree', req)
|
||||
.then(response => {
|
||||
console.log(response)
|
||||
})
|
||||
.catch(e => console.log(e.type, e.error))
|
||||
}
|
||||
}
|
||||
|
||||
class Tree extends Component {
|
||||
constructor(props) {//{{{
|
||||
super(props)
|
||||
this.treeNodes = {}
|
||||
this.treeNodeComponents = {}
|
||||
this.treeTrunk = []
|
||||
this.selectedTreeNode = null
|
||||
|
||||
this.retrieve()
|
||||
}//}}}
|
||||
render({ app }) {//{{{
|
||||
let renderedTreeTrunk = this.treeTrunk.map(node => {
|
||||
this.treeNodeComponents[node.ID] = createRef()
|
||||
return html`<${TreeNode} key=${"treenode_" + node.ID} tree=${this} node=${node} ref=${this.treeNodeComponents[node.ID]} selected=${node.ID == app.startNode.ID} />`
|
||||
})
|
||||
return html`<div id="tree">${renderedTreeTrunk}</div>`
|
||||
}//}}}
|
||||
|
||||
retrieve(callback = null) {//{{{
|
||||
const req = { StartNodeID: 0 }
|
||||
API.query('POST', '/node/tree', req)
|
||||
.then(res => {
|
||||
this.treeNodes = {}
|
||||
this.treeNodeComponents = {}
|
||||
this.treeTrunk = []
|
||||
this.selectedTreeNode = null
|
||||
|
||||
// A tree of nodes is built. This requires the list of nodes
|
||||
// returned from the server to be sorted in such a way that
|
||||
// a parent node always appears before a child node.
|
||||
// The server uses a recursive SQL query delivering this.
|
||||
res.Nodes.forEach(nodeData => {
|
||||
let node = new Node(
|
||||
this,
|
||||
nodeData.ID,
|
||||
)
|
||||
node.Children = []
|
||||
node.Crumbs = []
|
||||
node.Files = []
|
||||
node.Level = nodeData.Level
|
||||
node.Name = nodeData.Name
|
||||
node.ParentID = nodeData.ParentID
|
||||
node.Updated = nodeData.Updated
|
||||
node.UserID = nodeData.UserID
|
||||
|
||||
this.treeNodes[node.ID] = node
|
||||
|
||||
if (node.ParentID == 0)
|
||||
this.treeTrunk.push(node)
|
||||
else if (this.treeNodes[node.ParentID] !== undefined)
|
||||
this.treeNodes[node.ParentID].Children.push(node)
|
||||
})
|
||||
// When starting with an explicit node value, expanding all nodes
|
||||
// on its path gives the user a sense of location. Not necessarily working
|
||||
// as the start node isn't guaranteed to have returned data yet.
|
||||
// XXX this.crumbsUpdateNodes()
|
||||
this.forceUpdate()
|
||||
|
||||
if (callback)
|
||||
callback()
|
||||
|
||||
})
|
||||
.catch(e => { console.log(e); console.log(e.type, e.error); alert(e.error) })
|
||||
}//}}}
|
||||
setSelected(node) {//{{{
|
||||
if (this.selectedTreeNode)
|
||||
this.selectedTreeNode.selected.value = false
|
||||
|
||||
this.selectedTreeNode = this.treeNodeComponents[node.ID].current
|
||||
this.selectedTreeNode.selected.value = true
|
||||
this.selectedTreeNode.expanded.value = true
|
||||
this.expandToTrunk(node.ID)
|
||||
}//}}}
|
||||
crumbsUpdateNodes(node) {//{{{
|
||||
this.props.app.startNode.Crumbs.forEach(crumb => {
|
||||
// Start node is loaded before the tree.
|
||||
let node = this.treeNodes[crumb.ID]
|
||||
if (node)
|
||||
node._expanded = true
|
||||
|
||||
// Tree is done before the start node.
|
||||
let component = this.treeNodeComponents[crumb.ID]
|
||||
if (component && component.current)
|
||||
component.current.expanded.value = true
|
||||
})
|
||||
|
||||
// Will be undefined when called from tree initialization
|
||||
// (as tree nodes aren't rendered yet)
|
||||
if (node !== undefined)
|
||||
this.setSelected(node)
|
||||
}//}}}
|
||||
expandToTrunk(nodeID) {//{{{
|
||||
let node = this.treeNodes[nodeID]
|
||||
if (node === undefined)
|
||||
return
|
||||
|
||||
node = this.treeNodes[node.ParentID]
|
||||
while (node !== undefined) {
|
||||
this.treeNodeComponents[node.ID].current.expanded.value = true
|
||||
node = this.treeNodes[node.ParentID]
|
||||
}
|
||||
}//}}}
|
||||
}
|
||||
|
||||
class TreeNode extends Component {
|
||||
constructor(props) {//{{{
|
||||
super(props)
|
||||
this.selected = signal(props.selected)
|
||||
this.expanded = signal(this.props.node._expanded)
|
||||
}//}}}
|
||||
render({ tree, node }) {//{{{
|
||||
|
||||
let children = node.Children.map(node => {
|
||||
tree.treeNodeComponents[node.ID] = createRef()
|
||||
return html`<${TreeNode} key=${"treenode_" + node.ID} tree=${tree} node=${node} ref=${tree.treeNodeComponents[node.ID]} selected=${node.ID == tree.props.app.startNode.ID} />`
|
||||
})
|
||||
|
||||
let expandImg = ''
|
||||
if (node.Children.length == 0)
|
||||
expandImg = html`<img src="/images/${window._VERSION}/leaf.svg" />`
|
||||
else {
|
||||
if (this.expanded.value)
|
||||
expandImg = html`<img src="/images/${window._VERSION}/expanded.svg" />`
|
||||
else
|
||||
expandImg = html`<img src="/images/${window._VERSION}/collapsed.svg" />`
|
||||
}
|
||||
|
||||
|
||||
let selected = (this.selected.value ? 'selected' : '')
|
||||
|
||||
return html`
|
||||
<div class="node">
|
||||
<div class="expand-toggle" onclick=${() => this.expanded.value ^= true}>${expandImg}</div>
|
||||
<div class="name ${selected}" onclick=${() => window._app.current.nodeUI.current.goToNode(node.ID)}>${node.Name}</div>
|
||||
<div class="children ${node.Children.length > 0 && this.expanded.value ? 'expanded' : 'collapsed'}">${children}</div>
|
||||
</div>`
|
||||
}//}}}
|
||||
}
|
||||
|
472
static/js/checklist.mjs
Normal file
472
static/js/checklist.mjs
Normal file
@ -0,0 +1,472 @@
|
||||
import { h, Component, createRef } from 'preact'
|
||||
import htm from 'htm'
|
||||
import { signal } from 'preact/signals'
|
||||
const html = htm.bind(h)
|
||||
|
||||
export class ChecklistGroup {
|
||||
static sort(a, b) {//{{{
|
||||
if (a.Order < b.Order) return -1
|
||||
if (a.Order > b.Order) return 1
|
||||
return 0
|
||||
}//}}}
|
||||
constructor(data) {//{{{
|
||||
Object.keys(data).forEach(key => {
|
||||
if (key == 'Items')
|
||||
this.items = data[key].map(itemData => {
|
||||
let item = new ChecklistItem(itemData)
|
||||
item.checklistGroup = this
|
||||
return item
|
||||
})
|
||||
else
|
||||
this[key] = data[key]
|
||||
})
|
||||
}//}}}
|
||||
addItem(label, okCallback) {//{{{
|
||||
window._app.current.request('/node/checklist_group/item_add', {
|
||||
ChecklistGroupID: this.ID,
|
||||
Label: label,
|
||||
})
|
||||
.then(json => {
|
||||
let item = new ChecklistItem(json.Item)
|
||||
item.checklistGroup = this
|
||||
this.items.push(item)
|
||||
okCallback()
|
||||
})
|
||||
.catch(window._app.current.responseError)
|
||||
return
|
||||
}//}}}
|
||||
updateLabel(newLabel, okCallback, errCallback) {//{{{
|
||||
window._app.current.request('/node/checklist_group/label', {
|
||||
ChecklistGroupID: this.ID,
|
||||
Label: newLabel,
|
||||
})
|
||||
.then(okCallback)
|
||||
.catch(errCallback)
|
||||
}//}}}
|
||||
delete(okCallback, errCallback) {//{{{
|
||||
window._app.current.request('/node/checklist_group/delete', {
|
||||
ChecklistGroupID: this.ID,
|
||||
})
|
||||
.then(() => {
|
||||
okCallback()
|
||||
})
|
||||
.catch(errCallback)
|
||||
}//}}}
|
||||
}
|
||||
|
||||
export class ChecklistItem {
|
||||
static sort(a, b) {//{{{
|
||||
if (a.Order < b.Order) return -1
|
||||
if (a.Order > b.Order) return 1
|
||||
return 0
|
||||
}//}}}
|
||||
constructor(data) {//{{{
|
||||
Object.keys(data).forEach(key => {
|
||||
this[key] = data[key]
|
||||
})
|
||||
}//}}}
|
||||
updateState(newState, okCallback, errCallback) {//{{{
|
||||
window._app.current.request('/node/checklist_item/state', {
|
||||
ChecklistItemID: this.ID,
|
||||
State: newState,
|
||||
})
|
||||
.then(okCallback)
|
||||
.catch(errCallback)
|
||||
}//}}}
|
||||
updateLabel(newLabel, okCallback, errCallback) {//{{{
|
||||
window._app.current.request('/node/checklist_item/label', {
|
||||
ChecklistItemID: this.ID,
|
||||
Label: newLabel,
|
||||
})
|
||||
.then(okCallback)
|
||||
.catch(errCallback)
|
||||
}//}}}
|
||||
delete(okCallback, errCallback) {//{{{
|
||||
window._app.current.request('/node/checklist_item/delete', {
|
||||
ChecklistItemID: this.ID,
|
||||
})
|
||||
.then(() => {
|
||||
this.checklistGroup.items = this.checklistGroup.items.filter(item => item.ID != this.ID)
|
||||
okCallback()
|
||||
})
|
||||
.catch(errCallback)
|
||||
}//}}}
|
||||
move(to, okCallback) {//{{{
|
||||
window._app.current.request('/node/checklist_item/move', {
|
||||
ChecklistItemID: this.ID,
|
||||
AfterItemID: to.ID,
|
||||
})
|
||||
.then(okCallback)
|
||||
.catch(_app.current.responseError)
|
||||
}//}}}
|
||||
}
|
||||
|
||||
export class Checklist extends Component {
|
||||
constructor() {//{{{
|
||||
super()
|
||||
this.edit = signal(false)
|
||||
this.dragItemSource = null
|
||||
this.dragItemTarget = null
|
||||
this.groupElements = {}
|
||||
this.state = {
|
||||
confirmDeletion: true,
|
||||
continueAddingItems: true,
|
||||
}
|
||||
window._checklist = this
|
||||
}//}}}
|
||||
render({ ui, groups }, { confirmDeletion, continueAddingItems }) {//{{{
|
||||
this.groupElements = {}
|
||||
if (groups.length == 0 && !ui.node.value.ShowChecklist.value)
|
||||
return
|
||||
|
||||
if (typeof groups.sort != 'function')
|
||||
groups = []
|
||||
|
||||
groups.sort(ChecklistGroup.sort)
|
||||
let groupElements = groups.map(group => {
|
||||
this.groupElements[group.ID] = createRef()
|
||||
return html`<${ChecklistGroupElement} ref=${this.groupElements[group.ID]} key="group-${group.ID}" ui=${this} group=${group} />`
|
||||
})
|
||||
|
||||
let edit = 'edit-list-gray.svg'
|
||||
let confirmDeletionEl = ''
|
||||
if (this.edit.value) {
|
||||
edit = 'edit-list.svg'
|
||||
confirmDeletionEl = html`
|
||||
<div>
|
||||
<input type="checkbox" id="confirm-checklist-delete" checked=${confirmDeletion} onchange=${() => this.setState({ confirmDeletion: !confirmDeletion })} />
|
||||
<label for="confirm-checklist-delete">Confirm checklist deletion</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="checkbox" id="continue-adding-items" checked=${continueAddingItems} onchange=${() => this.setState({ continueAddingItems: !continueAddingItems })} />
|
||||
<label for="continue-adding-items">Continue adding items</label>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
let addGroup = () => {
|
||||
if (this.edit.value)
|
||||
return html`<img src="/images/${_VERSION}/add-gray.svg" onclick=${() => this.addGroup()} />`
|
||||
}
|
||||
|
||||
return html`
|
||||
<div id="checklist">
|
||||
<div class="header">
|
||||
<h1>Checklist</h1>
|
||||
<img src="/images/${_VERSION}/${edit}" onclick=${() => this.toggleEdit()} />
|
||||
<${addGroup} />
|
||||
</div>
|
||||
${confirmDeletionEl}
|
||||
${groupElements}
|
||||
</div>
|
||||
`
|
||||
}//}}}
|
||||
|
||||
toggleEdit() {//{{{
|
||||
this.edit.value = !this.edit.value
|
||||
}//}}}
|
||||
addGroup() {//{{{
|
||||
let label = prompt("Create a new group")
|
||||
if (label === null)
|
||||
return
|
||||
label = label.trim()
|
||||
if (label == '')
|
||||
return
|
||||
|
||||
window._app.current.request('/node/checklist_group/add', {
|
||||
NodeID: window._app.current.nodeUI.current.node.value.ID,
|
||||
Label: label,
|
||||
})
|
||||
.then(json => {
|
||||
let group = new ChecklistGroup(json.Group)
|
||||
this.props.groups.push(group)
|
||||
this.forceUpdate()
|
||||
})
|
||||
.catch(window._app.current.responseError)
|
||||
return
|
||||
}//}}}
|
||||
dragTarget(target) {//{{{
|
||||
if (this.dragItemTarget)
|
||||
this.dragItemTarget.setDragTarget(false)
|
||||
this.dragItemTarget = target
|
||||
target.setDragTarget(true)
|
||||
}//}}}
|
||||
dragReset() {//{{{
|
||||
if (this.dragItemTarget) {
|
||||
this.dragItemTarget.setDragTarget(false)
|
||||
this.dragItemTarget = null
|
||||
}
|
||||
}//}}}
|
||||
}
|
||||
|
||||
class InputElement extends Component {
|
||||
render({ placeholder, label }) {//{{{
|
||||
return html`
|
||||
<dialog id="input-text">
|
||||
<div class="container">
|
||||
<div class="label">${label}</div>
|
||||
<input id="input-text-el" type="text" placeholder=${placeholder} />
|
||||
<div class="buttons">
|
||||
<div></div>
|
||||
<button onclick=${()=>this.cancel()}>Cancel</button>
|
||||
<button onclick=${()=>this.ok()}>OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
`
|
||||
}//}}}
|
||||
componentDidMount() {//{{{
|
||||
const dlg = document.getElementById('input-text')
|
||||
const input = document.getElementById('input-text-el')
|
||||
dlg.showModal()
|
||||
dlg.addEventListener("keydown", evt => this.keyhandler(evt))
|
||||
input.addEventListener("keydown", evt => this.keyhandler(evt))
|
||||
input.focus()
|
||||
}//}}}
|
||||
ok() {//{{{
|
||||
const input = document.getElementById('input-text-el')
|
||||
this.props.callback(true, input.value)
|
||||
}//}}}
|
||||
cancel() {//{{{
|
||||
this.props.callback(false)
|
||||
}//}}}
|
||||
keyhandler(evt) {//{{{
|
||||
let handled = true
|
||||
switch (evt.key) {
|
||||
case 'Enter':
|
||||
this.ok()
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
this.cancel()
|
||||
break;
|
||||
default:
|
||||
handled = false
|
||||
}
|
||||
if (handled) {
|
||||
evt.stopPropagation()
|
||||
evt.preventDefault()
|
||||
}
|
||||
}//}}}
|
||||
}
|
||||
|
||||
class ChecklistGroupElement extends Component {
|
||||
constructor() {//{{{
|
||||
super()
|
||||
this.label = createRef()
|
||||
this.addingItem = signal(false)
|
||||
}//}}}
|
||||
render({ ui, group }) {//{{{
|
||||
let items = ({ ui, group }) =>
|
||||
group.items
|
||||
.sort(ChecklistItem.sort)
|
||||
.map(item => html`<${ChecklistItemElement} key="item-${item.ID}" ui=${ui} group=${this} item=${item} />`)
|
||||
|
||||
let label = () => html`<div class="label" style="cursor: pointer" ref=${this.label} onclick=${() => this.editLabel()}>${group.Label}</div>`
|
||||
let addItem = () => {
|
||||
if (this.addingItem.value)
|
||||
return html`<${InputElement} label="New item" callback=${(ok, val) => this.addItem(ok, val)} />`
|
||||
}
|
||||
|
||||
return html`
|
||||
<${addItem} />
|
||||
<div class="checklist-group-container">
|
||||
<div class="checklist-group ${ui.edit.value ? 'edit' : ''}">
|
||||
<div class="reorder" style="cursor: grab">☰</div>
|
||||
<img src="/images/${_VERSION}/trashcan.svg" onclick=${() => this.delete()} />
|
||||
<${label} />
|
||||
<img src="/images/${_VERSION}/add-gray.svg" onclick=${() => this.addingItem.value = true} />
|
||||
</div>
|
||||
<${items} ui=${ui} group=${group} />
|
||||
</div>
|
||||
`
|
||||
}//}}}
|
||||
addItem(ok, label) {//{{{
|
||||
if (!ok) {
|
||||
this.addingItem.value = false
|
||||
return
|
||||
}
|
||||
|
||||
label = label.trim()
|
||||
if (label == '') {
|
||||
this.addingItem.value = false
|
||||
return
|
||||
}
|
||||
|
||||
this.props.group.addItem(label, () => {
|
||||
this.forceUpdate()
|
||||
})
|
||||
|
||||
if (!this.props.ui.state.continueAddingItems)
|
||||
this.addingItem.value = false
|
||||
}//}}}
|
||||
editLabel() {//{{{
|
||||
let label = prompt('Edit label', this.props.group.Label)
|
||||
if (label === null)
|
||||
return
|
||||
|
||||
label = label.trim()
|
||||
if (label == '') {
|
||||
alert(`A label can't be empty.`)
|
||||
return
|
||||
}
|
||||
|
||||
this.label.current.classList.remove('error')
|
||||
this.props.group.updateLabel(label, () => {
|
||||
this.props.group.Label = label
|
||||
this.label.current.innerHTML = label
|
||||
this.label.current.classList.add('ok')
|
||||
this.forceUpdate()
|
||||
setTimeout(() => this.label.current.classList.remove('ok'), 500)
|
||||
}, () => {
|
||||
this.label.current.classList.add('error')
|
||||
})
|
||||
|
||||
}//}}}
|
||||
delete() {//{{{
|
||||
if (this.props.ui.state.confirmDeletion) {
|
||||
if (!confirm(`Delete '${this.props.group.Label}'?`))
|
||||
return
|
||||
}
|
||||
|
||||
this.props.group.delete(() => {
|
||||
this.props.ui.props.groups = this.props.ui.props.groups.filter(g => g.ID != this.props.group.ID)
|
||||
this.props.ui.forceUpdate()
|
||||
}, err => {
|
||||
console.log(err)
|
||||
console.log('error')
|
||||
})
|
||||
}//}}}
|
||||
}
|
||||
|
||||
class ChecklistItemElement extends Component {
|
||||
constructor(props) {//{{{
|
||||
super(props)
|
||||
this.state = {
|
||||
checked: props.item.Checked,
|
||||
dragTarget: false,
|
||||
}
|
||||
this.checkbox = createRef()
|
||||
this.label = createRef()
|
||||
}//}}}
|
||||
render({ ui, item }, { checked, dragTarget }) {//{{{
|
||||
let checkbox = () => {
|
||||
if (ui.edit.value)
|
||||
return html`<label ref=${this.label} onclick=${() => this.editLabel()} style="cursor: pointer">${item.Label}</label>`
|
||||
else
|
||||
return html`
|
||||
<input type="checkbox" ref=${this.checkbox} key="checkbox-${item.ID}" id="checkbox-${item.ID}" checked=${checked} onchange=${evt => this.update(evt.target.checked)} />
|
||||
<label ref=${this.label} for="checkbox-${item.ID}">${item.Label}</label>
|
||||
`
|
||||
}
|
||||
return html`
|
||||
<div class="checklist-item ${checked ? 'checked' : ''} ${ui.edit.value ? 'edit' : ''} ${dragTarget ? 'drag-target' : ''}" draggable=true>
|
||||
<div class="reorder" style="user-select: none;">☰</div>
|
||||
<img src="/images/${_VERSION}/trashcan.svg" onclick=${() => this.delete()} />
|
||||
<${checkbox} />
|
||||
</div>
|
||||
`
|
||||
}//}}}
|
||||
componentDidMount() {//{{{
|
||||
this.base.addEventListener('dragstart', evt => this.dragStart(evt))
|
||||
this.base.addEventListener('dragend', () => this.dragEnd())
|
||||
this.base.addEventListener('dragenter', evt => this.dragEnter(evt))
|
||||
}//}}}
|
||||
|
||||
update(checked) {//{{{
|
||||
this.setState({ checked })
|
||||
this.checkbox.current.classList.remove('error')
|
||||
this.props.item.updateState(checked, () => {
|
||||
this.checkbox.current.classList.add('ok')
|
||||
setTimeout(() => this.checkbox.current.classList.remove('ok'), 500)
|
||||
}, () => {
|
||||
this.checkbox.current.classList.add('error')
|
||||
})
|
||||
}//}}}
|
||||
editLabel() {//{{{
|
||||
let label = prompt('Edit label', this.props.item.Label)
|
||||
if (label === null)
|
||||
return
|
||||
|
||||
label = label.trim()
|
||||
if (label == '') {
|
||||
alert(`A label can't be empty.`)
|
||||
return
|
||||
}
|
||||
|
||||
this.label.current.classList.remove('error')
|
||||
this.props.item.updateLabel(label, () => {
|
||||
this.props.item.Label = label
|
||||
this.label.current.innerHTML = label
|
||||
this.label.current.classList.add('ok')
|
||||
setTimeout(() => this.label.current.classList.remove('ok'), 500)
|
||||
}, () => {
|
||||
this.label.current.classList.add('error')
|
||||
})
|
||||
|
||||
}//}}}
|
||||
delete() {//{{{
|
||||
if (this.props.ui.state.confirmDeletion) {
|
||||
if (!confirm(`Delete '${this.props.item.Label}'?`))
|
||||
return
|
||||
}
|
||||
|
||||
this.props.item.delete(() => {
|
||||
this.props.group.forceUpdate()
|
||||
}, err => {
|
||||
console.log(err)
|
||||
console.log('error')
|
||||
})
|
||||
}//}}}
|
||||
|
||||
setDragTarget(state) {//{{{
|
||||
this.setState({ dragTarget: state })
|
||||
}//}}}
|
||||
dragStart(evt) {//{{{
|
||||
// Shouldn't be needed, but in case the previous drag was bungled up, we reset.
|
||||
this.props.ui.dragReset()
|
||||
this.props.ui.dragItemSource = this
|
||||
|
||||
const img = new Image();
|
||||
evt.dataTransfer.setDragImage(img, 10, 10);
|
||||
}//}}}
|
||||
dragEnter(evt) {//{{{
|
||||
evt.preventDefault()
|
||||
this.props.ui.dragTarget(this)
|
||||
}//}}}
|
||||
dragEnd() {//{{{
|
||||
let groups = this.props.ui.props.groups
|
||||
let from = this.props.ui.dragItemSource.props.item
|
||||
let to = this.props.ui.dragItemTarget.props.item
|
||||
|
||||
this.props.ui.dragReset()
|
||||
|
||||
if (from.ID == to.ID)
|
||||
return
|
||||
|
||||
let fromGroup = groups.find(g => g.ID == from.GroupID)
|
||||
let toGroup = groups.find(g => g.ID == to.GroupID)
|
||||
|
||||
|
||||
from.Order = to.Order
|
||||
from.GroupID = toGroup.ID
|
||||
toGroup.items.forEach(i => {
|
||||
if (i.ID == from.ID)
|
||||
return
|
||||
if (i.Order <= to.Order)
|
||||
i.Order--
|
||||
})
|
||||
|
||||
if (fromGroup.ID != toGroup.ID) {
|
||||
fromGroup.items = fromGroup.items.filter(i => i.ID != from.ID)
|
||||
toGroup.items.push(from)
|
||||
}
|
||||
|
||||
this.props.ui.groupElements[fromGroup.ID].current.forceUpdate()
|
||||
this.props.ui.groupElements[toGroup.ID].current.forceUpdate()
|
||||
|
||||
from.move(to, () => {})
|
||||
}//}}}
|
||||
}
|
||||
|
||||
// vim: foldmethod=marker
|
72
static/js/crypto.mjs
Normal file
72
static/js/crypto.mjs
Normal file
@ -0,0 +1,72 @@
|
||||
export default class Crypto {
|
||||
constructor(key) {//{{{
|
||||
if(key === null)
|
||||
throw new Error("No key provided")
|
||||
|
||||
if(typeof key === 'string')
|
||||
this.key = sjcl.codec.base64.toBits(base64_key)
|
||||
else
|
||||
this.key = key
|
||||
|
||||
this.aes = new sjcl.cipher.aes(this.key)
|
||||
}//}}}
|
||||
|
||||
static generate_key() {//{{{
|
||||
return sjcl.random.randomWords(8)
|
||||
}//}}}
|
||||
static pass_to_key(pass, salt = null) {//{{{
|
||||
if(salt === null)
|
||||
salt = sjcl.random.randomWords(4) // 128 bits (16 bytes)
|
||||
let key = sjcl.misc.pbkdf2(pass, salt, 10000)
|
||||
|
||||
return {
|
||||
salt,
|
||||
key,
|
||||
}
|
||||
}//}}}
|
||||
|
||||
encrypt(plaintext_data_in_bits, counter, return_encoded = true) {//{{{
|
||||
// 8 bytes of random data, (1 word = 4 bytes) * 2
|
||||
// with 8 bytes of byte encoded counter is used as
|
||||
// IV to guarantee a non-repeated IV (which is a catastrophe).
|
||||
// Assumes counter value is kept unique. Counter is taken from
|
||||
// Postgres sequence.
|
||||
let random_bits = sjcl.random.randomWords(2)
|
||||
let iv_bytes = sjcl.codec.bytes.fromBits(random_bits)
|
||||
for (let i = 0; i < 8; ++i) {
|
||||
let mask = 0xffn << BigInt(i*8)
|
||||
let counter_i_byte = (counter & mask) >> BigInt(i*8)
|
||||
iv_bytes[15-i] = Number(counter_i_byte)
|
||||
}
|
||||
let iv = sjcl.codec.bytes.toBits(iv_bytes)
|
||||
|
||||
let encrypted = sjcl.mode['ccm'].encrypt(
|
||||
this.aes,
|
||||
plaintext_data_in_bits,
|
||||
iv,
|
||||
)
|
||||
|
||||
// Returning 16 bytes (4 words) IV + encrypted data.
|
||||
if(return_encoded)
|
||||
return sjcl.codec.base64.fromBits(
|
||||
iv.concat(encrypted)
|
||||
)
|
||||
else
|
||||
return iv.concat(encrypted)
|
||||
}//}}}
|
||||
decrypt(encrypted_base64_data) {//{{{
|
||||
try {
|
||||
let encoded = sjcl.codec.base64.toBits(encrypted_base64_data)
|
||||
let iv = encoded.slice(0, 4) // in words (4 bytes), not bytes
|
||||
let encrypted_data = encoded.slice(4)
|
||||
return sjcl.mode['ccm'].decrypt(this.aes, encrypted_data, iv)
|
||||
} catch(err) {
|
||||
if(err.message == `ccm: tag doesn't match`)
|
||||
throw('Decryption failed')
|
||||
else
|
||||
throw(err)
|
||||
}
|
||||
}//}}}
|
||||
}
|
||||
|
||||
// vim: foldmethod=marker
|
241
static/js/key.mjs
Normal file
241
static/js/key.mjs
Normal file
@ -0,0 +1,241 @@
|
||||
import 'preact/devtools'
|
||||
import { h, Component } from 'preact'
|
||||
import htm from 'htm'
|
||||
import Crypto from 'crypto'
|
||||
const html = htm.bind(h)
|
||||
|
||||
export class Keys extends Component {
|
||||
constructor(props) {//{{{
|
||||
super(props)
|
||||
this.state = {
|
||||
create: false,
|
||||
}
|
||||
|
||||
props.nodeui.retrieveKeys()
|
||||
}//}}}
|
||||
render({ nodeui }, { create }) {//{{{
|
||||
let keys = nodeui.keys.value
|
||||
.sort((a,b)=>{
|
||||
if(a.description < b.description) return -1
|
||||
if(a.description > b.description) return 1
|
||||
return 0
|
||||
})
|
||||
.map(key=>
|
||||
html`<${KeyComponent} key=${`key-${key.ID}`} model=${key} />`
|
||||
)
|
||||
|
||||
let createButton = ''
|
||||
let createComponents = ''
|
||||
if(create) {
|
||||
createComponents = html`
|
||||
<div id="key-create">
|
||||
<h2>New key</h2>
|
||||
|
||||
<div class="fields">
|
||||
<input type="text" id="key-description" placeholder="Name" />
|
||||
|
||||
<input type="password" id="key-pass1" placeholder="Password" />
|
||||
<input type="password" id="key-pass2" placeholder="Repeat password" />
|
||||
|
||||
<textarea id="key-key" placeholder="Key"></textarea>
|
||||
|
||||
<div>
|
||||
<button class="generate" onclick=${()=>this.generateKey()}>Generate</button>
|
||||
<button class="create" onclick=${()=>this.createKey()}>Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
} else {
|
||||
createButton = html`<div style="margin-top: 16px;"><button onclick=${()=>this.setState({ create: true })}>Create new key</button></div>`
|
||||
}
|
||||
|
||||
return html`
|
||||
<div id="keys">
|
||||
<h1>Encryption keys</h1>
|
||||
<p>
|
||||
Unlock a key by clicking its name. Lock it by clicking it again.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Copy the key and store it in a very secure place to have a way to access notes
|
||||
in case the password is forgotten, or database is corrupted.
|
||||
</p>
|
||||
|
||||
<p>Click "View key" after unlocking it.</p>
|
||||
|
||||
${createButton}
|
||||
${createComponents}
|
||||
|
||||
<h2>Keys</h2>
|
||||
<div class="key-list">
|
||||
${keys}
|
||||
</div>
|
||||
</div>`
|
||||
}//}}}
|
||||
|
||||
generateKey() {//{{{
|
||||
let keyTextarea = document.getElementById('key-key')
|
||||
let key = sjcl.codec.hex.fromBits(Crypto.generate_key()).replace(/(....)/g, '$1 ').trim()
|
||||
keyTextarea.value = key
|
||||
}//}}}
|
||||
validateNewKey() {//{{{
|
||||
let keyDescription = document.getElementById('key-description').value
|
||||
let keyTextarea = document.getElementById('key-key').value
|
||||
let pass1 = document.getElementById('key-pass1').value
|
||||
let pass2 = document.getElementById('key-pass2').value
|
||||
|
||||
if(keyDescription.trim() == '')
|
||||
throw new Error('The key has to have a description')
|
||||
|
||||
if(pass1.trim() == '' || pass1.length < 4)
|
||||
throw new Error('The password has to be at least 4 characters long.')
|
||||
|
||||
if(pass1 != pass2)
|
||||
throw new Error(`Passwords doesn't match`)
|
||||
|
||||
let cleanKey = keyTextarea.replace(/\s+/g, '')
|
||||
if(!cleanKey.match(/^[0-9a-f]{64}$/i))
|
||||
throw new Error('Invalid key - has to be 64 characters of 0-9 and A-F')
|
||||
}//}}}
|
||||
createKey() {//{{{
|
||||
try {
|
||||
this.validateNewKey()
|
||||
|
||||
let description = document.getElementById('key-description').value
|
||||
let keyAscii = document.getElementById('key-key').value
|
||||
let pass1 = document.getElementById('key-pass1').value
|
||||
|
||||
// Key in hex taken from user.
|
||||
let actual_key = sjcl.codec.hex.toBits(keyAscii.replace(/\s+/g, ''))
|
||||
|
||||
// Key generated from password, used to encrypt the actual key.
|
||||
let pass_gen = Crypto.pass_to_key(pass1)
|
||||
|
||||
let crypto = new Crypto(pass_gen.key)
|
||||
let encrypted_actual_key = crypto.encrypt(actual_key, 0x1n, false)
|
||||
|
||||
// Database value is salt + actual key, needed to generate the same key from the password.
|
||||
let db_encoded = sjcl.codec.hex.fromBits(
|
||||
pass_gen.salt.concat(encrypted_actual_key)
|
||||
)
|
||||
|
||||
// Create on server.
|
||||
window._app.current.request('/key/create', {
|
||||
description,
|
||||
key: db_encoded,
|
||||
})
|
||||
.then(res=>{
|
||||
let key = new Key(res.Key, this.props.nodeui.keyCounter)
|
||||
this.props.nodeui.keys.value = this.props.nodeui.keys.value.concat(key)
|
||||
})
|
||||
.catch(window._app.current.responseError)
|
||||
} catch(err) {
|
||||
alert(err.message)
|
||||
return
|
||||
}
|
||||
}//}}}
|
||||
}
|
||||
|
||||
export class Key {
|
||||
constructor(data, counter_callback) {//{{{
|
||||
this.ID = data.ID
|
||||
this.description = data.Description
|
||||
this.encryptedKey = data.Key
|
||||
this.key = null
|
||||
|
||||
this._counter_cbk = counter_callback
|
||||
|
||||
let hex_key = window.sessionStorage.getItem(`key-${this.ID}`)
|
||||
if(hex_key)
|
||||
this.key = sjcl.codec.hex.toBits(hex_key)
|
||||
}//}}}
|
||||
status() {//{{{
|
||||
if(this.key === null)
|
||||
return 'locked'
|
||||
return 'unlocked'
|
||||
}//}}}
|
||||
lock() {//{{{
|
||||
this.key = null
|
||||
window.sessionStorage.removeItem(`key-${this.ID}`)
|
||||
}//}}}
|
||||
unlock(password) {//{{{
|
||||
let db = sjcl.codec.hex.toBits(this.encryptedKey)
|
||||
let salt = db.slice(0, 4)
|
||||
let pass_key = Crypto.pass_to_key(password, salt)
|
||||
let crypto = new Crypto(pass_key.key)
|
||||
this.key = crypto.decrypt(sjcl.codec.base64.fromBits(db.slice(4)))
|
||||
window.sessionStorage.setItem(`key-${this.ID}`, sjcl.codec.hex.fromBits(this.key))
|
||||
}//}}}
|
||||
async counter() {//{{{
|
||||
return this._counter_cbk()
|
||||
}//}}}
|
||||
}
|
||||
|
||||
export class KeyComponent extends Component {
|
||||
constructor({ model }) {//{{{
|
||||
super({ model })
|
||||
this.state = {
|
||||
show_key: false,
|
||||
}
|
||||
}//}}}
|
||||
render({ model }, { show_key }) {//{{{
|
||||
let status = ''
|
||||
switch(model.status()) {
|
||||
case 'locked':
|
||||
status = html`<div class="status locked"><img src="/images/${window._VERSION}/padlock-closed.svg" /></div>`
|
||||
break
|
||||
|
||||
case 'unlocked':
|
||||
status = html`<div class="status unlocked"><img src="/images/${window._VERSION}/padlock-open.svg" /></div>`
|
||||
break
|
||||
}
|
||||
|
||||
let hex_key = ''
|
||||
if(show_key) {
|
||||
if(model.status() == 'locked')
|
||||
hex_key = html`<div class="hex-key">Unlock key first</div>`
|
||||
else {
|
||||
let key = sjcl.codec.hex.fromBits(model.key)
|
||||
key = key.replace(/(....)/g, "$1 ").trim()
|
||||
hex_key = html`<div class="hex-key">${key}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
let unlocked = model.status()=='unlocked'
|
||||
|
||||
return html`
|
||||
<div class="status" onclick=${()=>this.toggle()}>${status}</div>
|
||||
<div class="description" onclick=${()=>this.toggle()}>${model.description}</div>
|
||||
<div class="view" onclick=${()=>this.toggleViewKey()}>${unlocked ? 'View key' : ''}</div>
|
||||
${hex_key}
|
||||
`
|
||||
}//}}}
|
||||
toggle() {//{{{
|
||||
if(this.props.model.status() == 'locked')
|
||||
this.unlock()
|
||||
else
|
||||
this.lock()
|
||||
}//}}}
|
||||
lock() {//{{{
|
||||
this.props.model.lock()
|
||||
this.forceUpdate()
|
||||
}//}}}
|
||||
unlock() {//{{{
|
||||
let pass = prompt("Password")
|
||||
if(!pass)
|
||||
return
|
||||
|
||||
try {
|
||||
this.props.model.unlock(pass)
|
||||
this.forceUpdate()
|
||||
} catch(err) {
|
||||
alert(err)
|
||||
}
|
||||
}//}}}
|
||||
toggleViewKey() {//{{{
|
||||
this.setState({ show_key: !this.state.show_key })
|
||||
}//}}}
|
||||
}
|
||||
|
||||
// vim: foldmethod=marker
|
1117
static/js/node.mjs
Normal file
1117
static/js/node.mjs
Normal file
File diff suppressed because it is too large
Load Diff
52
static/less/notes2.less
Normal file
52
static/less/notes2.less
Normal file
@ -0,0 +1,52 @@
|
||||
@import "theme.less";
|
||||
|
||||
#tree {
|
||||
grid-area: tree;
|
||||
padding: 16px;
|
||||
background-color: #333;
|
||||
color: #ddd;
|
||||
z-index: 100; // Over crumbs shadow
|
||||
|
||||
.node {
|
||||
display: grid;
|
||||
grid-template-columns: 24px min-content;
|
||||
grid-template-rows:
|
||||
min-content
|
||||
1fr;
|
||||
margin-top: 12px;
|
||||
|
||||
|
||||
.expand-toggle {
|
||||
img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
color: @color1;
|
||||
}
|
||||
&.selected {
|
||||
color: @color1;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.children {
|
||||
padding-left: 24px;
|
||||
margin-left: 8px;
|
||||
border-left: 1px solid #555;
|
||||
grid-column: 1 / -1;
|
||||
|
||||
&.collapsed {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -21,7 +21,11 @@
|
||||
"preact/signals": "/js/{{ .VERSION }}/lib/signals/signals.mjs",
|
||||
"htm": "/js/{{ .VERSION }}/lib/htm/htm.mjs",
|
||||
|
||||
"api": "/js/{{ .VERSION }}/api.mjs"
|
||||
"api": "/js/{{ .VERSION }}/api.mjs",
|
||||
"key": "/js/{{ .VERSION }}/key.mjs",
|
||||
"checklist": "/js/{{ .VERSION }}/checklist.mjs",
|
||||
"crypto": "/js/{{ .VERSION }}/crypto.mjs",
|
||||
"node": "/js/{{ .VERSION }}/node.mjs"
|
||||
{{/*
|
||||
"session": "/js/{{ .VERSION }}/session.mjs",
|
||||
"node": "/js/{{ .VERSION }}/node.mjs",
|
||||
|
@ -1,4 +1,5 @@
|
||||
{{ define "page" }}
|
||||
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/notes2.css">
|
||||
|
||||
<script type="module">
|
||||
import { h, Component, render, createRef } from 'preact'
|
||||
|
Loading…
Reference in New Issue
Block a user