Compare commits
13 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bd5ef1f02 | ||
|
|
43212a4487 | ||
|
|
6ce0cd838d | ||
|
|
eb5fc1c59e | ||
|
|
b839e5abc9 | ||
|
|
2fece1ede4 | ||
|
|
2582de90aa | ||
|
|
bbce399bc3 | ||
|
|
f3e16207a6 | ||
|
|
662053e750 | ||
|
|
5a67c638c6 | ||
|
|
4c513a5106 | ||
|
|
75d242c041 |
1
.gitignore
vendored
|
|
@ -1 +1,2 @@
|
|||
notes2
|
||||
untracked
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package main
|
|||
import (
|
||||
// Standard
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
|
|
@ -27,9 +26,8 @@ type Config struct {
|
|||
}
|
||||
}
|
||||
|
||||
func readConfig() (err error) {
|
||||
func readConfig(fname string) (err error) {
|
||||
var configData []byte
|
||||
fname := fmt.Sprintf("%s/.config/notes2.json", os.Getenv("HOME"))
|
||||
configData, err = os.ReadFile(fname)
|
||||
if err != nil {
|
||||
return
|
||||
|
|
|
|||
28
main.go
|
|
@ -23,7 +23,7 @@ import (
|
|||
"text/template"
|
||||
)
|
||||
|
||||
const VERSION = "v2"
|
||||
const VERSION = "v5"
|
||||
const CONTEXT_USER = 1
|
||||
const SYNC_PAGINATION = 200
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ func initLog() { // {{{
|
|||
} // }}}
|
||||
func main() { // {{{
|
||||
initLog()
|
||||
err := readConfig()
|
||||
err := readConfig(FlagConfig)
|
||||
if err != nil {
|
||||
Log.Error("config", "error", err)
|
||||
os.Exit(1)
|
||||
|
|
@ -129,7 +129,6 @@ func main() { // {{{
|
|||
}
|
||||
|
||||
http.HandleFunc("/", rootHandler)
|
||||
http.HandleFunc("/notes2", pageNotes2)
|
||||
http.HandleFunc("/login", pageLogin)
|
||||
http.HandleFunc("/sync", pageSync)
|
||||
http.HandleFunc("/offline", pageOffline)
|
||||
|
|
@ -189,7 +188,13 @@ func rootHandler(w http.ResponseWriter, r *http.Request) { // {{{
|
|||
// All URLs not specifically handled are routed to this function.
|
||||
// Everything going here should be a static resource.
|
||||
if r.URL.Path == "/" {
|
||||
http.Redirect(w, r, "/notes2", http.StatusSeeOther)
|
||||
page := NewPage("notes2")
|
||||
|
||||
err := Webengine.Render(page, w, r)
|
||||
if err != nil {
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -245,15 +250,6 @@ func pageLogin(w http.ResponseWriter, r *http.Request) { // {{{
|
|||
return
|
||||
}
|
||||
} // }}}
|
||||
func pageNotes2(w http.ResponseWriter, r *http.Request) { // {{{
|
||||
page := NewPage("notes2")
|
||||
|
||||
err := Webengine.Render(page, w, r)
|
||||
if err != nil {
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
} // }}}
|
||||
func pageSync(w http.ResponseWriter, r *http.Request) { // {{{
|
||||
page := NewPage("sync")
|
||||
|
||||
|
|
@ -280,9 +276,9 @@ func actionSyncFromServer(w http.ResponseWriter, r *http.Request) { // {{{
|
|||
}
|
||||
|
||||
/*
|
||||
Log.Debug("/sync/from_server", "num_nodes", len(nodes), "maxSeq", maxSeq)
|
||||
foo, _ := json.Marshal(nodes)
|
||||
os.WriteFile(fmt.Sprintf("/tmp/nodes-%d.json", offset), foo, 0644)
|
||||
Log.Debug("/sync/from_server", "num_nodes", len(nodes), "maxSeq", maxSeq)
|
||||
foo, _ := json.Marshal(nodes)
|
||||
os.WriteFile(fmt.Sprintf("/tmp/nodes-%d.json", offset), foo, 0644)
|
||||
*/
|
||||
|
||||
j, _ := json.Marshal(struct {
|
||||
|
|
|
|||
577
sql/00001.sql
|
|
@ -1,48 +1,218 @@
|
|||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
--
|
||||
-- Name: pg_trgm; Type: EXTENSION; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
CREATE SEQUENCE node_updates;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;
|
||||
|
||||
CREATE TABLE public."user" (
|
||||
id serial4 NOT NULL,
|
||||
username varchar NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"password" varchar NOT NULL,
|
||||
last_login timestamp DEFAULT now() NOT NULL,
|
||||
timezone varchar DEFAULT 'UTC'::character varying NOT NULL,
|
||||
CONSTRAINT user_pk PRIMARY KEY (id)
|
||||
|
||||
--
|
||||
-- Name: EXTENSION pg_trgm; Type: COMMENT; Schema: -; Owner:
|
||||
--
|
||||
|
||||
COMMENT ON EXTENSION pg_trgm IS 'text similarity measurement and index searching based on trigrams';
|
||||
|
||||
|
||||
--
|
||||
-- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public;
|
||||
|
||||
|
||||
--
|
||||
-- Name: EXTENSION pgcrypto; Type: COMMENT; Schema: -; Owner:
|
||||
--
|
||||
|
||||
COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions';
|
||||
|
||||
|
||||
--
|
||||
-- Name: json_ancestor_array; Type: TYPE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TYPE public.json_ancestor_array AS (
|
||||
"Ancestors" character varying[]
|
||||
);
|
||||
|
||||
CREATE TABLE public.node (
|
||||
id serial4 NOT NULL,
|
||||
user_id int4 NOT NULL,
|
||||
"uuid" bpchar(36) DEFAULT gen_random_uuid() NOT NULL,
|
||||
parent_uuid bpchar(36) NULL,
|
||||
--
|
||||
-- Name: add_nodes(integer, character varying, jsonb); Type: PROCEDURE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
created timestamptz DEFAULT NOW() NOT NULL,
|
||||
updated timestamptz DEFAULT NOW() NOT NULL,
|
||||
deleted timestamptz NULL,
|
||||
CREATE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid character varying, IN p_nodes jsonb)
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
|
||||
created_seq bigint NOT NULL DEFAULT nextval('node_updates'),
|
||||
updated_seq bigint NOT NULL DEFAULT nextval('node_updates'),
|
||||
deleted_seq bigint NULL,
|
||||
DECLARE
|
||||
node_data jsonb;
|
||||
node_updated timestamptz;
|
||||
db_updated timestamptz;
|
||||
db_uuid bpchar;
|
||||
db_client bpchar;
|
||||
db_client_seq int;
|
||||
node_uuid bpchar;
|
||||
parent_uuid_nullable bpchar;
|
||||
|
||||
"name" varchar(256) DEFAULT ''::character varying NOT NULL,
|
||||
"content" text DEFAULT ''::text NOT NULL,
|
||||
content_encrypted text DEFAULT ''::text NOT NULL,
|
||||
markdown bool DEFAULT false NOT NULL,
|
||||
BEGIN
|
||||
RAISE NOTICE '--------------------------';
|
||||
FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes)
|
||||
LOOP
|
||||
node_uuid = (node_data->>'UUID')::bpchar;
|
||||
node_updated = (node_data->>'Updated')::timestamptz;
|
||||
|
||||
CONSTRAINT name_length CHECK ((length(TRIM(BOTH FROM 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
|
||||
);
|
||||
CREATE UNIQUE INDEX node_uuid_idx ON public.node USING btree (uuid);
|
||||
CREATE INDEX node_search_index ON public.node USING gin (name gin_trgm_ops, content gin_trgm_ops);
|
||||
IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' THEN
|
||||
parent_uuid_nullable = NULL;
|
||||
ELSE
|
||||
parent_uuid_nullable = node_data->>'ParentUUID';
|
||||
END IF;
|
||||
|
||||
CREATE OR REPLACE FUNCTION node_update_timestamp()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE PLPGSQL
|
||||
AS $$
|
||||
/* Retrieve the current modified timestamp for this node from the database. */
|
||||
SELECT
|
||||
uuid, updated, client, client_sequence
|
||||
INTO
|
||||
db_uuid, db_updated, db_client, db_client_seq
|
||||
FROM public."node"
|
||||
WHERE
|
||||
user_id = p_user_id AND
|
||||
uuid = node_uuid;
|
||||
|
||||
/* Is the node not in database? It needs to be created. */
|
||||
IF db_uuid IS NULL THEN
|
||||
RAISE NOTICE '01 New node %', node_uuid;
|
||||
INSERT INTO public."node" (
|
||||
user_id, "uuid", parent_uuid, created, updated,
|
||||
"name", "content", markdown, "content_encrypted",
|
||||
client, client_sequence
|
||||
)
|
||||
VALUES(
|
||||
p_user_id,
|
||||
node_uuid,
|
||||
parent_uuid_nullable,
|
||||
(node_data->>'Created')::timestamptz,
|
||||
(node_data->>'Updated')::timestamptz,
|
||||
(node_data->>'Name')::varchar,
|
||||
(node_data->>'Content')::text,
|
||||
(node_data->>'Markdown')::bool,
|
||||
'', /* content_encrypted */
|
||||
p_client_uuid,
|
||||
(node_data->>'ClientSequence')::int
|
||||
);
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
|
||||
/* The client could send a specific node again if it didn't receive the OK from this procedure before. */
|
||||
IF db_updated = node_updated AND db_client = p_client_uuid AND db_client_seq = (node_data->>'ClientSequence')::int THEN
|
||||
RAISE NOTICE '04, already recorded, %, %', db_client, db_client_seq;
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
/* Determine if the incoming node data is to go into history or replace the current node. */
|
||||
IF db_updated > node_updated THEN
|
||||
RAISE NOTICE '02 DB newer, % > % (%))', db_updated, node_updated, node_uuid;
|
||||
/* Incoming node is going straight to history since it is older than the current node. */
|
||||
INSERT INTO node_history(
|
||||
user_id, "uuid", parents, created, updated,
|
||||
"name", "content", markdown, "content_encrypted",
|
||||
client, client_sequence
|
||||
)
|
||||
VALUES(
|
||||
p_user_id,
|
||||
node_uuid,
|
||||
(jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors",
|
||||
(node_data->>'Created')::timestamptz,
|
||||
(node_data->>'Updated')::timestamptz,
|
||||
(node_data->>'Name')::varchar,
|
||||
(node_data->>'Content')::text,
|
||||
(node_data->>'Markdown')::bool,
|
||||
'', /* content_encrypted */
|
||||
p_client_uuid,
|
||||
(node_data->>'ClientSequence')::int
|
||||
)
|
||||
ON CONFLICT (client, client_sequence)
|
||||
DO NOTHING;
|
||||
ELSE
|
||||
RAISE NOTICE '03 Client newer, % > % (%, %)', node_updated, db_updated, node_uuid, (node_data->>'ClientSequence');
|
||||
/* Incoming node is newer and will replace the current node.
|
||||
*
|
||||
* The current node is copied to the node_history table and then modified in place
|
||||
* with the incoming data. */
|
||||
INSERT INTO node_history(
|
||||
user_id, "uuid", parents,
|
||||
created, updated, "name", "content", markdown, "content_encrypted",
|
||||
client, client_sequence
|
||||
)
|
||||
SELECT
|
||||
user_id,
|
||||
"uuid",
|
||||
(
|
||||
WITH RECURSIVE nodes AS (
|
||||
SELECT
|
||||
uuid,
|
||||
COALESCE(parent_uuid, '') AS parent_uuid,
|
||||
name,
|
||||
0 AS depth
|
||||
FROM node
|
||||
WHERE
|
||||
uuid = node_uuid
|
||||
|
||||
UNION
|
||||
|
||||
SELECT
|
||||
n.uuid,
|
||||
COALESCE(n.parent_uuid, '') AS parent_uuid,
|
||||
n.name,
|
||||
nr.depth+1 AS depth
|
||||
FROM node n
|
||||
INNER JOIN nodes nr ON n.uuid = nr.parent_uuid
|
||||
)
|
||||
SELECT ARRAY (
|
||||
SELECT name
|
||||
FROM nodes
|
||||
ORDER BY depth DESC
|
||||
OFFSET 1 /* discard itself */
|
||||
)
|
||||
),
|
||||
created,
|
||||
updated,
|
||||
name,
|
||||
content,
|
||||
markdown,
|
||||
content_encrypted,
|
||||
client,
|
||||
client_sequence
|
||||
FROM public."node"
|
||||
WHERE
|
||||
user_id = p_user_id AND
|
||||
uuid = node_uuid
|
||||
ON CONFLICT (client, client_sequence)
|
||||
DO NOTHING;
|
||||
|
||||
/* Current node in database is updated with incoming data. */
|
||||
UPDATE public."node"
|
||||
SET
|
||||
updated = (node_data->>'Updated')::timestamptz,
|
||||
updated_seq = nextval('node_updates'),
|
||||
name = (node_data->>'Name')::varchar,
|
||||
content = (node_data->>'Content')::text,
|
||||
markdown = (node_data->>'Markdown')::bool,
|
||||
client = p_client_uuid,
|
||||
client_sequence = (node_data->>'ClientSequence')::int
|
||||
WHERE
|
||||
user_id = p_user_id AND
|
||||
uuid = node_uuid;
|
||||
END IF;
|
||||
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
||||
--
|
||||
-- Name: node_update_timestamp(); Type: FUNCTION; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.node_update_timestamp() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.updated = OLD.updated THEN
|
||||
UPDATE node
|
||||
|
|
@ -56,6 +226,335 @@ BEGIN
|
|||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE TRIGGER node_update AFTER UPDATE ON node
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE node_update_timestamp();
|
||||
|
||||
--
|
||||
-- Name: password_hash(character, bytea); Type: FUNCTION; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.password_hash(salt_hex character, pass bytea) RETURNS character
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT
|
||||
salt_hex ||
|
||||
encode(
|
||||
sha256(
|
||||
decode(salt_hex, 'hex') || /* salt in binary */
|
||||
pass /* password */
|
||||
),
|
||||
'hex'
|
||||
)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
--
|
||||
-- Name: client; Type: TABLE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TABLE public.client (
|
||||
id integer NOT NULL,
|
||||
user_id integer NOT NULL,
|
||||
client_uuid character(36) DEFAULT ''::bpchar NOT NULL,
|
||||
created timestamp with time zone DEFAULT now() NOT NULL,
|
||||
description character varying DEFAULT ''::character varying NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: client_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.client_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: client_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.client_id_seq OWNED BY public.client.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: node_updates; Type: SEQUENCE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.node_updates
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: node; Type: TABLE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TABLE public.node (
|
||||
id integer NOT NULL,
|
||||
user_id integer NOT NULL,
|
||||
uuid character(36) DEFAULT gen_random_uuid() NOT NULL,
|
||||
parent_uuid character(36),
|
||||
created timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated timestamp with time zone DEFAULT now() NOT NULL,
|
||||
deleted timestamp with time zone,
|
||||
created_seq bigint DEFAULT nextval('public.node_updates'::regclass) NOT NULL,
|
||||
updated_seq bigint DEFAULT nextval('public.node_updates'::regclass) NOT NULL,
|
||||
deleted_seq bigint,
|
||||
name character varying(256) DEFAULT ''::character varying NOT NULL,
|
||||
content text DEFAULT ''::text NOT NULL,
|
||||
content_encrypted text DEFAULT ''::text NOT NULL,
|
||||
markdown boolean DEFAULT false NOT NULL,
|
||||
history boolean DEFAULT false NOT NULL,
|
||||
client character(36) DEFAULT ''::bpchar NOT NULL,
|
||||
client_sequence integer,
|
||||
CONSTRAINT name_length CHECK ((length(TRIM(BOTH FROM name)) > 0))
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: node_history; Type: TABLE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TABLE public.node_history (
|
||||
id integer NOT NULL,
|
||||
user_id integer NOT NULL,
|
||||
uuid character(36) NOT NULL,
|
||||
parents character varying[],
|
||||
created timestamp with time zone NOT NULL,
|
||||
updated timestamp with time zone NOT NULL,
|
||||
name character varying(256) NOT NULL,
|
||||
content text NOT NULL,
|
||||
content_encrypted text NOT NULL,
|
||||
markdown boolean DEFAULT false NOT NULL,
|
||||
client character(36) DEFAULT ''::bpchar NOT NULL,
|
||||
client_sequence integer
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: node_history_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.node_history_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: node_history_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.node_history_id_seq OWNED BY public.node_history.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: node_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.node_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: node_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.node_id_seq OWNED BY public.node.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: test_data; Type: SEQUENCE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.test_data
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: user; Type: TABLE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TABLE public."user" (
|
||||
id integer NOT NULL,
|
||||
username character varying NOT NULL,
|
||||
name character varying NOT NULL,
|
||||
password character varying NOT NULL,
|
||||
last_login timestamp without time zone DEFAULT now() NOT NULL,
|
||||
timezone character varying DEFAULT 'UTC'::character varying NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: user_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.user_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: user_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.user_id_seq OWNED BY public."user".id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: client id; Type: DEFAULT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.client ALTER COLUMN id SET DEFAULT nextval('public.client_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: node id; Type: DEFAULT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.node ALTER COLUMN id SET DEFAULT nextval('public.node_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: node_history id; Type: DEFAULT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.node_history ALTER COLUMN id SET DEFAULT nextval('public.node_history_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: user id; Type: DEFAULT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public."user" ALTER COLUMN id SET DEFAULT nextval('public.user_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: client client_pk; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.client
|
||||
ADD CONSTRAINT client_pk PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: node_history node_history_pk; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.node_history
|
||||
ADD CONSTRAINT node_history_pk PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: node node_pk; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.node
|
||||
ADD CONSTRAINT node_pk PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: user user_pk; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public."user"
|
||||
ADD CONSTRAINT user_pk PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: client_uuid_idx; Type: INDEX; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE UNIQUE INDEX client_uuid_idx ON public.client USING btree (client_uuid);
|
||||
|
||||
|
||||
--
|
||||
-- Name: node_history_client_idx; Type: INDEX; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE UNIQUE INDEX node_history_client_idx ON public.node_history USING btree (client, client_sequence);
|
||||
|
||||
|
||||
--
|
||||
-- Name: node_history_idx; Type: INDEX; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE INDEX node_history_idx ON public.node USING btree (history);
|
||||
|
||||
|
||||
--
|
||||
-- Name: node_history_uuid_idx; Type: INDEX; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE INDEX node_history_uuid_idx ON public.node USING btree (uuid);
|
||||
|
||||
|
||||
--
|
||||
-- Name: node_search_index; Type: INDEX; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE INDEX node_search_index ON public.node USING gin (name public.gin_trgm_ops, content public.gin_trgm_ops);
|
||||
|
||||
|
||||
--
|
||||
-- Name: node_uuid_idx; Type: INDEX; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE UNIQUE INDEX node_uuid_idx ON public.node USING btree (uuid);
|
||||
|
||||
|
||||
--
|
||||
-- Name: node node_update; Type: TRIGGER; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TRIGGER node_update AFTER UPDATE ON public.node FOR EACH ROW EXECUTE FUNCTION public.node_update_timestamp();
|
||||
|
||||
|
||||
--
|
||||
-- Name: node_history node_history_user_fk; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.node_history
|
||||
ADD CONSTRAINT node_history_user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON UPDATE RESTRICT ON DELETE RESTRICT;
|
||||
|
||||
|
||||
--
|
||||
-- Name: node node_node_fk; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.node
|
||||
ADD CONSTRAINT node_node_fk FOREIGN KEY (parent_uuid) REFERENCES public.node(uuid) ON UPDATE SET NULL ON DELETE SET NULL;
|
||||
|
||||
|
||||
--
|
||||
-- Name: node user_fk; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.node
|
||||
ADD CONSTRAINT user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON UPDATE RESTRICT ON DELETE RESTRICT;
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
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;
|
||||
$$;
|
||||
|
|
@ -1 +0,0 @@
|
|||
ALTER TABLE public.node ADD CONSTRAINT node_node_fk FOREIGN KEY (parent_uuid) REFERENCES public.node("uuid") ON DELETE SET NULL ON UPDATE SET NULL;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
ALTER TABLE public.node ADD COLUMN history bool NOT NULL DEFAULT false;
|
||||
CREATE INDEX node_history_idx ON public.node (history);
|
||||
|
|
@ -1 +0,0 @@
|
|||
ALTER TABLE public.node ADD COLUMN client bpchar(36) NOT NULL DEFAULT '';
|
||||
|
|
@ -1 +0,0 @@
|
|||
DROP INDEX public.node_uuid_idx;
|
||||
162
sql/00007.sql
|
|
@ -1,162 +0,0 @@
|
|||
CREATE TYPE json_ancestor_array as ("Ancestors" varchar[]);
|
||||
|
||||
|
||||
CREATE OR REPLACE PROCEDURE add_nodes(p_user_id int4, p_client_uuid varchar, p_nodes jsonb)
|
||||
LANGUAGE PLPGSQL AS $$
|
||||
|
||||
DECLARE
|
||||
node_data jsonb;
|
||||
node_updated timestamptz;
|
||||
db_updated timestamptz;
|
||||
db_uuid bpchar;
|
||||
db_client bpchar;
|
||||
db_client_seq int;
|
||||
node_uuid bpchar;
|
||||
|
||||
BEGIN
|
||||
RAISE NOTICE '--------------------------';
|
||||
FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes)
|
||||
LOOP
|
||||
node_uuid = (node_data->>'UUID')::bpchar;
|
||||
node_updated = (node_data->>'Updated')::timestamptz;
|
||||
|
||||
/* Retrieve the current modified timestamp for this node from the database. */
|
||||
SELECT
|
||||
uuid, updated, client, client_sequence
|
||||
INTO
|
||||
db_uuid, db_updated, db_client, db_client_seq
|
||||
FROM public."node"
|
||||
WHERE
|
||||
user_id = p_user_id AND
|
||||
uuid = node_uuid;
|
||||
|
||||
/* Is the node not in database? It needs to be created. */
|
||||
IF db_uuid IS NULL THEN
|
||||
RAISE NOTICE '01 New node %', node_uuid;
|
||||
INSERT INTO public."node" (
|
||||
user_id, "uuid", parent_uuid, created, updated,
|
||||
"name", "content", markdown, "content_encrypted",
|
||||
client, client_sequence
|
||||
)
|
||||
VALUES(
|
||||
p_user_id,
|
||||
node_uuid,
|
||||
(node_data->>'ParentUUID')::bpchar,
|
||||
(node_data->>'Created')::timestamptz,
|
||||
(node_data->>'Updated')::timestamptz,
|
||||
(node_data->>'Name')::varchar,
|
||||
(node_data->>'Content')::text,
|
||||
(node_data->>'Markdown')::bool,
|
||||
'', /* content_encrypted */
|
||||
p_client_uuid,
|
||||
(node_data->>'ClientSequence')::int
|
||||
);
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
|
||||
/* The client could send a specific node again if it didn't receive the OK from this procedure before. */
|
||||
IF db_updated = node_updated AND db_client = p_client_uuid AND db_client_seq = (node_data->>'ClientSequence')::int THEN
|
||||
RAISE NOTICE '04, already recorded, %, %', db_client, db_client_seq;
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
/* Determine if the incoming node data is to go into history or replace the current node. */
|
||||
IF db_updated > node_updated THEN
|
||||
RAISE NOTICE '02 DB newer, % > % (%))', db_updated, node_updated, node_uuid;
|
||||
/* Incoming node is going straight to history since it is older than the current node. */
|
||||
INSERT INTO node_history(
|
||||
user_id, "uuid", parents, created, updated,
|
||||
"name", "content", markdown, "content_encrypted",
|
||||
client, client_sequence
|
||||
)
|
||||
VALUES(
|
||||
p_user_id,
|
||||
node_uuid,
|
||||
(jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors",
|
||||
(node_data->>'Created')::timestamptz,
|
||||
(node_data->>'Updated')::timestamptz,
|
||||
(node_data->>'Name')::varchar,
|
||||
(node_data->>'Content')::text,
|
||||
(node_data->>'Markdown')::bool,
|
||||
'', /* content_encrypted */
|
||||
p_client_uuid,
|
||||
(node_data->>'ClientSequence')::int
|
||||
)
|
||||
ON CONFLICT (client, client_sequence)
|
||||
DO NOTHING;
|
||||
ELSE
|
||||
RAISE NOTICE '03 Client newer, % > % (%, %)', node_updated, db_updated, node_uuid, (node_data->>'ClientSequence');
|
||||
/* Incoming node is newer and will replace the current node.
|
||||
*
|
||||
* The current node is copied to the node_history table and then modified in place
|
||||
* with the incoming data. */
|
||||
INSERT INTO node_history(
|
||||
user_id, "uuid", parents,
|
||||
created, updated, "name", "content", markdown, "content_encrypted",
|
||||
client, client_sequence
|
||||
)
|
||||
SELECT
|
||||
user_id,
|
||||
"uuid",
|
||||
(
|
||||
WITH RECURSIVE nodes AS (
|
||||
SELECT
|
||||
uuid,
|
||||
COALESCE(parent_uuid, '') AS parent_uuid,
|
||||
name,
|
||||
0 AS depth
|
||||
FROM node
|
||||
WHERE
|
||||
uuid = node_uuid
|
||||
|
||||
UNION
|
||||
|
||||
SELECT
|
||||
n.uuid,
|
||||
COALESCE(n.parent_uuid, '') AS parent_uuid,
|
||||
n.name,
|
||||
nr.depth+1 AS depth
|
||||
FROM node n
|
||||
INNER JOIN nodes nr ON n.uuid = nr.parent_uuid
|
||||
)
|
||||
SELECT ARRAY (
|
||||
SELECT name
|
||||
FROM nodes
|
||||
ORDER BY depth DESC
|
||||
OFFSET 1 /* discard itself */
|
||||
)
|
||||
),
|
||||
created,
|
||||
updated,
|
||||
name,
|
||||
content,
|
||||
markdown,
|
||||
content_encrypted,
|
||||
client,
|
||||
client_sequence
|
||||
FROM public."node"
|
||||
WHERE
|
||||
user_id = p_user_id AND
|
||||
uuid = node_uuid
|
||||
ON CONFLICT (client, client_sequence)
|
||||
DO NOTHING;
|
||||
|
||||
/* Current node in database is updated with incoming data. */
|
||||
UPDATE public."node"
|
||||
SET
|
||||
updated = (node_data->>'Updated')::timestamptz,
|
||||
updated_seq = nextval('node_updates'),
|
||||
name = (node_data->>'Name')::varchar,
|
||||
content = (node_data->>'Content')::text,
|
||||
markdown = (node_data->>'Markdown')::bool,
|
||||
client = p_client_uuid,
|
||||
client_sequence = (node_data->>'ClientSequence')::int
|
||||
WHERE
|
||||
user_id = p_user_id AND
|
||||
uuid = node_uuid;
|
||||
END IF;
|
||||
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
ALTER TABLE node ADD COLUMN Client_sequence int NULL;
|
||||
ALTER TABLE node_history ADD COLUMN Client_sequence int NULL;
|
||||
|
|
@ -1 +0,0 @@
|
|||
CREATE UNIQUE INDEX node_history_client_idx ON public.node_history (client,client_sequence);
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
CREATE TABLE public.client (
|
||||
id serial NOT NULL,
|
||||
user_id int4 NOT NULL,
|
||||
client_uuid bpchar(36) DEFAULT '' NOT NULL,
|
||||
created timestamptz DEFAULT NOW() NOT NULL,
|
||||
description varchar DEFAULT '' NOT NULL,
|
||||
CONSTRAINT client_pk PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX client_uuid_idx ON public.client (client_uuid);
|
||||
166
sql/00011.sql
|
|
@ -1,166 +0,0 @@
|
|||
CREATE OR REPLACE PROCEDURE add_nodes(p_user_id int4, p_client_uuid varchar, p_nodes jsonb)
|
||||
LANGUAGE PLPGSQL AS $$
|
||||
|
||||
DECLARE
|
||||
node_data jsonb;
|
||||
node_updated timestamptz;
|
||||
db_updated timestamptz;
|
||||
db_uuid bpchar;
|
||||
db_client bpchar;
|
||||
db_client_seq int;
|
||||
node_uuid bpchar;
|
||||
parent_uuid bpchar;
|
||||
|
||||
BEGIN
|
||||
RAISE NOTICE '--------------------------';
|
||||
FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes)
|
||||
LOOP
|
||||
node_uuid = (node_data->>'UUID')::bpchar;
|
||||
node_updated = (node_data->>'Updated')::timestamptz;
|
||||
|
||||
IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' THEN
|
||||
parent_uuid = NULL;
|
||||
ELSE
|
||||
parent_uuid = node_data->>'ParentUUID';
|
||||
END IF;
|
||||
|
||||
/* Retrieve the current modified timestamp for this node from the database. */
|
||||
SELECT
|
||||
uuid, updated, client, client_sequence
|
||||
INTO
|
||||
db_uuid, db_updated, db_client, db_client_seq
|
||||
FROM public."node"
|
||||
WHERE
|
||||
user_id = p_user_id AND
|
||||
uuid = node_uuid;
|
||||
|
||||
/* Is the node not in database? It needs to be created. */
|
||||
IF db_uuid IS NULL THEN
|
||||
RAISE NOTICE '01 New node %', node_uuid;
|
||||
INSERT INTO public."node" (
|
||||
user_id, "uuid", parent_uuid, created, updated,
|
||||
"name", "content", markdown, "content_encrypted",
|
||||
client, client_sequence
|
||||
)
|
||||
VALUES(
|
||||
p_user_id,
|
||||
node_uuid,
|
||||
parent_uuid,
|
||||
(node_data->>'Created')::timestamptz,
|
||||
(node_data->>'Updated')::timestamptz,
|
||||
(node_data->>'Name')::varchar,
|
||||
(node_data->>'Content')::text,
|
||||
(node_data->>'Markdown')::bool,
|
||||
'', /* content_encrypted */
|
||||
p_client_uuid,
|
||||
(node_data->>'ClientSequence')::int
|
||||
);
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
|
||||
/* The client could send a specific node again if it didn't receive the OK from this procedure before. */
|
||||
IF db_updated = node_updated AND db_client = p_client_uuid AND db_client_seq = (node_data->>'ClientSequence')::int THEN
|
||||
RAISE NOTICE '04, already recorded, %, %', db_client, db_client_seq;
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
/* Determine if the incoming node data is to go into history or replace the current node. */
|
||||
IF db_updated > node_updated THEN
|
||||
RAISE NOTICE '02 DB newer, % > % (%))', db_updated, node_updated, node_uuid;
|
||||
/* Incoming node is going straight to history since it is older than the current node. */
|
||||
INSERT INTO node_history(
|
||||
user_id, "uuid", parents, created, updated,
|
||||
"name", "content", markdown, "content_encrypted",
|
||||
client, client_sequence
|
||||
)
|
||||
VALUES(
|
||||
p_user_id,
|
||||
node_uuid,
|
||||
(jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors",
|
||||
(node_data->>'Created')::timestamptz,
|
||||
(node_data->>'Updated')::timestamptz,
|
||||
(node_data->>'Name')::varchar,
|
||||
(node_data->>'Content')::text,
|
||||
(node_data->>'Markdown')::bool,
|
||||
'', /* content_encrypted */
|
||||
p_client_uuid,
|
||||
(node_data->>'ClientSequence')::int
|
||||
)
|
||||
ON CONFLICT (client, client_sequence)
|
||||
DO NOTHING;
|
||||
ELSE
|
||||
RAISE NOTICE '03 Client newer, % > % (%, %)', node_updated, db_updated, node_uuid, (node_data->>'ClientSequence');
|
||||
/* Incoming node is newer and will replace the current node.
|
||||
*
|
||||
* The current node is copied to the node_history table and then modified in place
|
||||
* with the incoming data. */
|
||||
INSERT INTO node_history(
|
||||
user_id, "uuid", parents,
|
||||
created, updated, "name", "content", markdown, "content_encrypted",
|
||||
client, client_sequence
|
||||
)
|
||||
SELECT
|
||||
user_id,
|
||||
"uuid",
|
||||
(
|
||||
WITH RECURSIVE nodes AS (
|
||||
SELECT
|
||||
uuid,
|
||||
COALESCE(parent_uuid, '') AS parent_uuid,
|
||||
name,
|
||||
0 AS depth
|
||||
FROM node
|
||||
WHERE
|
||||
uuid = node_uuid
|
||||
|
||||
UNION
|
||||
|
||||
SELECT
|
||||
n.uuid,
|
||||
COALESCE(n.parent_uuid, '') AS parent_uuid,
|
||||
n.name,
|
||||
nr.depth+1 AS depth
|
||||
FROM node n
|
||||
INNER JOIN nodes nr ON n.uuid = nr.parent_uuid
|
||||
)
|
||||
SELECT ARRAY (
|
||||
SELECT name
|
||||
FROM nodes
|
||||
ORDER BY depth DESC
|
||||
OFFSET 1 /* discard itself */
|
||||
)
|
||||
),
|
||||
created,
|
||||
updated,
|
||||
name,
|
||||
content,
|
||||
markdown,
|
||||
content_encrypted,
|
||||
client,
|
||||
client_sequence
|
||||
FROM public."node"
|
||||
WHERE
|
||||
user_id = p_user_id AND
|
||||
uuid = node_uuid
|
||||
ON CONFLICT (client, client_sequence)
|
||||
DO NOTHING;
|
||||
|
||||
/* Current node in database is updated with incoming data. */
|
||||
UPDATE public."node"
|
||||
SET
|
||||
updated = (node_data->>'Updated')::timestamptz,
|
||||
updated_seq = nextval('node_updates'),
|
||||
name = (node_data->>'Name')::varchar,
|
||||
content = (node_data->>'Content')::text,
|
||||
markdown = (node_data->>'Markdown')::bool,
|
||||
client = p_client_uuid,
|
||||
client_sequence = (node_data->>'ClientSequence')::int
|
||||
WHERE
|
||||
user_id = p_user_id AND
|
||||
uuid = node_uuid;
|
||||
END IF;
|
||||
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
166
sql/00012.sql
|
|
@ -1,166 +0,0 @@
|
|||
CREATE OR REPLACE PROCEDURE add_nodes(p_user_id int4, p_client_uuid varchar, p_nodes jsonb)
|
||||
LANGUAGE PLPGSQL AS $$
|
||||
|
||||
DECLARE
|
||||
node_data jsonb;
|
||||
node_updated timestamptz;
|
||||
db_updated timestamptz;
|
||||
db_uuid bpchar;
|
||||
db_client bpchar;
|
||||
db_client_seq int;
|
||||
node_uuid bpchar;
|
||||
parent_uuid_nullable bpchar;
|
||||
|
||||
BEGIN
|
||||
RAISE NOTICE '--------------------------';
|
||||
FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes)
|
||||
LOOP
|
||||
node_uuid = (node_data->>'UUID')::bpchar;
|
||||
node_updated = (node_data->>'Updated')::timestamptz;
|
||||
|
||||
IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' THEN
|
||||
parent_uuid_nullable = NULL;
|
||||
ELSE
|
||||
parent_uuid_nullable = node_data->>'ParentUUID';
|
||||
END IF;
|
||||
|
||||
/* Retrieve the current modified timestamp for this node from the database. */
|
||||
SELECT
|
||||
uuid, updated, client, client_sequence
|
||||
INTO
|
||||
db_uuid, db_updated, db_client, db_client_seq
|
||||
FROM public."node"
|
||||
WHERE
|
||||
user_id = p_user_id AND
|
||||
uuid = node_uuid;
|
||||
|
||||
/* Is the node not in database? It needs to be created. */
|
||||
IF db_uuid IS NULL THEN
|
||||
RAISE NOTICE '01 New node %', node_uuid;
|
||||
INSERT INTO public."node" (
|
||||
user_id, "uuid", parent_uuid, created, updated,
|
||||
"name", "content", markdown, "content_encrypted",
|
||||
client, client_sequence
|
||||
)
|
||||
VALUES(
|
||||
p_user_id,
|
||||
node_uuid,
|
||||
parent_uuid_nullable,
|
||||
(node_data->>'Created')::timestamptz,
|
||||
(node_data->>'Updated')::timestamptz,
|
||||
(node_data->>'Name')::varchar,
|
||||
(node_data->>'Content')::text,
|
||||
(node_data->>'Markdown')::bool,
|
||||
'', /* content_encrypted */
|
||||
p_client_uuid,
|
||||
(node_data->>'ClientSequence')::int
|
||||
);
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
|
||||
/* The client could send a specific node again if it didn't receive the OK from this procedure before. */
|
||||
IF db_updated = node_updated AND db_client = p_client_uuid AND db_client_seq = (node_data->>'ClientSequence')::int THEN
|
||||
RAISE NOTICE '04, already recorded, %, %', db_client, db_client_seq;
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
/* Determine if the incoming node data is to go into history or replace the current node. */
|
||||
IF db_updated > node_updated THEN
|
||||
RAISE NOTICE '02 DB newer, % > % (%))', db_updated, node_updated, node_uuid;
|
||||
/* Incoming node is going straight to history since it is older than the current node. */
|
||||
INSERT INTO node_history(
|
||||
user_id, "uuid", parents, created, updated,
|
||||
"name", "content", markdown, "content_encrypted",
|
||||
client, client_sequence
|
||||
)
|
||||
VALUES(
|
||||
p_user_id,
|
||||
node_uuid,
|
||||
(jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors",
|
||||
(node_data->>'Created')::timestamptz,
|
||||
(node_data->>'Updated')::timestamptz,
|
||||
(node_data->>'Name')::varchar,
|
||||
(node_data->>'Content')::text,
|
||||
(node_data->>'Markdown')::bool,
|
||||
'', /* content_encrypted */
|
||||
p_client_uuid,
|
||||
(node_data->>'ClientSequence')::int
|
||||
)
|
||||
ON CONFLICT (client, client_sequence)
|
||||
DO NOTHING;
|
||||
ELSE
|
||||
RAISE NOTICE '03 Client newer, % > % (%, %)', node_updated, db_updated, node_uuid, (node_data->>'ClientSequence');
|
||||
/* Incoming node is newer and will replace the current node.
|
||||
*
|
||||
* The current node is copied to the node_history table and then modified in place
|
||||
* with the incoming data. */
|
||||
INSERT INTO node_history(
|
||||
user_id, "uuid", parents,
|
||||
created, updated, "name", "content", markdown, "content_encrypted",
|
||||
client, client_sequence
|
||||
)
|
||||
SELECT
|
||||
user_id,
|
||||
"uuid",
|
||||
(
|
||||
WITH RECURSIVE nodes AS (
|
||||
SELECT
|
||||
uuid,
|
||||
COALESCE(parent_uuid, '') AS parent_uuid,
|
||||
name,
|
||||
0 AS depth
|
||||
FROM node
|
||||
WHERE
|
||||
uuid = node_uuid
|
||||
|
||||
UNION
|
||||
|
||||
SELECT
|
||||
n.uuid,
|
||||
COALESCE(n.parent_uuid, '') AS parent_uuid,
|
||||
n.name,
|
||||
nr.depth+1 AS depth
|
||||
FROM node n
|
||||
INNER JOIN nodes nr ON n.uuid = nr.parent_uuid
|
||||
)
|
||||
SELECT ARRAY (
|
||||
SELECT name
|
||||
FROM nodes
|
||||
ORDER BY depth DESC
|
||||
OFFSET 1 /* discard itself */
|
||||
)
|
||||
),
|
||||
created,
|
||||
updated,
|
||||
name,
|
||||
content,
|
||||
markdown,
|
||||
content_encrypted,
|
||||
client,
|
||||
client_sequence
|
||||
FROM public."node"
|
||||
WHERE
|
||||
user_id = p_user_id AND
|
||||
uuid = node_uuid
|
||||
ON CONFLICT (client, client_sequence)
|
||||
DO NOTHING;
|
||||
|
||||
/* Current node in database is updated with incoming data. */
|
||||
UPDATE public."node"
|
||||
SET
|
||||
updated = (node_data->>'Updated')::timestamptz,
|
||||
updated_seq = nextval('node_updates'),
|
||||
name = (node_data->>'Name')::varchar,
|
||||
content = (node_data->>'Content')::text,
|
||||
markdown = (node_data->>'Markdown')::bool,
|
||||
client = p_client_uuid,
|
||||
client_sequence = (node_data->>'ClientSequence')::int
|
||||
WHERE
|
||||
user_id = p_user_id AND
|
||||
uuid = node_uuid;
|
||||
END IF;
|
||||
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
|
@ -52,51 +52,51 @@ html {
|
|||
#tree {
|
||||
grid-area: tree;
|
||||
display: grid;
|
||||
background-color: #fafafa;
|
||||
background-color: #ffffff;
|
||||
color: #444;
|
||||
z-index: 100;
|
||||
|
||||
border-right: 1px solid #ddd;
|
||||
|
||||
n2-tree {
|
||||
/*border: 2px solid #f8f8f8;*/
|
||||
padding: 16px 48px 16px 24px;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
n2-tree {
|
||||
/*
|
||||
border: 2px solid #fe5f55;
|
||||
*/
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
border-right: 2px solid #ddd;
|
||||
|
||||
#logo {
|
||||
display: grid;
|
||||
position: relative;
|
||||
justify-items: center;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
margin-left: 24px;
|
||||
margin-right: 24px;
|
||||
grid-template-columns: min-content 1fr min-content;
|
||||
align-items: center;
|
||||
justify-items: start;
|
||||
cursor: pointer;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #ccc;
|
||||
|
||||
img {
|
||||
width: 128px;
|
||||
left: -20px;
|
||||
.el-search {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
img:first-child {
|
||||
height: 24px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.icons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 32px;
|
||||
margin: 16px 0px 32px 0px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
n2-tree {
|
||||
.el-treenodes {
|
||||
margin: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
n2-tree {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
.node {
|
||||
display: grid;
|
||||
grid-template-columns: 40px min-content;
|
||||
|
|
@ -145,16 +145,6 @@ html {
|
|||
}
|
||||
}
|
||||
|
||||
#tree-nodes {
|
||||
padding: 16px 32px;
|
||||
/*
|
||||
border-radius: 8px;
|
||||
*/
|
||||
/*
|
||||
box-shadow: 5px 5px 10px -5px rgba(0, 0, 0, 0.75);
|
||||
*/
|
||||
}
|
||||
|
||||
#crumbs {
|
||||
grid-area: crumbs;
|
||||
display: grid;
|
||||
|
|
@ -316,7 +306,6 @@ n2-nodeui {
|
|||
margin-bottom: 32px;
|
||||
|
||||
&:invalid {
|
||||
background: #f5f5f5;
|
||||
padding-top: 16px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
static/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -8,7 +8,7 @@
|
|||
version="1.1"
|
||||
id="svg1"
|
||||
sodipodi:docname="collapsed.svg"
|
||||
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)"
|
||||
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||
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"
|
||||
|
|
@ -23,13 +23,13 @@
|
|||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="px"
|
||||
inkscape:zoom="4.8373092"
|
||||
inkscape:cx="6.201795"
|
||||
inkscape:cy="-12.40359"
|
||||
inkscape:window-width="1916"
|
||||
inkscape:window-height="1161"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="18"
|
||||
inkscape:zoom="19.349237"
|
||||
inkscape:cx="11.809251"
|
||||
inkscape:cy="6.3051583"
|
||||
inkscape:window-width="1093"
|
||||
inkscape:window-height="1401"
|
||||
inkscape:window-x="2560"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1"
|
||||
showguides="false" />
|
||||
|
|
@ -42,9 +42,13 @@
|
|||
transform="translate(-102.39375,-146.31458)">
|
||||
<title
|
||||
id="title1">folder-outline</title>
|
||||
<path
|
||||
style="opacity:1;fill:#ffffff;stroke-width:0.264583"
|
||||
d="m 102.7356,147.34014 h 5.83884 v 3.91079 h -5.86619 z"
|
||||
id="path2" />
|
||||
<path
|
||||
d="m 108.34687,150.94479 h -5.29166 v -3.30729 h 5.29166 m 0,-0.66146 h -2.64584 l -0.66145,-0.66146 h -1.98437 c -0.36711,0 -0.66146,0.29435 -0.66146,0.66146 v 3.96875 a 0.66145729,0.66145729 0 0 0 0.66146,0.66146 h 5.29166 a 0.66145729,0.66145729 0 0 0 0.66146,-0.66146 v -3.30729 c 0,-0.36711 -0.29767,-0.66146 -0.66146,-0.66146 z"
|
||||
id="path1"
|
||||
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:#71c837;fill-opacity:1;stroke-width:0.330728;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none;stop-color:#000000;stop-opacity:1" />
|
||||
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:#ffcc00;fill-opacity:1;stroke-width:0.330728;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none;stop-color:#000000;stop-opacity:1" />
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 2.1 KiB |
|
|
@ -8,7 +8,7 @@
|
|||
version="1.1"
|
||||
id="svg1"
|
||||
sodipodi:docname="expanded.svg"
|
||||
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)"
|
||||
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||
xml:space="preserve"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
|
|
@ -23,13 +23,13 @@
|
|||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="px"
|
||||
inkscape:zoom="11.17754"
|
||||
inkscape:cx="20.845374"
|
||||
inkscape:cy="26.929003"
|
||||
inkscape:window-width="1916"
|
||||
inkscape:window-height="1161"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="18"
|
||||
inkscape:zoom="15.807429"
|
||||
inkscape:cx="10.533022"
|
||||
inkscape:cy="16.384701"
|
||||
inkscape:window-width="1093"
|
||||
inkscape:window-height="1401"
|
||||
inkscape:window-x="1463"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" /><defs
|
||||
id="defs1" /><g
|
||||
|
|
@ -39,6 +39,9 @@
|
|||
transform="translate(-101.33542,-147.10833)"><title
|
||||
id="title1">folder-open</title><title
|
||||
id="title1-1">folder-open-outline</title><path
|
||||
style="opacity:1;fill:#ffffff;stroke-width:0.264583;fill-opacity:1"
|
||||
d="m 101.61996,148.02892 5.99218,0.36823 v 1.12144 l 0.16738,0.33476 -0.703,2.32657 -5.22222,-0.11717 z"
|
||||
id="path2" /><path
|
||||
d="m 102.69141,149.0927 -0.69454,2.64584 v -3.30729 h 5.62239 a 0.6614573,0.6614573 0 0 0 -0.66146,-0.66146 h -2.3151 l -0.66146,-0.66146 h -1.98437 a 0.6614573,0.6614573 0 0 0 -0.66145,0.66146 v 3.96875 a 0.6614573,0.6614573 0 0 0 0.66145,0.66146 h 4.96093 c 0.29766,0 0.56224,-0.19844 0.62839,-0.4961 l 0.76067,-2.8112 h -5.65545 m 4.26639,2.64584 h -4.29947 l 0.52916,-1.98438 h 4.29948 z"
|
||||
id="path1"
|
||||
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:#71c837;fill-opacity:1;stroke-width:0.264583;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none;stop-color:#000000;stop-opacity:1" /></g></svg>
|
||||
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:#ffcc00;fill-opacity:1;stroke-width:0.264583;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none;stop-color:#000000;stop-opacity:1" /></g></svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.3 KiB |
49
static/images/icon_settings.svg
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="23.350698"
|
||||
height="23.999699"
|
||||
viewBox="0 0 6.1782055 6.3499202"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||
sodipodi:docname="icon_settings.svg"
|
||||
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">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="px"
|
||||
inkscape:zoom="1"
|
||||
inkscape:cx="-144.5"
|
||||
inkscape:cy="-132"
|
||||
inkscape:window-width="2190"
|
||||
inkscape:window-height="1401"
|
||||
inkscape:window-x="1463"
|
||||
inkscape:window-y="18"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-146.12156,-110.33135)">
|
||||
<title
|
||||
id="title1">cog-outline</title>
|
||||
<path
|
||||
d="m 149.21069,112.23625 a 1.2700039,1.2700039 0 0 1 1.27001,1.27 1.2700039,1.2700039 0 0 1 -1.27001,1.27001 1.2700039,1.2700039 0 0 1 -1.27,-1.27001 1.2700039,1.2700039 0 0 1 1.27,-1.27 m 0,0.63501 a 0.63500199,0.63500199 0 0 0 -0.635,0.63499 0.63500199,0.63500199 0 0 0 0.635,0.63501 0.63500199,0.63500199 0 0 0 0.635,-0.63501 0.63500199,0.63500199 0 0 0 -0.635,-0.63499 m -0.635,3.81001 c -0.0794,0 -0.14605,-0.0571 -0.15875,-0.13336 l -0.11748,-0.84137 c -0.20002,-0.0793 -0.37147,-0.18733 -0.53658,-0.31433 l -0.79057,0.32068 c -0.0698,0.0254 -0.15557,0 -0.19367,-0.0698 l -0.63501,-1.09855 c -0.0413,-0.0699 -0.0222,-0.15557 0.038,-0.2032 l 0.66993,-0.52706 -0.0222,-0.30798 0.0222,-0.31749 -0.66993,-0.51753 c -0.0604,-0.0476 -0.0793,-0.13336 -0.038,-0.2032 l 0.63501,-1.09855 c 0.0382,-0.0698 0.12382,-0.0984 0.19367,-0.0698 l 0.79057,0.3175 c 0.16511,-0.12382 0.33656,-0.23177 0.53658,-0.31115 l 0.11748,-0.84137 c 0.0127,-0.0762 0.0793,-0.13336 0.15875,-0.13336 h 1.27 c 0.0793,0 0.14606,0.0571 0.15875,0.13336 l 0.11748,0.84137 c 0.20003,0.0794 0.37148,0.18733 0.53657,0.31115 l 0.79059,-0.3175 c 0.0698,-0.0286 0.15557,0 0.19367,0.0698 l 0.635,1.09855 c 0.0413,0.0698 0.0222,0.15557 -0.0382,0.2032 l -0.66992,0.51753 0.0222,0.31749 -0.0222,0.31751 0.66992,0.51753 c 0.0604,0.0476 0.0793,0.13334 0.0382,0.2032 l -0.635,1.09855 c -0.0382,0.0698 -0.12383,0.0984 -0.19367,0.0698 l -0.79059,-0.31751 c -0.16509,0.12383 -0.33654,0.23178 -0.53657,0.31116 l -0.11748,0.84137 c -0.0127,0.0762 -0.0793,0.13336 -0.15875,0.13336 h -1.27 m 0.39688,-5.71502 -0.11748,0.82868 c -0.381,0.0794 -0.71755,0.28257 -0.96203,0.56515 l -0.76517,-0.3302 -0.23813,0.41275 0.66993,0.49212 c -0.127,0.37149 -0.127,0.77471 0,1.143 l -0.67311,0.49531 0.23813,0.41275 0.77153,-0.33019 c 0.24448,0.27939 0.57785,0.48259 0.95567,0.55879 l 0.11748,0.83185 h 0.48261 l 0.11748,-0.82867 c 0.37783,-0.0793 0.7112,-0.28258 0.95568,-0.56197 l 0.77153,0.33019 0.23812,-0.41275 -0.6731,-0.49213 c 0.127,-0.37147 0.127,-0.77469 0,-1.14618 l 0.66993,-0.49212 -0.23813,-0.41275 -0.76518,0.3302 c -0.24447,-0.28258 -0.58102,-0.48577 -0.96202,-0.56197 l -0.11748,-0.83186 z"
|
||||
id="path1"
|
||||
style="stroke-width:0.317501;fill:#000000;fill-opacity:1" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 6 KiB |
63
static/images/logo_small.svg
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="28.593159"
|
||||
height="20"
|
||||
viewBox="0 0 7.5652731 5.2916666"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||
sodipodi:docname="logo_small.svg"
|
||||
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">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="px"
|
||||
inkscape:zoom="16.477217"
|
||||
inkscape:cx="48.460855"
|
||||
inkscape:cy="5.2193281"
|
||||
inkscape:window-width="2190"
|
||||
inkscape:window-height="1401"
|
||||
inkscape:window-x="1463"
|
||||
inkscape:window-y="18"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-126.73541,-178.59375)">
|
||||
<rect
|
||||
style="fill:#a02c2c;stroke:none;stroke-width:0.113818"
|
||||
id="rect5"
|
||||
width="7.5652733"
|
||||
height="5.2916665"
|
||||
x="126.73541"
|
||||
y="178.59375"
|
||||
ry="1.0060203" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:4.82235px;font-family:'Forgotten Futurist';-inkscape-font-specification:'Forgotten Futurist, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;writing-mode:lr-tb;direction:ltr;fill:#ffffff;stroke:none;stroke-width:0.40186"
|
||||
x="112.25369"
|
||||
y="207.99469"
|
||||
id="text5"
|
||||
transform="scale(1.1392149,0.87779751)"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan5"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:4.82235px;font-family:'Forgotten Futurist';-inkscape-font-specification:'Forgotten Futurist, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ffffff;stroke:none;stroke-width:0.40186"
|
||||
x="112.25369"
|
||||
y="207.99469">N2</tspan></text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
|
@ -184,7 +184,7 @@ export class App {
|
|||
}
|
||||
|
||||
if (!dontPush)
|
||||
history.pushState({ nodeUUID }, '', `/notes2#${nodeUUID}`)
|
||||
history.pushState({ nodeUUID }, '', `/#${nodeUUID}`)
|
||||
|
||||
const node = nodeStore.node(nodeUUID)
|
||||
node.reset() // any modifications are discarded.
|
||||
|
|
@ -249,7 +249,7 @@ class N2Crumb extends CustomHTMLElement {
|
|||
this.label = label
|
||||
this.uuid = uuid
|
||||
|
||||
this.elLink.href = `/notes2#${this.uuid}`
|
||||
this.elLink.href = `/#${this.uuid}`
|
||||
this.elLink.innerText = this.label
|
||||
this.elLink.addEventListener('click', () => _mbus.dispatch("GO_TO_NODE", { nodeUUID: this.uuid, dontPush: false, dontExpand: true }))
|
||||
}// }}}
|
||||
|
|
|
|||
207
static/js/lib/css_colorize.mjs
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
export class Color {
|
||||
constructor(r, g, b) { this.set(r, g, b); }
|
||||
toString() { return `rgb(${Math.round(this.r)}, ${Math.round(this.g)}, ${Math.round(this.b)})`; }
|
||||
|
||||
set(r, g, b) {
|
||||
this.r = this.clamp(r);
|
||||
this.g = this.clamp(g);
|
||||
this.b = this.clamp(b);
|
||||
}
|
||||
|
||||
hueRotate(angle = 0) {
|
||||
angle = angle / 180 * Math.PI;
|
||||
let sin = Math.sin(angle);
|
||||
let cos = Math.cos(angle);
|
||||
|
||||
this.multiply([
|
||||
0.213 + cos * 0.787 - sin * 0.213, 0.715 - cos * 0.715 - sin * 0.715, 0.072 - cos * 0.072 + sin * 0.928,
|
||||
0.213 - cos * 0.213 + sin * 0.143, 0.715 + cos * 0.285 + sin * 0.140, 0.072 - cos * 0.072 - sin * 0.283,
|
||||
0.213 - cos * 0.213 - sin * 0.787, 0.715 - cos * 0.715 + sin * 0.715, 0.072 + cos * 0.928 + sin * 0.072
|
||||
]);
|
||||
}
|
||||
|
||||
grayscale(value = 1) {
|
||||
this.multiply([
|
||||
0.2126 + 0.7874 * (1 - value), 0.7152 - 0.7152 * (1 - value), 0.0722 - 0.0722 * (1 - value),
|
||||
0.2126 - 0.2126 * (1 - value), 0.7152 + 0.2848 * (1 - value), 0.0722 - 0.0722 * (1 - value),
|
||||
0.2126 - 0.2126 * (1 - value), 0.7152 - 0.7152 * (1 - value), 0.0722 + 0.9278 * (1 - value)
|
||||
]);
|
||||
}
|
||||
|
||||
sepia(value = 1) {
|
||||
this.multiply([
|
||||
0.393 + 0.607 * (1 - value), 0.769 - 0.769 * (1 - value), 0.189 - 0.189 * (1 - value),
|
||||
0.349 - 0.349 * (1 - value), 0.686 + 0.314 * (1 - value), 0.168 - 0.168 * (1 - value),
|
||||
0.272 - 0.272 * (1 - value), 0.534 - 0.534 * (1 - value), 0.131 + 0.869 * (1 - value)
|
||||
]);
|
||||
}
|
||||
|
||||
saturate(value = 1) {
|
||||
this.multiply([
|
||||
0.213 + 0.787 * value, 0.715 - 0.715 * value, 0.072 - 0.072 * value,
|
||||
0.213 - 0.213 * value, 0.715 + 0.285 * value, 0.072 - 0.072 * value,
|
||||
0.213 - 0.213 * value, 0.715 - 0.715 * value, 0.072 + 0.928 * value
|
||||
]);
|
||||
}
|
||||
|
||||
multiply(matrix) {
|
||||
let newR = this.clamp(this.r * matrix[0] + this.g * matrix[1] + this.b * matrix[2]);
|
||||
let newG = this.clamp(this.r * matrix[3] + this.g * matrix[4] + this.b * matrix[5]);
|
||||
let newB = this.clamp(this.r * matrix[6] + this.g * matrix[7] + this.b * matrix[8]);
|
||||
this.r = newR; this.g = newG; this.b = newB;
|
||||
}
|
||||
|
||||
brightness(value = 1) { this.linear(value); }
|
||||
contrast(value = 1) { this.linear(value, -(0.5 * value) + 0.5); }
|
||||
|
||||
linear(slope = 1, intercept = 0) {
|
||||
this.r = this.clamp(this.r * slope + intercept * 255);
|
||||
this.g = this.clamp(this.g * slope + intercept * 255);
|
||||
this.b = this.clamp(this.b * slope + intercept * 255);
|
||||
}
|
||||
|
||||
invert(value = 1) {
|
||||
this.r = this.clamp((value + (this.r / 255) * (1 - 2 * value)) * 255);
|
||||
this.g = this.clamp((value + (this.g / 255) * (1 - 2 * value)) * 255);
|
||||
this.b = this.clamp((value + (this.b / 255) * (1 - 2 * value)) * 255);
|
||||
}
|
||||
|
||||
hsl() { // Code taken from https://stackoverflow.com/a/9493060/2688027, licensed under CC BY-SA.
|
||||
let r = this.r / 255;
|
||||
let g = this.g / 255;
|
||||
let b = this.b / 255;
|
||||
let max = Math.max(r, g, b);
|
||||
let min = Math.min(r, g, b);
|
||||
let h, s, l = (max + min) / 2;
|
||||
|
||||
if (max === min) {
|
||||
h = s = 0;
|
||||
} else {
|
||||
let d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
|
||||
case g: h = (b - r) / d + 2; break;
|
||||
case b: h = (r - g) / d + 4; break;
|
||||
} h /= 6;
|
||||
}
|
||||
|
||||
return {
|
||||
h: h * 100,
|
||||
s: s * 100,
|
||||
l: l * 100
|
||||
};
|
||||
}
|
||||
|
||||
clamp(value) {
|
||||
if (value > 255) { value = 255; }
|
||||
else if (value < 0) { value = 0; }
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export class Solver {
|
||||
constructor(target) {
|
||||
this.target = target;
|
||||
this.targetHSL = target.hsl();
|
||||
this.reusedColor = new Color(0, 0, 0); // Object pool
|
||||
}
|
||||
|
||||
solve() {
|
||||
let result = this.solveNarrow(this.solveWide());
|
||||
return {
|
||||
values: result.values,
|
||||
loss: result.loss,
|
||||
filter: this.css(result.values)
|
||||
};
|
||||
}
|
||||
|
||||
solveWide() {
|
||||
const A = 5;
|
||||
const c = 15;
|
||||
const a = [60, 180, 18000, 600, 1.2, 1.2];
|
||||
|
||||
let best = { loss: Infinity };
|
||||
for (let i = 0; best.loss > 25 && i < 3; i++) {
|
||||
let initial = [50, 20, 3750, 50, 100, 100];
|
||||
let result = this.spsa(A, a, c, initial, 1000);
|
||||
if (result.loss < best.loss) { best = result; }
|
||||
} return best;
|
||||
}
|
||||
|
||||
solveNarrow(wide) {
|
||||
const A = wide.loss;
|
||||
const c = 2;
|
||||
const A1 = A + 1;
|
||||
const a = [0.25 * A1, 0.25 * A1, A1, 0.25 * A1, 0.2 * A1, 0.2 * A1];
|
||||
return this.spsa(A, a, c, wide.values, 500);
|
||||
}
|
||||
|
||||
spsa(A, a, c, values, iters) {
|
||||
const alpha = 1;
|
||||
const gamma = 0.16666666666666666;
|
||||
|
||||
let best = null;
|
||||
let bestLoss = Infinity;
|
||||
let deltas = new Array(6);
|
||||
let highArgs = new Array(6);
|
||||
let lowArgs = new Array(6);
|
||||
|
||||
for (let k = 0; k < iters; k++) {
|
||||
let ck = c / Math.pow(k + 1, gamma);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
deltas[i] = Math.random() > 0.5 ? 1 : -1;
|
||||
highArgs[i] = values[i] + ck * deltas[i];
|
||||
lowArgs[i] = values[i] - ck * deltas[i];
|
||||
}
|
||||
|
||||
let lossDiff = this.loss(highArgs) - this.loss(lowArgs);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
let g = lossDiff / (2 * ck) * deltas[i];
|
||||
let ak = a[i] / Math.pow(A + k + 1, alpha);
|
||||
values[i] = fix(values[i] - ak * g, i);
|
||||
}
|
||||
|
||||
let loss = this.loss(values);
|
||||
if (loss < bestLoss) { best = values.slice(0); bestLoss = loss; }
|
||||
} return { values: best, loss: bestLoss };
|
||||
|
||||
function fix(value, idx) {
|
||||
let max = 100;
|
||||
if (idx === 2 /* saturate */) { max = 7500; }
|
||||
else if (idx === 4 /* brightness */ || idx === 5 /* contrast */) { max = 200; }
|
||||
|
||||
if (idx === 3 /* hue-rotate */) {
|
||||
if (value > max) { value = value % max; }
|
||||
else if (value < 0) { value = max + value % max; }
|
||||
} else if (value < 0) { value = 0; }
|
||||
else if (value > max) { value = max; }
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
loss(filters) { // Argument is array of percentages.
|
||||
let color = this.reusedColor;
|
||||
color.set(0, 0, 0);
|
||||
|
||||
color.invert(filters[0] / 100);
|
||||
color.sepia(filters[1] / 100);
|
||||
color.saturate(filters[2] / 100);
|
||||
color.hueRotate(filters[3] * 3.6);
|
||||
color.brightness(filters[4] / 100);
|
||||
color.contrast(filters[5] / 100);
|
||||
|
||||
let colorHSL = color.hsl();
|
||||
return Math.abs(color.r - this.target.r)
|
||||
+ Math.abs(color.g - this.target.g)
|
||||
+ Math.abs(color.b - this.target.b)
|
||||
+ Math.abs(colorHSL.h - this.targetHSL.h)
|
||||
+ Math.abs(colorHSL.s - this.targetHSL.s)
|
||||
+ Math.abs(colorHSL.l - this.targetHSL.l);
|
||||
}
|
||||
|
||||
css(filters) {
|
||||
function fmt(idx, multiplier = 1) { return Math.round(filters[idx] * multiplier); }
|
||||
return `invert(${fmt(0)}%) sepia(${fmt(1)}%) saturate(${fmt(2)}%) hue-rotate(${fmt(3, 3.6)}deg) brightness(${fmt(4)}%) contrast(${fmt(5)}%)`;
|
||||
}
|
||||
}
|
||||
6
static/js/lib/fullcalendar.min.js
vendored
|
|
@ -110,12 +110,12 @@ export class MarkedPosition {
|
|||
renderer: {
|
||||
heading(token) {
|
||||
const content = this.parser.parseInline(token.tokens)
|
||||
return `<h${token.depth} onclick="setpos(event)" onclick="setpos(this)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${content}</h${token.depth}>\n`
|
||||
return `<h${token.depth} ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${content}</h${token.depth}>\n`
|
||||
},
|
||||
|
||||
paragraph(token) {
|
||||
const content = this.parser.parseInline(token.tokens)
|
||||
return `<p onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${content}</p>\n`
|
||||
return `<p ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${content}</p>\n`
|
||||
},
|
||||
|
||||
list(token) {
|
||||
|
|
@ -134,7 +134,7 @@ export class MarkedPosition {
|
|||
},
|
||||
|
||||
listitem(token) {
|
||||
return `<li onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${this.parser.parse(token.tokens)}</li>\n`
|
||||
return `<li ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${this.parser.parse(token.tokens)}</li>\n`
|
||||
},
|
||||
|
||||
code(token) {
|
||||
|
|
@ -143,12 +143,12 @@ export class MarkedPosition {
|
|||
const code = token.text.replace(other.endingNewline, '') + '\n'
|
||||
|
||||
if (!langString) {
|
||||
return `<pre onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"><code>`
|
||||
return `<pre ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"><code>`
|
||||
+ (token.escaped ? code : escapeHtmlEntities(code, true))
|
||||
+ '</code></pre>\n'
|
||||
}
|
||||
|
||||
return `<pre onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"><code class="language-`
|
||||
return `<pre ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"><code class="language-`
|
||||
+ escapeHtmlEntities(langString)
|
||||
+ '">'
|
||||
+ (token.escaped ? code : escapeHtmlEntities(code, true))
|
||||
|
|
@ -157,7 +157,7 @@ export class MarkedPosition {
|
|||
|
||||
blockquote(token) {
|
||||
const body = this.parser.parse(token.tokens)
|
||||
return `<blockquote onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">\n${body}</blockquote>\n`
|
||||
return `<blockquote ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">\n${body}</blockquote>\n`
|
||||
},
|
||||
|
||||
html(token) {
|
||||
|
|
@ -169,11 +169,11 @@ export class MarkedPosition {
|
|||
},
|
||||
|
||||
hr(token) {
|
||||
return `<hr onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">\n`
|
||||
return `<hr ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">\n`
|
||||
},
|
||||
|
||||
checkbox(token) {
|
||||
return `<input onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"`
|
||||
return `<input ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"`
|
||||
+ (token.checked ? 'checked="" ' : '')
|
||||
+ 'disabled="" type="checkbox"> '
|
||||
},
|
||||
|
|
@ -218,7 +218,7 @@ export class MarkedPosition {
|
|||
if (token.tokens.length > 0) {
|
||||
const start = token.tokens[0].position.start.offset
|
||||
const end = token.tokens[0].position.end.offset
|
||||
ofs = `onclick="setpos(event)" data-offset-start="${start}" data-offset-end="${end}"`
|
||||
ofs = `ondblclick="setpos(event)" data-offset-start="${start}" data-offset-end="${end}"`
|
||||
}
|
||||
|
||||
const content = this.parser.parseInline(token.tokens);
|
||||
|
|
@ -230,23 +230,23 @@ export class MarkedPosition {
|
|||
},
|
||||
|
||||
strong(token) {
|
||||
return `<strong onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${this.parser.parseInline(token.tokens)}</strong>`
|
||||
return `<strong ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${this.parser.parseInline(token.tokens)}</strong>`
|
||||
},
|
||||
|
||||
em(token) {
|
||||
return `<em onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${this.parser.parseInline(token.tokens)}</em>`
|
||||
return `<em ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${this.parser.parseInline(token.tokens)}</em>`
|
||||
},
|
||||
|
||||
codespan(token) {
|
||||
return `<code onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${escapeHtmlEntities(token.text, true)}</code>`
|
||||
return `<code ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${escapeHtmlEntities(token.text, true)}</code>`
|
||||
},
|
||||
|
||||
br(token) {
|
||||
return `<br onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">`
|
||||
return `<br ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">`
|
||||
},
|
||||
|
||||
del(token) {
|
||||
return `<del onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${this.parser.parseInline(token.tokens)}</del>`
|
||||
return `<del ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${this.parser.parseInline(token.tokens)}</del>`
|
||||
},
|
||||
|
||||
link(token) {
|
||||
|
|
@ -256,7 +256,7 @@ export class MarkedPosition {
|
|||
return text
|
||||
}
|
||||
token.href = cleanHref
|
||||
let out = '<a onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}" href="' + token.href + '"'
|
||||
let out = '<a ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}" href="' + token.href + '"'
|
||||
if (token.title) {
|
||||
out += ' title="' + (escapeHtmlEntities(token.title)) + '"'
|
||||
}
|
||||
|
|
@ -275,7 +275,7 @@ export class MarkedPosition {
|
|||
}
|
||||
token.href = cleanHref
|
||||
|
||||
let out = `<img onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}" src="${token.href}" alt="${escapeHtmlEntities(token.text)}"`
|
||||
let out = `<img ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}" src="${token.href}" alt="${escapeHtmlEntities(token.text)}"`
|
||||
if (token.title) {
|
||||
out += ` title="${escapeHtmlEntities(token.title)}"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export class N2NodeUI extends CustomHTMLElement {
|
|||
_mbus.subscribe('NODE_MODIFIED', () => {
|
||||
document.querySelector('#crumbs .crumbs')?.classList.add('node-modified')
|
||||
this.elIconSave.src = `/images/${_VERSION}/icon_save.svg`
|
||||
this.render()
|
||||
this.renderName()
|
||||
})
|
||||
|
||||
_mbus.subscribe('NODE_UNMODIFIED', () => {
|
||||
|
|
@ -76,6 +76,9 @@ export class N2NodeUI extends CustomHTMLElement {
|
|||
|
||||
this.showMarkdown(true)
|
||||
}// }}}
|
||||
renderName() {// {{{
|
||||
this.elName.innerText = this.node?.get('Name') ?? ''
|
||||
}// }}}
|
||||
render() {// {{{
|
||||
this.elName.innerText = this.node?.get('Name') ?? ''
|
||||
this.elNodeContent.value = this.node?.get('Content') ?? ''
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export class Notes2 extends Component {
|
|||
}
|
||||
|
||||
if (!dontPush)
|
||||
history.pushState({ nodeUUID }, '', `/notes2#${nodeUUID}`)
|
||||
history.pushState({ nodeUUID }, '', `/#${nodeUUID}`)
|
||||
|
||||
// New node is fetched in order to retrieve content and files.
|
||||
// Such data is unnecessary to transfer for tree/navigational purposes.
|
||||
|
|
@ -86,404 +86,6 @@ export class Notes2 extends Component {
|
|||
}//}}}
|
||||
}
|
||||
|
||||
class Tree extends Component {
|
||||
constructor(props) {//{{{
|
||||
super(props)
|
||||
console.log('new tree')
|
||||
this.treeNodeComponents = {}
|
||||
this.treeTrunk = []
|
||||
this.selectedNode = null
|
||||
this.expandedNodes = {} // keyed on UUID
|
||||
this.treeDiv = createRef()
|
||||
|
||||
// childrenFetchedCallbacks is keyed on a UUID and each
|
||||
// item is an array with callbacks called when a UUID has
|
||||
// had all children fetched.
|
||||
this.childrenFetchedCallbacks = {}
|
||||
|
||||
this.props.app.tree = this
|
||||
|
||||
this.populateFirstLevel()
|
||||
}//}}}
|
||||
render({ app }) {//{{{
|
||||
const renderedTreeTrunk = this.treeTrunk.map(node => {
|
||||
this.treeNodeComponents[node.UUID] = createRef()
|
||||
return html`<${TreeNode} key=${`treenode_${node.UUID}`} tree=${this} node=${node} ref=${this.treeNodeComponents[node.UUID]} selected=${node.UUID === app.state.startNode?.UUID} />`
|
||||
})
|
||||
|
||||
return html`
|
||||
<div id="tree" ref=${this.treeDiv} tabindex="0">
|
||||
<div id="logo" onclick=${() => _notes2.current.goToNode(ROOT_NODE)}><img src="/images/${_VERSION}/logo.svg" /></div>
|
||||
<div class="icons">
|
||||
<img src="/images/${_VERSION}/icon_search.svg" style="height: 22px" onclick=${() => _mbus.dispatch('op-search')} />
|
||||
<img src="/images/${_VERSION}/icon_refresh.svg" onclick=${() => _sync.run()} />
|
||||
</div>
|
||||
${renderedTreeTrunk}
|
||||
</div>`
|
||||
}//}}}
|
||||
componentDidMount() {//{{{
|
||||
//this.treeDiv.current.addEventListener('keydown', event => this.keyHandler(event))
|
||||
|
||||
// This will show and select the treenode that is selected in the node UI.
|
||||
const node = _notes2.current?.nodeUI.current?.node.value
|
||||
if (node === null)
|
||||
return
|
||||
_notes2.current.tree.expandToTrunk(node)
|
||||
this.setSelected(node)
|
||||
}//}}}
|
||||
|
||||
fetchChildrenNotify(uuid, fn) {//{{{
|
||||
if (this.childrenFetchedCallbacks[uuid] === undefined)
|
||||
this.childrenFetchedCallbacks[uuid] = [fn]
|
||||
else
|
||||
this.childrenFetchedCallbacks[uuid].push(fn)
|
||||
}//}}}
|
||||
fetchChildrenOn(uuid) {//{{{
|
||||
if (this.childrenFetchedCallbacks[uuid] === undefined)
|
||||
return
|
||||
for (const fn of this.childrenFetchedCallbacks[uuid])
|
||||
fn(uuid)
|
||||
delete this.childrenFetchedCallbacks[uuid]
|
||||
}//}}}
|
||||
|
||||
populateFirstLevel(callback = null) {//{{{
|
||||
nodeStore.get(ROOT_NODE)
|
||||
.then(node => node.fetchChildren())
|
||||
.then(children => {
|
||||
this.treeNodeComponents = {}
|
||||
this.treeTrunk = []
|
||||
for (const node of children) {
|
||||
// The root node isn't supposed to be shown in the tree.
|
||||
if (node.UUID === ROOT_NODE)
|
||||
continue
|
||||
if (node.ParentUUID === ROOT_NODE)
|
||||
this.treeTrunk.push(node)
|
||||
}
|
||||
this.forceUpdate()
|
||||
if (callback)
|
||||
callback()
|
||||
|
||||
})
|
||||
.catch(e => { console.log(e); console.log(e.type, e.error); alert(e.error) })
|
||||
}//}}}
|
||||
setSelected(node, dontExpand) {//{{{
|
||||
// The previously selected node, if any, needs to be rerendered
|
||||
// to not retain its 'selected' class.
|
||||
const prevUUID = this.selectedNode?.UUID
|
||||
this.selectedNode = node
|
||||
if (prevUUID)
|
||||
this.treeNodeComponents[prevUUID]?.current.forceUpdate()
|
||||
|
||||
// And now the newly selected node is rerendered.
|
||||
this.treeNodeComponents[node.UUID]?.current.forceUpdate()
|
||||
|
||||
if (!dontExpand)
|
||||
this.setNodeExpanded(node, true)
|
||||
}//}}}
|
||||
isSelected(node) {//{{{
|
||||
return this.selectedNode?.UUID === node.UUID
|
||||
}//}}}
|
||||
async expandToTrunk(node) {//{{{
|
||||
// Get all ancestors from a certain node up to the highest grandparent.
|
||||
const ancestry = await nodeStore.getNodeAncestry(node, [])
|
||||
for (const i in ancestry) {
|
||||
await nodeStore.node(ancestry[i].UUID).fetchChildren()
|
||||
this.setNodeExpanded(ancestry[i], true)
|
||||
}
|
||||
|
||||
// Already a top node, no need to expand anything.
|
||||
if (ancestry.length === 0)
|
||||
return
|
||||
|
||||
// Start the chain of by expanding the top node.
|
||||
this.setNodeExpanded(ancestry[ancestry.length - 1], true)
|
||||
}//}}}
|
||||
getNodeExpanded(UUID) {//{{{
|
||||
if (this.expandedNodes[UUID] === undefined)
|
||||
this.expandedNodes[UUID] = signal(false)
|
||||
return this.expandedNodes[UUID].value
|
||||
}//}}}
|
||||
async setNodeExpanded(node, value) {//{{{
|
||||
return new Promise((resolve, reject) => {
|
||||
const work = uuid => {
|
||||
// Creating a default value if it doesn't exist already.
|
||||
this.getNodeExpanded(uuid)
|
||||
this.expandedNodes[uuid].value = value
|
||||
resolve()
|
||||
}
|
||||
|
||||
if (node.hasFetchedChildren()) {
|
||||
work(node.UUID)
|
||||
return
|
||||
} else {
|
||||
this.fetchChildrenNotify(node.UUID, uuid => work(uuid))
|
||||
}
|
||||
})
|
||||
}//}}}
|
||||
getParentWithNextSibling(node) {//{{{
|
||||
let currNode = node
|
||||
while (currNode !== null && currNode.UUID !== ROOT_NODE && currNode.getSiblingAfter() === null) {
|
||||
currNode = currNode.getParent()
|
||||
}
|
||||
return currNode?.getSiblingAfter()
|
||||
}//}}}
|
||||
getLastExpandedNode(node) {//{{{
|
||||
let currNode = node
|
||||
while (this.getNodeExpanded(currNode.UUID) && currNode.hasChildren()) {
|
||||
currNode = currNode.Children[currNode.Children.length - 1]
|
||||
}
|
||||
return currNode
|
||||
}//}}}
|
||||
|
||||
async recursiveExpand(node, state) {//{{{
|
||||
if (state)
|
||||
await this.setNodeExpanded(node, true)
|
||||
|
||||
for (const child of node.Children)
|
||||
await this.recursiveExpand(child, state)
|
||||
|
||||
if (!state)
|
||||
await this.setNodeExpanded(node, false)
|
||||
}//}}}
|
||||
|
||||
async keyHandler(event) {//{{{
|
||||
let handled = true
|
||||
const n = this.selectedNode
|
||||
const Space = ' '
|
||||
|
||||
// This handler would otherwise react to stuff like Ctrl+L.
|
||||
if (event.ctrlKey || event.altKey)
|
||||
return
|
||||
|
||||
switch (event.key) {
|
||||
// Space and enter is toggling expansion.
|
||||
// Holding shift down does it recursively.
|
||||
case Space:
|
||||
case 'Enter':
|
||||
const expanded = this.getNodeExpanded(n.UUID)
|
||||
if (event.shiftKey) {
|
||||
this.recursiveExpand(n, !expanded)
|
||||
} else {
|
||||
this.setNodeExpanded(n, !expanded)
|
||||
}
|
||||
break
|
||||
|
||||
case 'g':
|
||||
case 'Home':
|
||||
this.navigateTop()
|
||||
break
|
||||
|
||||
case 'G':
|
||||
case 'End':
|
||||
this.navigateBottom()
|
||||
break
|
||||
|
||||
case 'j':
|
||||
case 'ArrowDown':
|
||||
await this.navigateDown(this.selectedNode)
|
||||
break
|
||||
|
||||
case 'k':
|
||||
case 'ArrowUp':
|
||||
await this.navigateUp(this.selectedNode)
|
||||
break
|
||||
|
||||
case 'h':
|
||||
case 'ArrowLeft':
|
||||
await this.navigateLeft(this.selectedNode)
|
||||
break
|
||||
|
||||
case 'l':
|
||||
case 'ArrowRight':
|
||||
await this.navigateRight(this.selectedNode)
|
||||
break
|
||||
|
||||
default:
|
||||
// nonsole.log(event.key)
|
||||
handled = false
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}//}}}
|
||||
async navigateLeft(n) {//{{{
|
||||
if (n === null)
|
||||
return
|
||||
|
||||
const expanded = this.getNodeExpanded(n.UUID)
|
||||
if (expanded && n.hasChildren()) {
|
||||
this.setNodeExpanded(n, false)
|
||||
return
|
||||
}
|
||||
|
||||
if (n.isFirstSibling() && n.getParent().UUID !== ROOT_NODE) {
|
||||
await _notes2.current.goToNode(n.getParent()?.UUID, true, true)
|
||||
return
|
||||
}
|
||||
|
||||
const siblingBefore = n.getSiblingBefore()
|
||||
const siblingExpanded = this.getNodeExpanded(siblingBefore?.UUID)
|
||||
if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) {
|
||||
const siblingAbove = this.getLastExpandedNode(siblingBefore)
|
||||
await _notes2.current.goToNode(siblingAbove?.UUID, true, true)
|
||||
return
|
||||
}
|
||||
|
||||
await _notes2.current.goToNode(n.getSiblingBefore()?.UUID, true, true)
|
||||
}//}}}
|
||||
async navigateRight(n) {//{{{
|
||||
if (n === null)
|
||||
return
|
||||
|
||||
const siblingAfter = n.getSiblingAfter()
|
||||
const expanded = this.getNodeExpanded(n.UUID)
|
||||
|
||||
if (!expanded && n.hasChildren()) {
|
||||
this.setNodeExpanded(n, true)
|
||||
return
|
||||
}
|
||||
|
||||
if (expanded && n.hasChildren()) {
|
||||
await _notes2.current.goToNode(n.Children[0]?.UUID, true, true)
|
||||
return
|
||||
}
|
||||
|
||||
if (n.isLastSibling()) {
|
||||
const nextNode = this.getParentWithNextSibling(n)
|
||||
await _notes2.current.goToNode(nextNode?.UUID, true, true)
|
||||
return
|
||||
}
|
||||
|
||||
await _notes2.current.goToNode(n.getSiblingAfter()?.UUID, true, true)
|
||||
}//}}}
|
||||
async navigateUp(n) {//{{{
|
||||
if (n === null)
|
||||
return
|
||||
|
||||
let parent = null
|
||||
const siblingBefore = n.getSiblingBefore()
|
||||
let siblingExpanded = false
|
||||
if (siblingBefore !== null)
|
||||
siblingExpanded = this.getNodeExpanded(siblingBefore.UUID)
|
||||
|
||||
if (n.isFirstSibling()) {
|
||||
parent = n.getParent()
|
||||
if (parent?.UUID === ROOT_NODE)
|
||||
return
|
||||
await _notes2.current.goToNode(parent?.UUID, true, true)
|
||||
return
|
||||
}
|
||||
|
||||
if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) {
|
||||
await _notes2.current.goToNode(siblingBefore.Children[siblingBefore.Children.length - 1]?.UUID, true, true)
|
||||
return
|
||||
}
|
||||
|
||||
if (siblingBefore) {
|
||||
await _notes2.current.goToNode(siblingBefore.UUID, true, true)
|
||||
return
|
||||
}
|
||||
}//}}}
|
||||
async navigateDown(n) {//{{{
|
||||
if (n === null)
|
||||
return
|
||||
|
||||
const nodeExpanded = this.getNodeExpanded(n.UUID)
|
||||
|
||||
// Last node, not expanded, so it matters not whether it has children or not.
|
||||
// Traverse upward to nearest parent with next sibling.
|
||||
if (!nodeExpanded && n.isLastSibling()) {
|
||||
const wantedNode = this.getParentWithNextSibling(n)
|
||||
await _notes2.current.goToNode(wantedNode?.UUID, true, true)
|
||||
return
|
||||
}
|
||||
|
||||
if (nodeExpanded && n.isLastSibling() && !n.hasChildren()) {
|
||||
const wantedNode = this.getParentWithNextSibling(n)
|
||||
await _notes2.current.goToNode(wantedNode?.UUID, true, true)
|
||||
return
|
||||
}
|
||||
|
||||
// Node not expanded. Go to this node's next sibling.
|
||||
// GoToNode will abort if given null.
|
||||
if (!nodeExpanded || !n.hasChildren()) {
|
||||
await _notes2.current.goToNode(n.getSiblingAfter()?.UUID, true, true)
|
||||
return
|
||||
}
|
||||
|
||||
// Node is expanded.
|
||||
// Children will be visually beneath this node, if any.
|
||||
if (nodeExpanded && n.hasChildren()) {
|
||||
await _notes2.current.goToNode(n.Children[0].UUID, true, true)
|
||||
return
|
||||
}
|
||||
}//}}}
|
||||
async navigateTop() {//{{{
|
||||
const root = await nodeStore.get(ROOT_NODE)
|
||||
if (root.Children.length === 0)
|
||||
return
|
||||
await _notes2.current.goToNode(root.Children[0]?.UUID, true, true)
|
||||
}//}}}
|
||||
async navigateBottom() {//{{{
|
||||
const root = await nodeStore.get(ROOT_NODE)
|
||||
if (root.Children.length === 0)
|
||||
return
|
||||
|
||||
const toplevel = root.Children[root.Children.length - 1]
|
||||
const toplevelExpanded = this.getNodeExpanded(toplevel?.UUID)
|
||||
|
||||
if (toplevelExpanded) {
|
||||
const lastnode = this.getLastExpandedNode(toplevel)
|
||||
await _notes2.current.goToNode(lastnode?.UUID, true, true)
|
||||
} else
|
||||
await _notes2.current.goToNode(root.Children[root.Children.length - 1]?.UUID, true, true)
|
||||
}//}}}
|
||||
}
|
||||
|
||||
class TreeNode extends Component {
|
||||
constructor(props) {//{{{
|
||||
super(props)
|
||||
this.children_populated = signal(false)
|
||||
if (this.props.node.Level === 0 || this.props.tree.getNodeExpanded(this.props.node.UUID))
|
||||
this.fetchChildren()
|
||||
}//}}}
|
||||
render({ tree, node, parent }) {//{{{
|
||||
// Fetch the next level of children if the parent tree node is expanded and our children thus will be visible.
|
||||
const selected = tree.isSelected(node) ? 'selected' : ''
|
||||
|
||||
if (!this.children_populated.value && tree.getNodeExpanded(parent?.props.node.UUID))
|
||||
this.fetchChildren()
|
||||
|
||||
const children = node.Children.map(node => {
|
||||
tree.treeNodeComponents[node.UUID] = createRef()
|
||||
return html`<${TreeNode} key=${`treenode_${node.UUID}`} tree=${tree} node=${node} parent=${this} ref=${tree.treeNodeComponents[node.UUID]} selected=${node.UUID === tree.props.app.startNode?.UUID} />`
|
||||
})
|
||||
|
||||
let expandImg = ''
|
||||
if (node.Children.length === 0)
|
||||
expandImg = html`<img src="/images/${window._VERSION}/leaf.svg" />`
|
||||
else {
|
||||
if (tree.getNodeExpanded(node.UUID))
|
||||
expandImg = html`<img src="/images/${window._VERSION}/expanded.svg" />`
|
||||
else
|
||||
expandImg = html`<img src="/images/${window._VERSION}/collapsed.svg" />`
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="node">
|
||||
<div class="expand-toggle" onclick=${() => { tree.setNodeExpanded(node, !tree.getNodeExpanded(node.UUID)) }}>${expandImg}</div>
|
||||
<div class="name ${selected}" onclick=${() => window._notes2.current.goToNode(node.UUID)}>${node.get('Name')}</div>
|
||||
<div class="children ${node.Children.length > 0 && tree.getNodeExpanded(node.UUID) ? 'expanded' : 'collapsed'}">${children}</div>
|
||||
</div>`
|
||||
}//}}}
|
||||
async fetchChildren() {//{{{
|
||||
await this.props.node.fetchChildren()
|
||||
this.children_populated.value = true
|
||||
}//}}}
|
||||
}
|
||||
|
||||
class Op {
|
||||
constructor(id) {
|
||||
this.id = id
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
import { ROOT_NODE } from 'node_store'
|
||||
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
||||
import { Color, Solver } from './lib/css_colorize.mjs'
|
||||
|
||||
export class N2Tree extends CustomHTMLElement {
|
||||
static {// {{{
|
||||
this.tmpl = document.createElement('template')
|
||||
this.tmpl.innerHTML = `
|
||||
<div data-el="logo" id="logo"><img src="/images/${_VERSION}/logo.svg" /></div>
|
||||
<div class="icons">
|
||||
<div data-el="logo" id="logo">
|
||||
<img src="/images/${_VERSION}/logo_small.svg" />
|
||||
<img src="/images/${_VERSION}/logo.svg" />
|
||||
<img data-el="search" class='search' src="/images/${_VERSION}/icon_search.svg" style="height: 22px" />
|
||||
<img data-el="sync" class='sync' src="/images/${_VERSION}/icon_refresh.svg" />
|
||||
</div>
|
||||
<div class="icons">
|
||||
<img data-el="sync" class='sync' src="/images/${_VERSION}/icon_refresh.svg" />
|
||||
<img data-el="settings" class='settings' src="/images/${_VERSION}/icon_settings.svg" />
|
||||
</div>
|
||||
<div data-el="treenodes"></div>
|
||||
`
|
||||
|
|
@ -31,7 +36,7 @@ export class N2Tree extends CustomHTMLElement {
|
|||
this.elSync.addEventListener('click', () => _sync.run())
|
||||
this.elLogo.addEventListener('click', () => _app.goToNode(ROOT_NODE, false, false))
|
||||
|
||||
_mbus.subscribe('NODE_MODIFIED', ({ detail })=>{
|
||||
_mbus.subscribe('NODE_MODIFIED', ({ detail }) => {
|
||||
const node = detail.data.node
|
||||
const treenode = this.treeNodeComponents[node.get('UUID')]
|
||||
|
||||
|
|
@ -43,6 +48,12 @@ export class N2Tree extends CustomHTMLElement {
|
|||
})
|
||||
|
||||
this.populateFirstLevel()
|
||||
|
||||
/* XXX - set color */
|
||||
let color = new Color(255, 96, 80)
|
||||
let solver = new Solver(color)
|
||||
let result = solver.solve()
|
||||
this.elSettings.style.filter = result.filter
|
||||
}// }}}
|
||||
render() {// {{{
|
||||
if (this.rendered)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
const CACHE_NAME = 'notes2-{{ .VERSION }}'
|
||||
const CACHED_ASSETS = [
|
||||
'/',
|
||||
'/notes2',
|
||||
'/offline',
|
||||
|
||||
'/css/{{ .VERSION }}/main.css',
|
||||
|
|
@ -25,7 +24,6 @@ const CACHED_ASSETS = [
|
|||
'/js/{{ .VERSION }}/crypto.mjs',
|
||||
'/js/{{ .VERSION }}/key.mjs',
|
||||
'/js/{{ .VERSION }}/lib/custom_html_element.mjs',
|
||||
'/js/{{ .VERSION }}/lib/fullcalendar.min.js',
|
||||
'/js/{{ .VERSION }}/lib/node_modules/marked/lib/marked.esm.js',
|
||||
'/js/{{ .VERSION }}/lib/node_modules/marked-token-position/lib/index.esm.js',
|
||||
'/js/{{ .VERSION }}/lib/sjcl.js',
|
||||
|
|
|
|||
|
|
@ -16,10 +16,6 @@
|
|||
"node_store": "/js/{{ .VERSION }}/node_store.mjs",
|
||||
"node": "/js/{{ .VERSION }}/node.mjs",
|
||||
"tree": "/js/{{ .VERSION }}/tree.mjs"
|
||||
{{/*
|
||||
"session": "/js/{{ .VERSION }}/session.mjs",
|
||||
"ws": "/_js/{{ .VERSION }}/websocket.mjs"
|
||||
*/}}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -33,8 +29,6 @@
|
|||
import { MessageBus } from '/js/{{ .VERSION }}/mbus.mjs'
|
||||
window._mbus = new MessageBus()
|
||||
</script>
|
||||
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/sjcl.js"></script>
|
||||
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/fullcalendar.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">{{ block "page" . }}{{ end }}</div>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ class Login {
|
|||
const password = document.getElementById('password').value
|
||||
API.authenticate(username, password)
|
||||
.then(ans=>{
|
||||
location.href = '/notes2'
|
||||
location.href = '/'
|
||||
})
|
||||
.catch(e=>{
|
||||
setTimeout(()=>this.errorDiv.innerText = e, 75)
|
||||
|
|
|
|||