diff --git a/authentication/pkg.go b/authentication/pkg.go index 9eb6245..c0b9a2e 100644 --- a/authentication/pkg.go +++ b/authentication/pkg.go @@ -8,6 +8,9 @@ import ( "github.com/jmoiron/sqlx" "github.com/lib/pq" + // Internal + appUser "notes2/user" + // Standard "database/sql" "encoding/hex" @@ -27,12 +30,6 @@ type Manager struct { ExpireDays int } -type User struct { - ID int - Username string - Name string -} - func httpError(w http.ResponseWriter, err error) { // {{{ j, _ := json.Marshal(struct { OK bool @@ -165,16 +162,16 @@ func (mngr *Manager) AuthenticationHandler(w http.ResponseWriter, r *http.Reques mngr.log.Info("authentication", "username", request.Username, "status", "accepted") j, _ := json.Marshal(struct { OK bool - User User + User appUser.User Token string }{true, user, token}) w.Write(j) } // }}} -func (mngr *Manager) Authenticate(username, password string) (authenticated bool, user User, err error) { // {{{ +func (mngr *Manager) Authenticate(username, password string) (authenticated bool, user appUser.User, err error) { // {{{ var row *sql.Row row = mngr.db.QueryRow(` - SELECT id, username, name + SELECT id, username, name, preferences FROM public.user WHERE LOWER(username) = LOWER($1) AND @@ -183,13 +180,21 @@ func (mngr *Manager) Authenticate(username, password string) (authenticated bool username, password, ) - err = row.Scan(&user.ID, &user.Username, &user.Name) + var data []byte + err = row.Scan(&user.ID, &user.Username, &user.Name, &data) if err != nil && err.Error() == "sql: no rows in result set" { err = nil authenticated = false return } if err != nil { + authenticated = false + return + } + + err = json.Unmarshal(data, &user.Preferences) + if err != nil { + authenticated = false return } @@ -278,7 +283,7 @@ func (mngr *Manager) ChangePassword(username, currentPassword, newPassword strin changed = (rowsAffected == 1) return } // }}} -func (mngr *Manager) NewClientUUID(user User) (clientUUID string, err error) { // {{{ +func (mngr *Manager) NewClientUUID(user appUser.User) (clientUUID string, err error) { // {{{ // Each client session has its own UUID. // Loop through until a unique one is established. var proposedClientUUID string diff --git a/main.go b/main.go index 7c2decc..6e3cf94 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( // Internal "notes2/authentication" "notes2/html_template" + appUser "notes2/user" "os" // Standard @@ -23,7 +24,7 @@ import ( "text/template" ) -const VERSION = "v17" +const VERSION = "v29" const CONTEXT_USER = 1 const SYNC_PAGINATION = 200 @@ -134,6 +135,8 @@ func main() { // {{{ http.HandleFunc("/offline", pageOffline) http.HandleFunc("/user/authenticate", AuthManager.AuthenticationHandler) + http.HandleFunc("GET /user/preferences", authenticated(actionUserGetPreferences)) + http.HandleFunc("POST /user/preferences", authenticated(actionUserSetPreferences)) http.HandleFunc("/sync/from_server/count/{sequence}", authenticated(actionSyncFromServerCount)) http.HandleFunc("/sync/from_server/{sequence}/{offset}", authenticated(actionSyncFromServer)) @@ -178,7 +181,7 @@ func authenticated(fn func(http.ResponseWriter, *http.Request)) func(http.Respon } // User object is added to the context for the next handler. - user := NewUser(claims) + user := appUser.NewUser(claims) r = r.WithContext(context.WithValue(r.Context(), CONTEXT_USER, user)) Log.Debug("webserver", "op", "request", "method", r.Method, "url", r.URL.String(), "username", user.Username, "client", user.ClientUUID) @@ -266,7 +269,7 @@ func actionSyncFromServer(w http.ResponseWriter, r *http.Request) { // {{{ // The purpose of the Client UUID is to avoid // sending nodes back once again to a client that // just created or modified it. - user := getUser(r) + user := getUserSession(r) changedFrom, _ := strconv.Atoi(r.PathValue("sequence")) offset, _ := strconv.Atoi(r.PathValue("offset")) @@ -277,12 +280,6 @@ func actionSyncFromServer(w http.ResponseWriter, r *http.Request) { // {{{ return } - /* - 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 { OK bool Nodes []Node @@ -295,7 +292,7 @@ func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{ // The purpose of the Client UUID is to avoid // sending nodes back once again to a client that // just created or modified it. - user := getUser(r) + user := getUserSession(r) changedFrom, _ := strconv.Atoi(r.PathValue("sequence")) count, err := NodesCount(user.UserID, uint64(changedFrom), user.ClientUUID) @@ -315,7 +312,7 @@ func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{ w.Write(j) } // }}} func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ - user := getUser(r) + user := getUserSession(r) var err error uuid := r.PathValue("uuid") @@ -331,7 +328,7 @@ func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ }) } // }}} func actionNodeHistoryRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ - user := getUser(r) + user := getUserSession(r) var err error uuid := r.PathValue("uuid") @@ -354,7 +351,7 @@ func actionNodeHistoryRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ }) } // }}} func actionNodeHistoryCount(w http.ResponseWriter, r *http.Request) { // {{{ - user := getUser(r) + user := getUserSession(r) var err error uuid := r.PathValue("uuid") @@ -366,12 +363,12 @@ func actionNodeHistoryCount(w http.ResponseWriter, r *http.Request) { // {{{ } responseData(w, map[string]any{ - "OK": true, - "Count": count, + "OK": true, + "Count": count, }) } // }}} func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{ - user := getUser(r) + user := getUserSession(r) body, _ := io.ReadAll(r.Body) var request struct { @@ -383,9 +380,50 @@ func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{ return } - _, err = db.Exec(`CALL add_nodes($1, $2, $3::jsonb)`, user.UserID, user.ClientUUID, request.NodeData) + _, err = db.Exec(`CALL add_nodes($1, $2::uuid, $3::jsonb)`, user.UserID, user.ClientUUID, request.NodeData) + if err != nil { + Log.Error("sync", "error", err, "user_id", user.UserID, "client_uuid", user.ClientUUID, "node_data", request.NodeData) + httpError(w, err) + return + } + + responseData(w, map[string]any{ + "OK": true, + }) +} // }}} + +func actionUserGetPreferences(w http.ResponseWriter, r *http.Request) { // {{{ + user := getUserSession(r) + prefs, err := user.Preferences() + if err != nil { + httpError(w, err) + return + } + + responseData(w, map[string]any{ + "OK": true, + "Preferences": prefs, + }) +} // }}} +func actionUserSetPreferences(w http.ResponseWriter, r *http.Request) { // {{{ + session := getUserSession(r) + + // Verify the "default" profile is still there. + var newPrefs map[string]appUser.UserPreferences + body, _ := io.ReadAll(r.Body) + err := json.Unmarshal(body, &newPrefs) + if err != nil { + httpError(w, err) + return + } + + if _, found := newPrefs["default"]; !found { + httpError(w, fmt.Errorf("'default' profile missing.")) + return + } + + err = session.SetPreferences(newPrefs) if err != nil { - Log.Error("sync", "error", err) httpError(w, err) return } @@ -437,7 +475,8 @@ func changePassword(username string) { // {{{ fmt.Printf("\nPassword changed\n") } // }}} -func getUser(r *http.Request) UserSession { // {{{ - user, _ := r.Context().Value(CONTEXT_USER).(UserSession) +func getUserSession(r *http.Request) appUser.UserSession { // {{{ + user, _ := r.Context().Value(CONTEXT_USER).(appUser.UserSession) + user.Db = db return user } // }}} diff --git a/node.go b/node.go index 0beb1b1..a25c771 100644 --- a/node.go +++ b/node.go @@ -44,6 +44,7 @@ type Node struct { UUID string UserID int `db:"user_id"` ParentUUID string `db:"parent_uuid"` + HistoryUUID string `db:"history_uuid"` Name string Created time.Time Updated time.Time @@ -53,11 +54,7 @@ type Node struct { DeletedSeq sql.NullInt64 `db:"deleted_seq"` Content string ContentEncrypted string `db:"content_encrypted" json:"-"` - Markdown bool - - // CryptoKeyID int `db:"crypto_key_id"` - //Files []File - //ChecklistGroups []ChecklistGroup + Special bool } func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint64, moreRowsExist bool, err error) { // {{{ @@ -78,7 +75,7 @@ func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint6 public.node WHERE user_id = $1 AND - NOT history AND ( + ( created_seq > $4 OR updated_seq > $4 OR deleted_seq > $4 @@ -126,7 +123,7 @@ func Nodes(userID, offset int, synced uint64, clientUUID string) (nodes []Node, rows, err = db.Queryx(` SELECT uuid, - COALESCE(parent_uuid, '') AS parent_uuid, + COALESCE(parent_uuid, '00000000-0000-0000-0000-000000000000'::uuid) AS parent_uuid, name, created, updated, @@ -135,14 +132,14 @@ func Nodes(userID, offset int, synced uint64, clientUUID string) (nodes []Node, updated_seq, deleted_seq, content, - content_encrypted, - markdown + content_encrypted FROM public.node WHERE + NOT special AND user_id = $1 AND - client != $5 AND - NOT history AND ( + client != $5::uuid AND + ( created_seq > $4 OR updated_seq > $4 OR deleted_seq > $4 @@ -195,7 +192,7 @@ func NodesCount(userID int, synced uint64, clientUUID string) (count int, err er WHERE user_id = $1 AND client != $3 AND - NOT history AND ( + ( created_seq > $2 OR updated_seq > $2 OR deleted_seq > $2 @@ -255,6 +252,7 @@ func RetrieveNodeHistory(userID int, nodeUUID string, offset int) (nodes []Node, rows, err = db.Queryx(` SELECT uuid, + history_uuid, user_id, name, created, diff --git a/sql/00001.sql b/sql/00001.sql index 7eb8273..4aecc91 100644 --- a/sql/00001.sql +++ b/sql/00001.sql @@ -257,7 +257,7 @@ $$; CREATE TABLE public.client ( id integer NOT NULL, user_id integer NOT NULL, - client_uuid character(36) DEFAULT ''::bpchar NOT NULL, + client_uuid uuid DEFAULT '00000000-0000-0000-0000-000000000000'::uuid NOT NULL, created timestamp with time zone DEFAULT now() NOT NULL, description character varying DEFAULT ''::character varying NOT NULL ); @@ -302,8 +302,8 @@ CREATE SEQUENCE public.node_updates 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), + "uuid" uuid DEFAULT gen_random_uuid() NOT NULL, + parent_uuid uuid, created timestamp with time zone DEFAULT now() NOT NULL, updated timestamp with time zone DEFAULT now() NOT NULL, deleted timestamp with time zone, @@ -315,7 +315,7 @@ CREATE TABLE public.node ( 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 uuid DEFAULT '00000000-0000-0000-0000-000000000000'::uuid NOT NULL, client_sequence integer, CONSTRAINT name_length CHECK ((length(TRIM(BOTH FROM name)) > 0)) ); @@ -328,7 +328,7 @@ CREATE TABLE public.node ( CREATE TABLE public.node_history ( id integer NOT NULL, user_id integer NOT NULL, - uuid character(36) NOT NULL, + "uuid" uuid NOT NULL, parents character varying[], created timestamp with time zone NOT NULL, updated timestamp with time zone NOT NULL, @@ -336,7 +336,7 @@ CREATE TABLE public.node_history ( content text NOT NULL, content_encrypted text NOT NULL, markdown boolean DEFAULT false NOT NULL, - client character(36) DEFAULT ''::bpchar NOT NULL, + client uuid DEFAULT '00000000-0000-0000-0000-000000000000'::uuid NOT NULL, client_sequence integer ); diff --git a/sql/00003.sql b/sql/00003.sql new file mode 100644 index 0000000..a0cd4b1 --- /dev/null +++ b/sql/00003.sql @@ -0,0 +1 @@ +ALTER TABLE public.node_history ADD history_uuid uuid NULL; diff --git a/sql/00004.sql b/sql/00004.sql new file mode 100644 index 0000000..eafbad2 --- /dev/null +++ b/sql/00004.sql @@ -0,0 +1,135 @@ +CREATE UNIQUE INDEX node_history_user_id_idx ON public.node_history (user_id,"uuid",history_uuid); + + +ALTER TABLE public.node ALTER COLUMN "uuid" TYPE uuid USING "uuid"::uuid::uuid; +ALTER TABLE public.node ALTER COLUMN parent_uuid TYPE uuid USING parent_uuid::uuid::uuid; +ALTER TABLE public.node ALTER COLUMN client TYPE uuid USING client::uuid::uuid; + + + +CREATE OR REPLACE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid uuid, IN p_nodes jsonb) + LANGUAGE plpgsql +AS $procedure$ + +DECLARE + node_data jsonb; + node_updated timestamptz; + db_updated timestamptz; + db_uuid uuid; + db_client uuid; + db_client_seq int; + db_history_uuid uuid; + node_uuid uuid; + node_parent_uuid uuid; + node_history_uuid uuid; + +BEGIN + RAISE NOTICE '--------------------------'; + FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes) + LOOP + node_uuid = (node_data->>'UUID')::uuid; + node_history_uuid = (node_data->>'HistoryUUID')::uuid; + node_updated = (node_data->>'Updated')::timestamptz; + + + + -- Frontend is using an all-zero UUID to define the root node. + -- Database is using NULL. + IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' THEN + node_parent_uuid = NULL; + ELSE + node_parent_uuid = (node_data->>'ParentUUID')::uuid; + END IF; + + + + -- Every jode has a new history UUID to keep the history entry uniquely identifiable + -- across clients. A history entry could potentially be sent again, but should be + -- safe to ignore as every change to a node should have a new history UUID. + -- + -- The current node is also stored as history. + INSERT INTO node_history( + user_id, "uuid", "history_uuid", parents, created, updated, + "name", "content", markdown, "content_encrypted", + client, client_sequence + ) + VALUES( + p_user_id, -- combined key + node_uuid, -- combined key + node_history_uuid, -- combined key + (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 ("user_id", "uuid", "history_uuid") + DO NOTHING; + + + + -- 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::uuid = node_uuid::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_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; + + + + -- Update the public node as well if it was older than incoming node. + IF node_updated > db_updated THEN + 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::uuid = node_uuid::uuid; + END IF; + + END LOOP; +END +$procedure$ +; diff --git a/sql/00005.sql b/sql/00005.sql new file mode 100644 index 0000000..b272085 --- /dev/null +++ b/sql/00005.sql @@ -0,0 +1,129 @@ +-- Some cleanup of old columns not used anymore. +DROP INDEX public.node_history_client_idx; +ALTER TABLE public.node_history DROP COLUMN client_sequence; + +ALTER TABLE public.node DROP COLUMN markdown; +DROP INDEX public.node_history_idx; +ALTER TABLE public.node DROP COLUMN history; +ALTER TABLE public.node DROP COLUMN client_sequence; + + + +CREATE OR REPLACE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid uuid, IN p_nodes jsonb) + LANGUAGE plpgsql +AS $procedure$ + +DECLARE + node_data jsonb; + node_updated timestamptz; + db_updated timestamptz; + db_uuid uuid; + db_client uuid; + db_history_uuid uuid; + node_uuid uuid; + node_parent_uuid uuid; + node_history_uuid uuid; + +BEGIN + FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes) + LOOP + node_uuid = (node_data->>'UUID')::uuid; + node_history_uuid = (node_data->>'HistoryUUID')::uuid; + node_updated = (node_data->>'Updated')::timestamptz; + + + + -- Frontend is using an all-zero UUID to define the root node. + -- Database is using NULL. + IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' OR node_data->>'ParentUUID' = '' THEN + node_parent_uuid = NULL; + ELSE + node_parent_uuid = (node_data->>'ParentUUID')::uuid; + END IF; + + + + -- Every jode has a new history UUID to keep the history entry uniquely identifiable + -- across clients. A history entry could potentially be sent again, but should be + -- safe to ignore as every change to a node should have a new history UUID. + -- + -- The current node is also stored as history. + INSERT INTO node_history( + user_id, "uuid", "history_uuid", parents, created, updated, + "name", "content", "content_encrypted", + client + ) + VALUES( + p_user_id, -- combined key + node_uuid, -- combined key + node_history_uuid, -- combined key + (jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors", + COALESCE((node_data->>'Created')::timestamptz, NOW()), + COALESCE((node_data->>'Updated')::timestamptz, NOW()), + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + '', /* content_encrypted */ + p_client_uuid + ) + ON CONFLICT ("user_id", "uuid", "history_uuid") + DO NOTHING; + + + + -- Retrieve the current modified timestamp for this node from the database. + SELECT + uuid, updated, client + INTO + db_uuid, db_updated, db_client + FROM public."node" + WHERE + user_id = p_user_id AND + uuid::uuid = node_uuid::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", "content_encrypted", + client + ) + VALUES( + p_user_id, + node_uuid, + node_parent_uuid, + COALESCE((node_data->>'Created')::timestamptz, NOW()), + COALESCE((node_data->>'Updated')::timestamptz, NOW()), + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + '', /* content_encrypted */ + p_client_uuid + ); + + CONTINUE; + + END IF; + + + + -- Update the public node as well if it was older than incoming node. + IF node_updated > db_updated THEN + UPDATE public."node" + SET + updated = (node_data->>'Updated')::timestamptz, + updated_seq = nextval('node_updates'), + name = (node_data->>'Name')::varchar, + content = (node_data->>'Content')::text, + client = p_client_uuid + WHERE + user_id = p_user_id AND + uuid::uuid = node_uuid::uuid; + END IF; + + END LOOP; +END +$procedure$ +; diff --git a/sql/00006.sql b/sql/00006.sql new file mode 100644 index 0000000..56f2acb --- /dev/null +++ b/sql/00006.sql @@ -0,0 +1,119 @@ +CREATE OR REPLACE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid uuid, IN p_nodes jsonb) + LANGUAGE plpgsql +AS $procedure$ + +DECLARE + node_data jsonb; + node_updated timestamptz; + db_updated timestamptz; + db_uuid uuid; + db_client uuid; + db_history_uuid uuid; + node_uuid uuid; + node_parent_uuid uuid; + node_history_uuid uuid; + +BEGIN + FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes) + LOOP + node_uuid = (node_data->>'UUID')::uuid; + node_history_uuid = (node_data->>'HistoryUUID')::uuid; + node_updated = (node_data->>'Updated')::timestamptz; + + + + -- Frontend is using an all-zero UUID to define the root node. + -- Database is using NULL. + IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' OR node_data->>'ParentUUID' = '' THEN + node_parent_uuid = NULL; + ELSE + node_parent_uuid = (node_data->>'ParentUUID')::uuid; + END IF; + + + + -- Every jode has a new history UUID to keep the history entry uniquely identifiable + -- across clients. A history entry could potentially be sent again, but should be + -- safe to ignore as every change to a node should have a new history UUID. + -- + -- The current node is also stored as history. + INSERT INTO node_history( + user_id, "uuid", "history_uuid", parents, created, updated, + "name", "content", "content_encrypted", + client + ) + VALUES( + p_user_id, -- combined key + node_uuid, -- combined key + node_history_uuid, -- combined key + (jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors", + COALESCE((node_data->>'Created')::timestamptz, NOW()), + COALESCE((node_data->>'Updated')::timestamptz, NOW()), + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + '', /* content_encrypted */ + p_client_uuid + ) + ON CONFLICT ("user_id", "uuid", "history_uuid") + DO NOTHING; + + + + -- Retrieve the current modified timestamp for this node from the database. + SELECT + uuid, updated, client + INTO + db_uuid, db_updated, db_client + FROM public."node" + WHERE + user_id = p_user_id AND + uuid::uuid = node_uuid::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", "content_encrypted", + client + ) + VALUES( + p_user_id, + node_uuid, + node_parent_uuid, + COALESCE((node_data->>'Created')::timestamptz, NOW()), + COALESCE((node_data->>'Updated')::timestamptz, NOW()), + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + '', /* content_encrypted */ + p_client_uuid + ); + + CONTINUE; + + END IF; + + + + -- Update the public node as well if it was older than incoming node. + IF node_updated > db_updated THEN + UPDATE public."node" + SET + updated = (node_data->>'Updated')::timestamptz, + updated_seq = nextval('node_updates'), + parent_uuid = (node_data->>'ParentUUID')::uuid, + name = (node_data->>'Name')::varchar, + content = (node_data->>'Content')::text, + client = p_client_uuid + WHERE + user_id = p_user_id AND + uuid::uuid = node_uuid::uuid; + END IF; + + END LOOP; +END +$procedure$ +; diff --git a/sql/00007.sql b/sql/00007.sql new file mode 100644 index 0000000..0b79d9c --- /dev/null +++ b/sql/00007.sql @@ -0,0 +1,119 @@ +CREATE OR REPLACE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid uuid, IN p_nodes jsonb) + LANGUAGE plpgsql +AS $procedure$ + +DECLARE + node_data jsonb; + node_updated timestamptz; + db_updated timestamptz; + db_uuid uuid; + db_client uuid; + db_history_uuid uuid; + node_uuid uuid; + node_parent_uuid uuid; + node_history_uuid uuid; + +BEGIN + FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes) + LOOP + node_uuid = (node_data->>'UUID')::uuid; + node_history_uuid = (node_data->>'HistoryUUID')::uuid; + node_updated = (node_data->>'Updated')::timestamptz; + + + + -- Frontend is using an all-zero UUID to define the root node. + -- Database is using NULL. + IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' OR node_data->>'ParentUUID' = '' THEN + node_parent_uuid = NULL; + ELSE + node_parent_uuid = (node_data->>'ParentUUID')::uuid; + END IF; + + + + -- Every jode has a new history UUID to keep the history entry uniquely identifiable + -- across clients. A history entry could potentially be sent again, but should be + -- safe to ignore as every change to a node should have a new history UUID. + -- + -- The current node is also stored as history. + INSERT INTO node_history( + user_id, "uuid", "history_uuid", parents, created, updated, + "name", "content", "content_encrypted", + client + ) + VALUES( + p_user_id, -- combined key + node_uuid, -- combined key + node_history_uuid, -- combined key + (jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors", + COALESCE((node_data->>'Created')::timestamptz, NOW()), + COALESCE((node_data->>'Updated')::timestamptz, NOW()), + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + '', /* content_encrypted */ + p_client_uuid + ) + ON CONFLICT ("user_id", "uuid", "history_uuid") + DO NOTHING; + + + + -- Retrieve the current modified timestamp for this node from the database. + SELECT + uuid, updated, client + INTO + db_uuid, db_updated, db_client + FROM public."node" + WHERE + user_id = p_user_id AND + uuid::uuid = node_uuid::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", "content_encrypted", + client + ) + VALUES( + p_user_id, + node_uuid, + node_parent_uuid, + COALESCE((node_data->>'Created')::timestamptz, NOW()), + COALESCE((node_data->>'Updated')::timestamptz, NOW()), + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + '', /* content_encrypted */ + p_client_uuid + ); + + CONTINUE; + + END IF; + + + + -- Update the public node as well if it was older than incoming node. + IF node_updated > db_updated THEN + UPDATE public."node" + SET + updated = (node_data->>'Updated')::timestamptz, + updated_seq = nextval('node_updates'), + parent_uuid = node_parent_uuid, + name = (node_data->>'Name')::varchar, + content = (node_data->>'Content')::text, + client = p_client_uuid + WHERE + user_id = p_user_id AND + uuid::uuid = node_uuid::uuid; + END IF; + + END LOOP; +END +$procedure$ +; diff --git a/sql/00008.sql b/sql/00008.sql new file mode 100644 index 0000000..2701ba5 --- /dev/null +++ b/sql/00008.sql @@ -0,0 +1,123 @@ +CREATE OR REPLACE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid uuid, IN p_nodes jsonb) + LANGUAGE plpgsql +AS $procedure$ + +DECLARE + node_data jsonb; + node_updated timestamptz; + db_updated timestamptz; + db_uuid uuid; + db_client uuid; + db_history_uuid uuid; + node_uuid uuid; + node_parent_uuid uuid; + node_history_uuid uuid; + +BEGIN + FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes) + LOOP + node_uuid = (node_data->>'UUID')::uuid; + node_history_uuid = (node_data->>'HistoryUUID')::uuid; + node_updated = (node_data->>'Updated')::timestamptz; + + + + -- Frontend is using an all-zero UUID to define the root node. + -- Database is using NULL. + IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' OR node_data->>'ParentUUID' = '' THEN + node_parent_uuid = NULL; + ELSE + node_parent_uuid = (node_data->>'ParentUUID')::uuid; + END IF; + + -- Safeguard against being your own parent. + IF node_uuid = node_parent_uuid THEN + RAISE EXCEPTION 'Node UUID is same as node parent UUID.' USING ERRCODE = 'XPRNT'; + END IF; + + + -- Every jode has a new history UUID to keep the history entry uniquely identifiable + -- across clients. A history entry could potentially be sent again, but should be + -- safe to ignore as every change to a node should have a new history UUID. + -- + -- The current node is also stored as history. + INSERT INTO node_history( + user_id, "uuid", "history_uuid", parents, created, updated, + "name", "content", "content_encrypted", + client + ) + VALUES( + p_user_id, -- combined key + node_uuid, -- combined key + node_history_uuid, -- combined key + (jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors", + COALESCE((node_data->>'Created')::timestamptz, NOW()), + COALESCE((node_data->>'Updated')::timestamptz, NOW()), + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + '', /* content_encrypted */ + p_client_uuid + ) + ON CONFLICT ("user_id", "uuid", "history_uuid") + DO NOTHING; + + + + -- Retrieve the current modified timestamp for this node from the database. + SELECT + uuid, updated, client + INTO + db_uuid, db_updated, db_client + FROM public."node" + WHERE + user_id = p_user_id AND + uuid::uuid = node_uuid::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", "content_encrypted", + client + ) + VALUES( + p_user_id, + node_uuid, + node_parent_uuid, + COALESCE((node_data->>'Created')::timestamptz, NOW()), + COALESCE((node_data->>'Updated')::timestamptz, NOW()), + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + '', /* content_encrypted */ + p_client_uuid + ); + + CONTINUE; + + END IF; + + + + -- Update the public node as well if it was older than incoming node. + IF node_updated > db_updated THEN + UPDATE public."node" + SET + updated = (node_data->>'Updated')::timestamptz, + updated_seq = nextval('node_updates'), + parent_uuid = node_parent_uuid, + name = (node_data->>'Name')::varchar, + content = (node_data->>'Content')::text, + client = p_client_uuid + WHERE + user_id = p_user_id AND + uuid::uuid = node_uuid::uuid; + END IF; + + END LOOP; +END +$procedure$ +; diff --git a/sql/00009.sql b/sql/00009.sql new file mode 100644 index 0000000..50487f3 --- /dev/null +++ b/sql/00009.sql @@ -0,0 +1,35 @@ +-- Special node such as orphaned and deleted nodes. +ALTER TABLE public.node ADD special bool DEFAULT false NOT NULL; + + +-- Needs to be dropped in order to drop the index on UUID. +ALTER TABLE public.node DROP CONSTRAINT node_node_fk; + +-- Index was missing user ID. +DROP INDEX public.node_uuid_idx; +CREATE UNIQUE INDEX node_user_uuid_idx ON public.node (user_id,"uuid"); + +-- Restore the "foreign" key of parent UUID back to UUID. +ALTER TABLE public.node ADD CONSTRAINT node_node_fk FOREIGN KEY (user_id,parent_uuid) REFERENCES public.node(user_id,"uuid") ON DELETE RESTRICT ON UPDATE RESTRICT; + + +-- Auto-create the special nodes for each user. +CREATE OR REPLACE FUNCTION create_user_nodes() +RETURNS TRIGGER AS $$ +BEGIN + -- NEW holds the row being created. + -- No semi-colons omitted here, PL/pgSQL requires them. + INSERT INTO public.node (user_id, uuid, parent_uuid, special, name) + VALUES + (NEW.id, '00000000-0000-0000-0000-000000000000'::uuid, null, true, 'Start'), + (NEW.id, '00000000-0000-0000-0000-000000000001'::uuid, null, true, 'Orphaned nodes'), + (NEW.id, '00000000-0000-0000-0000-000000000002'::uuid, null, true, 'Deleted nodes'); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_after_user_insert +AFTER INSERT ON public.user +FOR EACH ROW +EXECUTE FUNCTION create_user_nodes(); diff --git a/sql/00010.sql b/sql/00010.sql new file mode 100644 index 0000000..ecd8ab4 --- /dev/null +++ b/sql/00010.sql @@ -0,0 +1 @@ +ALTER TABLE public."user" ADD preferences jsonb DEFAULT '{}' NOT NULL; diff --git a/static/css/markdown.css b/static/css/markdown.css index e29ec9e..832d4a2 100644 --- a/static/css/markdown.css +++ b/static/css/markdown.css @@ -1,34 +1,73 @@ .el-node-markdown { padding-top: 16px; - h1 { - border-bottom: 1px solid #ccc; - margin-top: 32px; - margin-bottom: 8px; - - display: inline-block; - font-size: 1.25em; - - border-radius: 8px; - color: #fff; - background-color: var(--color1); - padding: 4px 12px; + .heading-container { + display: grid; + grid-template-columns: min-content 1fr; + grid-gap: 12px; + white-space: nowrap; + align-items: center; + margin-bottom: 16px; &:first-child { - margin-top: 32px; + margin-top: 32px !important; + .line { + display: none !important; + } } + + .line { + border-bottom: 1px solid var(--line-color); + } + + &[data-heading="1"] { + margin-top: 64px; + margin-bottom: 32px; + } + + &[data-heading="2"], + &[data-heading="3"] { + margin-top: 16px; + + .line { + display: none; + } + } + + h1, h2, h3 { + margin: 0; + } + + h1 { + border-bottom: 1px solid #ccc; + + display: inline-block; + font-size: 1.25em; + + clip-path: polygon(0 0, 100% 0, calc(100% - 16px) 100%, 0 100%); + + color: #fff; + background-color: var(--color1); + padding: 4px 24px 4px 16px; + + } + + h2 { + font-size: 1.25em; + color: var(--color1); + } + + h3 { + &:before { + font-size: 1.0em; + content: "> "; + color: var(--color1); + } + } + } - h2 { - font-size: 1.25em; - margin-top: 32px; - margin-bottom: 0px; - color: var(--color1); - } - - h3:before { - font-size: 1.0em; - content: "> "; + a { color: var(--color1); } @@ -44,7 +83,7 @@ table { border: 1px solid #ccc; border-collapse: collapse; - margin-top: 14px; + margin-top: 16px; th { text-align: left; @@ -63,6 +102,11 @@ border: 1px solid #ccc; padding: 2px 4px; border-radius: 4px; + + &.copy { + border: var(--markdown-copy-border); + background-color: var(--markdown-copy-background); + } } pre { @@ -72,6 +116,14 @@ border-radius: 4px; white-space: pre-wrap; + &.copy { + border: var(--markdown-copy-border); + background-color: var(--markdown-copy-background); + code { + background-color: inherit !important; + } + } + code { border: unset; padding: unset; diff --git a/static/css/notes2.css b/static/css/notes2.css index b4d7e02..7fdea0b 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -10,6 +10,14 @@ --line-color: #ccc; --tree-expander: 0px; --functions-width: 150px; + + --menu-color: #fff; + --menu-item-hover-color: #f4f4f4; + + --font-monospace: "Liberation Mono", monospace; + + --markdown-copy-border: 1px solid #0a0; + --markdown-copy-background: #e3f4d7; } html { @@ -20,6 +28,10 @@ html { filter: var(--colorize); } +textarea { + font-family: var(--font-monospace); +} + button { font-size: 1em; padding: 4px 8px; @@ -61,9 +73,10 @@ button { 1fr; } - &.page-history { + /* The other pages just gets the whole page without dividing it up. */ + &:not(.page-node) { grid-template-areas: - "tree-expander tree pad1 n2-pagehistory pad2" + "tree-expander tree pad1 n2-page pad2" ; grid-template-columns: @@ -186,6 +199,11 @@ button { img { width: auto; height: 18px; + + &.deleted { + height: 24px; + transform: translateX(3px) translateY(3px); + } } } @@ -218,10 +236,61 @@ button { } } + +/* =============== * + * PAGE MANAGEMENT * + * =============== */ [id^="page-"] { display: none; } +#notes2 { + &.page-node { + #page-root { + display: none; + } + + #page-node { + display: contents; + } + } + + &.page-storage { + #page-storage { + display: contents; + + n2-pagestorage { + grid-area: n2-page; + } + } + } + + &.page-history { + #page-history { + display: grid; + grid-area: n2-page; + } + } + + &.page-preferences { + #page-preferences { + display: block; + grid-area: n2-page; + } + } + + &.root-node-override { + [id^="page-"] { + display: none !important; + } + + #page-root { + display: contents !important; + } + } + +} + #main-page { display: contents; @@ -229,30 +298,6 @@ button { background-color: #faf; } - &.node { - #page-node { - display: contents; - } - } - - &.storage { - #page-storage { - display: contents; - - n2-pagestorage { - grid-area: content; - } - } - } - - &.history { - #page-history { - display: grid; - grid-area: n2-pagehistory; - - n2-pagehistory {} - } - } } #crumbs { @@ -307,65 +352,66 @@ button { } n2-syncprogress { - --radius: 8px; - display: grid; - grid-area: sync; - display: grid; - justify-items: center; - align-items: center; - - position: relative; + position: fixed; + top: 8px; + right: 8px; + padding: 8px 16px; + z-index: 16384; + border-radius: 6px; + font-weight: bold; + background-color: var(--color1); + color: #fff; + box-shadow: rgba(0, 0, 0, 0.1) 0px 10px 15px -3px, rgba(0, 0, 0, 0.05) 0px 4px 6px -2px; opacity: 0; - transition: height 0s 500ms, opacity 500ms linear, visibility 0s 500ms; + transition: opacity 250ms; &.show { opacity: 1; - transition: visibility, height 0s, opacity 500ms linear; } - progress { - width: 100%; - height: 24px; - border-radius: 8px; + &.ok { + background-color: #5aa02c; } - .count { - position: absolute; - top: 16px; - width: 100%; - white-space: nowrap; - color: #888; - text-align: center; - font-size: 12pt; - font-weight: bold; - } + grid-template-columns: min-content repeat(3, min-content); + grid-gap: 8px 8px; + white-space: nowrap; + align-items: center; + justify-items: end; - progress[value]::-webkit-progress-bar { - background-color: #eee; - box-shadow: 0 2px var(--radius) rgba(0, 0, 0, 0.25) inset; - border-radius: var(--radius); + img { + grid-row: 1/3; + height: 34px; + margin-right: 8px; } +} - progress[value]::-moz-progress-bar { - background-color: #eee; - box-shadow: 0 2px var(--radius) rgba(0, 0, 0, 0.25) inset; - border-radius: var(--radius); +#page-root { + &>div { + grid-area: content; + align-self: start; + margin-top: 64px; + + display: grid; + justify-items: center; + + /* logo */ + img { + margin-bottom: 16px; + height: 32px; + } + + .create { + border: 2px solid #529b00; + padding: 16px 32px; + margin-top: 64px; + background-color: #d9ffc9; + cursor: pointer; + + } } - - progress[value]::-webkit-progress-value { - background: rgb(186, 95, 89); - background: linear-gradient(180deg, rgba(186, 95, 89, 1) 0%, rgba(254, 95, 85, 1) 50%, rgba(186, 95, 89, 1) 100%); - border-radius: var(--radius); - } - - progress[value]::-moz-progress-value { - background: rgb(186, 95, 89); - background: linear-gradient(180deg, rgba(186, 95, 89, 1) 0%, rgba(254, 95, 85, 1) 50%, rgba(186, 95, 89, 1) 100%); - border-radius: var(--radius); - } - } /* ============================================================= */ @@ -393,6 +439,8 @@ n2-nodeui { font-size: 1.75em; margin-top: 8px; margin-bottom: 0px; + white-space: nowrap; + width: min-content; } .el-functions { @@ -406,7 +454,6 @@ n2-nodeui { grid-area: content; justify-self: center; word-wrap: break-word; - font-family: monospace; font-size: 1em; color: #333; @@ -430,6 +477,10 @@ n2-nodeui { grid-area: content; display: none; + font-family: var(--font-monospace); + font-size: 1em; + font-weight: 400; + border-top: 1px solid #e0e0e0; margin-top: 8px; margin-bottom: 32px; diff --git a/static/images/icon_drag.svg b/static/images/icon_drag.svg new file mode 100644 index 0000000..02d628e --- /dev/null +++ b/static/images/icon_drag.svg @@ -0,0 +1,71 @@ + + + +folder-openfolder-open-outlinenotebook-outlinetext-box-outline diff --git a/static/images/icon_drag_ok.svg b/static/images/icon_drag_ok.svg new file mode 100644 index 0000000..94ba949 --- /dev/null +++ b/static/images/icon_drag_ok.svg @@ -0,0 +1,75 @@ + + + +folder-openfolder-open-outlinenotebook-outlinetext-box-outlinetext-box-check-outline diff --git a/static/images/icon_drag_source.svg b/static/images/icon_drag_source.svg new file mode 100644 index 0000000..6378ed9 --- /dev/null +++ b/static/images/icon_drag_source.svg @@ -0,0 +1,49 @@ + + + + + + + + drag-variant + + + diff --git a/static/images/icon_menu.svg b/static/images/icon_menu.svg new file mode 100644 index 0000000..cfdd1e8 --- /dev/null +++ b/static/images/icon_menu.svg @@ -0,0 +1,56 @@ + + + +menuhamburger diff --git a/static/images/icon_new_document.svg b/static/images/icon_new_document.svg new file mode 100644 index 0000000..a105e05 --- /dev/null +++ b/static/images/icon_new_document.svg @@ -0,0 +1,49 @@ + + + + + + + + file-document-plus-outline + + + diff --git a/static/images/icon_transfer.svg b/static/images/icon_transfer.svg new file mode 100644 index 0000000..59c900e --- /dev/null +++ b/static/images/icon_transfer.svg @@ -0,0 +1,49 @@ + + + + + + + + file-arrow-up-down-outline + + + diff --git a/static/images/leaf.svg b/static/images/leaf.svg index 9d200c3..17f4fe2 100644 --- a/static/images/leaf.svg +++ b/static/images/leaf.svg @@ -24,12 +24,12 @@ inkscape:deskcolor="#d1d1d1" inkscape:document-units="px" inkscape:zoom="31.614857" - inkscape:cx="5.0609117" - inkscape:cy="9.5524708" + inkscape:cx="5.0450964" + inkscape:cy="9.5682862" inkscape:window-width="2190" inkscape:window-height="1401" inkscape:window-x="1463" - inkscape:window-y="0" + inkscape:window-y="18" inkscape:window-maximized="1" inkscape:current-layer="layer1" showgrid="false" /> + + +folder-openfolder-open-outlinenotebook-outlinetext-box-outlinedelete-circle diff --git a/static/images/leaf_orphaned.svg b/static/images/leaf_orphaned.svg new file mode 100644 index 0000000..8b1cc37 --- /dev/null +++ b/static/images/leaf_orphaned.svg @@ -0,0 +1,61 @@ + + + +folder-openfolder-open-outlinenotebook-outlinetext-box-outlinedelete-circleghost diff --git a/static/js/api.mjs b/static/js/api.mjs index 3fff10a..26a19de 100644 --- a/static/js/api.mjs +++ b/static/js/api.mjs @@ -1,7 +1,7 @@ export class API { // query resolves into the JSON data produced by the application, or an exception with 'type' and 'error' properties. static async query(method, path, request) { - return new Promise((resolve, reject) => { + try { const body = JSON.stringify(request) const headers = {} @@ -12,33 +12,22 @@ export class API { headers.Authorization = `Bearer ${token}` } - fetch(path, { method, headers, body }) - .then(response => { - // An HTTP communication level error occured. - if (!response.ok || response.status != 200) - return reject({ - type: 'http', - error: response, - }) - return response.json() - }) - .then(json => { - // Application level response are handled here. - if (!json.OK) - return reject({ - type: 'application', - error: json.Error, - application: json, - }) - resolve(json) - }) - .catch(err => - // Catch any other errors from fetch. - reject({ - type: 'http', - error: err, - })) - }) + const res = await fetch(path, { method, headers, body }) + // An HTTP communication level error occured. + if (!res.ok || res.status != 200) + throw new Error('HTTP error', { cause: { type: 'http', error: res, }}) + + // Application level response are handled here. + const json = await res.json() + if (!json.OK) + throw new Error(json.Error, { cause: { type: 'application', application: json, }}) + + return json + + } catch (err) { + // Catch any other errors from fetch. + throw new Error(err.message, { cause: { type: 'http', error: err, }}) + } } static hasAuthenticationToken() {//{{{ diff --git a/static/js/app.mjs b/static/js/app.mjs index 688b476..90bad39 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -2,51 +2,61 @@ import { ROOT_NODE } from 'node_store' import { CustomHTMLElement } from './lib/custom_html_element.mjs' import { N2Sidebar } from 'sidebar' import { Node } from 'node' +import { N2PreferenceSet } from './page_preferences.mjs' export class App { + static PAGES = ['node', 'history', 'storage'] + constructor() {// {{{ this.currentNode = null this.sidebar = new N2Sidebar() this.crumbs = new N2Crumbs() this.crumbsElement = document.getElementById('crumbs') this.nodeUI = document.getElementById('note') + this.dragIcon = new N2DragIcon() + + this.preferences = this.getPreferences() this.sidebar.render().then(sidebar => { document.getElementById('tree').append(sidebar) document.getElementById('tree-nodes')?.focus() }) + // Start node shows a system-wide page instead of node editing + // since the start node is kind of magic and doesn't fit into + // the syncing system. + const determineNodePage = uuid => { + const el = document.getElementById('notes2') + if (uuid == ROOT_NODE) + el.classList.add('root-node-override') + else + el.classList.remove('root-node-override') + } + _mbus.subscribe('TREE_RENDERED', async () => { // Subscribing to the start node existing after the tree trunk is // fetched since the NODE_COMPONENT_EXIST message isn't sent for the // root node itself, and the root node should be selected in the tree // after it is rendered when the site is shown without UUID in the URL. const startNode = await this.getStartNode() - - if (startNode.UUID == ROOT_NODE) - this.goToNode(startNode.UUID, false, false) - else - this.goToNode(startNode.UUID, false, false) + determineNodePage(startNode.UUID) + this.goToNode(startNode.UUID, false, false) }) _mbus.subscribe('TREE_NODE_SELECTED', event => { const node = event.detail.data + determineNodePage(node.UUID) this.goToNode(node.UUID, false, false) }) _mbus.subscribe('GO_TO_NODE', event => { const node = event.detail.data + determineNodePage(node.nodeUUID) this.goToNode(node.nodeUUID, node.dontPush, node.dontExpand) }) _mbus.subscribe('SHOW_PAGE', ({ detail: { data: { page } } }) => { - let classList = document.querySelector('#main-page').classList - classList.forEach(e => - classList.remove(e) - ) - classList.add(page) - - classList = document.querySelector('#notes2').classList + const classList = document.getElementById('notes2').classList classList.forEach(e => { if (e.startsWith('page-')) classList.remove(e) @@ -54,6 +64,11 @@ export class App { classList.add('page-' + page) }) + _mbus.subscribe('DEVICE_PREFERENCE_SET_UPDATED', ()=>{ + this.preferences = this.getPreferences() + console.log(this.preferences.data) + }) + window.addEventListener('keydown', event => this.keyHandler(event)) window.addEventListener('popstate', event => this.popState(event)) document.getElementById('notes2').addEventListener('click', event => { @@ -61,6 +76,9 @@ export class App { document.getElementById('node-content')?.focus() }) + document.querySelector('#page-root .create').addEventListener('click', () => this.createNode()) + document.body.append(this.dragIcon) + _mbus.dispatch('SHOW_PAGE', { page: 'node' }) window._sync = new Sync() @@ -70,65 +88,52 @@ export class App { // There a slight delay to initiate sync seems reasonable. setTimeout(() => window._sync.run(), 1000) }// }}} - keyHandler(event) {//{{{ let handled = true - // All keybindings is Alt+Shift, since the popular browsers at the time (2023) allows to override thees. + // Most keybindings is Alt+Shift, since the popular browsers at the time (2023) allows to override thees. // Ctrl+S is the exception to using Alt+Shift, since it is overridable and in such widespread use for saving. // Thus, the exception is acceptable to consequent use of alt+shift. - if (!(event.shiftKey && event.altKey) && !(event.key.toUpperCase() === 'S' && event.ctrlKey)) - return + const CTRL = !event.shiftKey && event.ctrlKey && !event.altKey + const SHIFT_ALT = event.shiftKey && !event.ctrlKey && event.altKey + const SHIFT_CTRL_ALT = event.shiftKey && event.ctrlKey && event.altKey switch (event.key.toUpperCase()) { + case 'F2': + this.nodeUI.renameNode() + break case 'T': - if (document.activeElement.id === 'tree-nodes') { + if (!SHIFT_ALT) { handled = false; break } + if (document.activeElement.id === 'tree-nodes') this.nodeUI.takeFocus() - } else { + else this.sidebar.focus() - } break case 'F': + if (!SHIFT_ALT) { handled = false; break } _mbus.dispatch('op-search') break - /* - case 'C': - this.showPage('node') - break - - case 'E': - this.showPage('keys') - break - */ case 'M': + if (!SHIFT_ALT) { handled = false; break } globalThis._mbus.dispatch('MARKDOWN_TOGGLE') break case 'N': - this.createNode() + if (SHIFT_ALT) + this.createNode() + else if (SHIFT_CTRL_ALT) { + this.createNode(this.currentNode?.ParentUUID) + } else { + handled = false + } break - /* - case 'P': - this.showPage('node-properties') - break - - */ case 'S': + if (!CTRL) { handled = false; break } this.nodeUI.saveNode() break - /* - - case 'U': - this.showPage('upload') - break - - case 'F': - this.showPage('search') - break - */ default: handled = false @@ -155,17 +160,26 @@ export class App { async saveNode() {//{{{ }//}}} - async createNode() {//{{{ - let name = prompt("Name") + async moveNode(node, targetNodeUUID) {// {{{ + node.moveToParent(targetNodeUUID) + await node.save() + }// }}} + async createNode(createUnderUUID) {//{{{ + const parentUUID = createUnderUUID ? createUnderUUID : this.currentNode.UUID + const p = createUnderUUID ? 'Name for sibling document' : 'Name for sub-document' + + let name = prompt(p) if (!name) return - const nn = Node.create(name, this.currentNode.UUID) - nn.save() - - nodeStore.sendQueue.add(nn) - nodeStore.add([nn]) + const nn = Node.create(name, parentUUID) + await nn.save() + // Treenode is forcefully rerendered and children refetched to both show the new node + // and to get it resorted. + const parentTreenode = this.sidebar.getTreeNode(parentUUID) + await parentTreenode.render(true, true) + _mbus.dispatch('GO_TO_NODE', { nodeUUID: nn.UUID }) }//}}} async goToNode(nodeUUID, dontPush, dontExpand) {//{{{ if (nodeUUID === null || nodeUUID === undefined) @@ -199,11 +213,18 @@ export class App { _mbus.dispatch('NODE_UI_OPEN', node) _mbus.dispatch('TREE_EXPANSION', { expand: false, when: 'narrow' }) _mbus.dispatch('NODE_UNMODIFIED') + _mbus.dispatch('SHOW_PAGE', { page: 'node' }) }//}}} pageIsVisible(page) {// {{{ let classList = document.querySelector('#main-page').classList return classList.contains(page) }// }}} + getPreferences() {// {{{ + const devPrefSet = localStorage.getItem('device_preference_set') || 'default' + const userData = localStorage.getItem('user') || '{"default": {}}' + const user = JSON.parse(userData) + return new N2PreferenceSet(devPrefSet, user.Preferences[devPrefSet]) + }// }}} } class N2Crumbs extends CustomHTMLElement { @@ -237,7 +258,6 @@ class N2Crumbs extends CustomHTMLElement { return this }// }}} } -customElements.define('n2-crumbs', N2Crumbs) class N2Crumb extends CustomHTMLElement { static {// {{{ @@ -268,7 +288,6 @@ class N2Crumb extends CustomHTMLElement { this.elLink.addEventListener('click', () => _mbus.dispatch("GO_TO_NODE", { nodeUUID: this.uuid, dontPush: false, dontExpand: true })) }// }}} } -customElements.define('n2-crumb', N2Crumb) function tmpl(html) {// {{{ const el = document.createElement('template') @@ -342,4 +361,52 @@ class OpSearch extends Op { }// }}} } +class N2DragIcon extends CustomHTMLElement { + static {// {{{ + this.tmpl = document.createElement('template') + this.tmpl.innerHTML = ` + + + ` + }// }}} + constructor() {// {{{ + super(true) + + document.addEventListener('dragover', e => { + this.style.left = `${e.clientX + 8}px` + this.style.top = `${e.clientY}px` + }) + + this.dragSource = null + }// }}} + start() {// {{{ + this.style.display = 'block' + }// }}} + end() {// {{{ + this.style.display = 'none' + }// }}} + icon(name) {// {{{ + if (name != '') + name = '_' + name + this.elIcon.setAttribute('src', `/images/${_VERSION}/icon_drag${name}.svg`) + }// }}} + setSource(s) {// {{{ + this.dragSource = s + }// }}} + getSource() {// {{{ + return this.dragSource + }// }}} +} + +customElements.define('n2-crumbs', N2Crumbs) +customElements.define('n2-crumb', N2Crumb) +customElements.define('n2-dragicon', N2DragIcon) + // vim: foldmethod=marker diff --git a/static/js/lib/custom_html_element.mjs b/static/js/lib/custom_html_element.mjs index 2cec808..d1fb7ae 100644 --- a/static/js/lib/custom_html_element.mjs +++ b/static/js/lib/custom_html_element.mjs @@ -1,7 +1,17 @@ +/* Use data-el or data-field attribute. + * Element with data-el="hum-ding" is accessible as this.elHumDing and fields with + * data-field="long-dong" as this.fieldLongDong. + * + * All field values can be retrieved with fieldValues() and uses the data-field attribute + * as LongDong as key. + */ + export class CustomHTMLElement extends HTMLElement { constructor(useShadow) {// {{{ super() + this._fields = new Map() + const workOn = useShadow ? this.attachShadow({ mode: 'open' }) : this workOn.appendChild(this.constructor.tmpl.content.cloneNode(true)) workOn.querySelectorAll('*').forEach(el => { @@ -9,6 +19,7 @@ export class CustomHTMLElement extends HTMLElement { if (field !== undefined) { const fieldName = this.toElementName('field', field) this[fieldName] = el + this._fields.set(this.toElementName('', field), el) } const name = el.dataset.el @@ -19,39 +30,22 @@ export class CustomHTMLElement extends HTMLElement { } }) }// }}} + allFields() {// {{{ + return this._fields + }// }}} + fieldValues() {// {{{ + const state = {} + for (const [name, field] of this._fields) { + if (field.tagName.toLowerCase() == 'input' && field.getAttribute('type').toLowerCase() == 'checkbox') + state[name] = field.checked + else + state[name] = field.value + + } + return state + }// }}} toElementName(prefix, str) {// {{{ str = prefix + '-' + str return str.replace(/-(id|[a-z])/g, match => match.toUpperCase().replace('-', '')) }// }}} } - -export class StupidPreactCustomHTMLElement extends HTMLElement { - constructor() {// {{{ - super() - - // Stupid stuff because of Preact. - this.clonedNodes = this.constructor.tmpl.content.cloneNode(true) - this.clonedNodes.querySelectorAll('*').forEach(el => { - const field = el.dataset.field - if (field !== undefined) { - const fieldName = this.toElementName('field', field) - this[fieldName] = el - } - - const name = el.dataset.el - if (name !== undefined) { - const elName = this.toElementName('el', name) - this[elName] = el - el.classList.add('el-' + name) - } - }) - }// }}} - toElementName(prefix, str) {// {{{ - str = prefix + '-' + str - return str.replace(/-(id|[a-z])/g, match => match.toUpperCase().replace('-', '')) - }// }}} - connectedCallback() {// {{{ - // Stupid stuff because of Preact. - this.appendChild(this.clonedNodes) - }// }}} -} diff --git a/static/js/marked_position.mjs b/static/js/marked_position.mjs index 62a6996..8c81eb4 100644 --- a/static/js/marked_position.mjs +++ b/static/js/marked_position.mjs @@ -92,7 +92,9 @@ function escapeHtmlEntities(html, encode) {// {{{ export class MarkedPosition { constructor() {// {{{ - window.setpos = (event) => this.setpos(event) + window.marked_setpos = (event) => this.setpos(event) + window.marked_changecheckbox = (event) => this.changecheckbox(event) + window.marked_copy_to_clipboard = (event, tagname) => this.copy_to_clipboard(event, tagname) this.render() }// }}} setpos(event) {// {{{ @@ -106,20 +108,61 @@ export class MarkedPosition { } }) }// }}} + changecheckbox(event) {// {{{ + event.stopPropagation() + event.preventDefault() + + _mbus.dispatch('MARKDOWN_CHANGE_CHECKBOX', { + checkbox: event.target, + position: { + start: event.target.closest('[data-offset-start]').dataset.offsetStart, + end: event.target.closest('[data-offset-start]').dataset.offsetEnd, + } + }) + }// }}} + async copy_to_clipboard(event, tagname) {// {{{ + if (!event.shiftKey) + return + + try { + // Stop text selections on the page to the mouse pointer. + // Old selections are remove as well to give a cleaner view + // of the copied text/highlighting. + event.preventDefault() + event.stopPropagation() + window.getSelection().removeAllRanges() + + const text = event.target.innerText + await navigator.clipboard.writeText(text) + + const tagClasslist = event.target.closest(tagname).classList + tagClasslist.add('copy') + setTimeout(()=>tagClasslist.remove('copy'), 250) + + } catch (err) { + console.error('Failed to copy: ', err) + alert('Failed to copy: ', err) + } + }// }}} + render() {// {{{ - const markedObject = this this.marked = new Marked() this.marked.use(markedTokenPosition()) this.marked.use({ renderer: { heading(token) { const content = this.parser.parseInline(token.tokens) - return `${content}\n` + return ` +
+ ${content}\n +
\n +
+ ` }, paragraph(token) { const content = this.parser.parseInline(token.tokens) - return `

${content}

\n` + return `

${content}

\n` }, list(token) { @@ -138,7 +181,7 @@ export class MarkedPosition { }, listitem(token) { - return `
  • ${this.parser.parse(token.tokens)}
  • \n` + return `
  • ${this.parser.parse(token.tokens)}
  • \n` }, code(token) { @@ -147,12 +190,12 @@ export class MarkedPosition { const code = token.text.replace(other.endingNewline, '') + '\n' if (!langString) { - return `
    `
    +						return `
    `
     							+ (token.escaped ? code : escapeHtmlEntities(code, true))
     							+ '
    \n' } - return `
    '
     						+ (token.escaped ? code : escapeHtmlEntities(code, true))
    @@ -161,7 +204,7 @@ export class MarkedPosition {
     
     				blockquote(token) {
     					const body = this.parser.parse(token.tokens)
    -					return `
    \n${body}
    \n` + return `
    \n${body}
    \n` }, html(token) { @@ -173,13 +216,13 @@ export class MarkedPosition { }, hr(token) { - return `
    \n` + return `
    \n` }, checkbox(token) { - return ` ' + + 'type="checkbox"> ' }, table(token) { @@ -222,7 +265,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 = `ondblclick="setpos(event)" data-offset-start="${start}" data-offset-end="${end}"` + ofs = `ondblclick="marked_setpos(event)" data-offset-start="${start}" data-offset-end="${end}"` } const content = this.parser.parseInline(token.tokens); @@ -234,23 +277,23 @@ export class MarkedPosition { }, strong(token) { - return `${this.parser.parseInline(token.tokens)}` + return `${this.parser.parseInline(token.tokens)}` }, em(token) { - return `${this.parser.parseInline(token.tokens)}` + return `${this.parser.parseInline(token.tokens)}` }, codespan(token) { - return `${escapeHtmlEntities(token.text, true)}` + return `${escapeHtmlEntities(token.text, true)}` }, br(token) { - return `
    ` + return `
    ` }, del(token) { - return `${this.parser.parseInline(token.tokens)}` + return `${this.parser.parseInline(token.tokens)}` }, link(token) { @@ -260,7 +303,7 @@ export class MarkedPosition { return text } token.href = cleanHref - let out = ' { @@ -57,7 +61,7 @@ export class NodeStore { break case 6: - nodesHistory = db.createObjectStore('nodes_history', { keyPath: ['UUID', 'Updated'] }) + nodesHistory = db.createObjectStore('nodes_history', { keyPath: ['UUID', 'HistoryUUID'] }) break case 7: @@ -76,8 +80,7 @@ export class NodeStore { this.sendQueue = new SimpleNodeStore(this.db, 'send_queue') this.nodesHistory = new NodeHistoryStore(this.db, 'nodes_history') this.files = new SimpleNodeStore(this.db, 'files') - this.initializeRootNode() - .then(() => resolve()) + resolve() } req.onerror = (event) => { @@ -85,40 +88,11 @@ export class NodeStore { } }) }//}}} - initializeRootNode() {//{{{ - return new Promise((resolve, reject) => { - // The root node is a magical node which displays as the first node if none is specified. - // If not already existing, it will be created. - const trx = this.db.transaction('nodes', 'readwrite') - const nodes = trx.objectStore('nodes') - const getRequest = nodes.get(ROOT_NODE) - getRequest.onsuccess = (event) => { - // Root node exists - nice! - if (event.target.result !== undefined) { - resolve(event.target.result) - return - } - - const putRequest = nodes.put({ - UUID: ROOT_NODE, - Name: 'Notes2', - Content: 'Hello, World!', - Updated: new Date().toISOString(), - ParentUUID: '', - }) - putRequest.onsuccess = (event) => { - resolve(event.target.result) - } - putRequest.onerror = (event) => { - reject(event.target.error) - } - } - getRequest.onerror = (event) => reject(event.target.error) - }) - }//}}} - purgeCache() {//{{{ - this.nodes = {} - }//}}} + initializeSpecialNodes() {// {{{ + this.nodes[ROOT_NODE] = new Node({ UUID: ROOT_NODE, Name: 'Start', Special: true }, -1) + this.nodes[DELETED_NODE] = new Node({ UUID: DELETED_NODE, Name: 'Deleted nodes', Special: true }, -1) + this.nodes[ORPHANED_NODE] = new Node({ UUID: ORPHANED_NODE, Name: 'Orphaned nodes', Special: true }, -1) + }// }}} node(uuid, dataIfUndefined, newLevel) {//{{{ let n = this.nodes[uuid] @@ -247,6 +221,7 @@ export class NodeStore { nodeStore = t.objectStore('nodes') t.oncomplete = (_event) => { + console.log('complete') resolve() } @@ -271,6 +246,14 @@ export class NodeStore { }//}}} get(uuid, suppliedNodestore) {//{{{ return new Promise((resolve, reject) => { + switch (uuid) { + case ROOT_NODE: + case DELETED_NODE: + case ORPHANED_NODE: + resolve(this.nodes[uuid]) + return + } + // A nodestore can be provided in order to // avoid creating new transactions. let trx @@ -308,6 +291,16 @@ export class NodeStore { return } + if (node.UUID === DELETED_NODE || node.ParentUUID === DELETED_NODE) { + resolve(accumulated) + return + } + + if (node.UUID === ORPHANED_NODE || node.ParentUUID === ORPHANED_NODE) { + resolve(accumulated) + return + } + const getRequest = nodeParentIndex.get(node.ParentUUID) getRequest.onsuccess = (event) => { // Node not found in IndexedDB. @@ -358,6 +351,7 @@ class SimpleNodeStore { // Node to be moved is first stored in the new queue. const req = store.put(node.data) req.onsuccess = () => { + console.log('here') resolve() } req.onerror = (event) => { @@ -473,11 +467,15 @@ class NodeHistoryStore extends SimpleNodeStore { }// }}} retrievePage(uuid, perPage, page) {// {{{ return new Promise((resolve, _reject) => { + + const lowerBound = [uuid, '00000000-0000-0000-0000-000000000000'] + const upperBound = [uuid, 'ffffffff-ffff-ffff-ffff-ffffffffffff'] + const range = IDBKeyRange.bound(lowerBound, upperBound) + const cursor = this.db .transaction(['nodes', this.storeName], 'readonly') .objectStore(this.storeName) - .index('byUUID') - .openCursor(uuid, 'prev') + .openCursor(range, 'prev') let retrieved = 0 let first = true diff --git a/static/js/page_node.mjs b/static/js/page_node.mjs index cca6cf0..2106ada 100644 --- a/static/js/page_node.mjs +++ b/static/js/page_node.mjs @@ -2,21 +2,86 @@ import { ROOT_NODE, uuidv7 } from 'node_store' import { CustomHTMLElement } from './lib/custom_html_element.mjs' import { MarkedPosition } from './marked_position.mjs' +class N2NodeMenu extends CustomHTMLElement { + static {// {{{ + this.tmpl = document.createElement('template') + this.tmpl.innerHTML = ` + +
    + + + + +
    + ` + }// }}} + constructor() {// {{{ + super() + }// }}} +} +customElements.define('n2-nodemenu', N2NodeMenu) + export class N2PageNodeUI extends CustomHTMLElement { static {// {{{ this.tmpl = document.createElement('template') this.tmpl.innerHTML = `
    @@ -27,9 +92,13 @@ export class N2PageNodeUI extends CustomHTMLElement {
    - - + + +
    + ` }// }}} @@ -38,12 +107,14 @@ export class N2PageNodeUI extends CustomHTMLElement { this.node = null this.style.display = 'contents' - this.classList.add('show-markdown') // TODO Should probably be moved to settings. this.marked = new MarkedPosition() _mbus.subscribe('NODE_UI_OPEN', event => { this.node = event.detail.data - this.showMarkdown(true) + + + if (!this.node.isSpecial()) + this.showMarkdown(true) this.render() }) @@ -62,23 +133,29 @@ export class N2PageNodeUI extends CustomHTMLElement { _mbus.subscribe('MARKDOWN_TOGGLE', () => this.showMarkdown(!this.showMarkdown())) _mbus.subscribe('MARKDOWN_EDIT', ({ detail }) => this.editMarkdown(detail.data)) + _mbus.subscribe('MARKDOWN_CHANGE_CHECKBOX', ({ detail }) => this.checkboxUpdated(detail.data)) - this.elName.addEventListener('click', () => { - const name = prompt('Change title', this.node.data.Name) - if (name === null) - return + // Binding the node rename handler. + this.elName.addEventListener('click', async () => this.renameNode()) - try { - this.node.setName(name) - } catch (err) { - console.error(err) - alert(err) - } - }) + // Bind handlers for content keyboard input and paste. this.elNodeContent.addEventListener('input', event => this.contentChanged(event)) this.elNodeContent.addEventListener('paste', async (event) => this.pasteHandler(event)) + + // Bind node icon handlers. + this.elIconSave.addEventListener('click', () => this.saveNode()) this.elIconMarkdown.addEventListener('click', () => this.showMarkdown(!this.showMarkdown())) - this.elIconTableFormat.addEventListener('click', event => { + this.elIconNewDocument.addEventListener('click', event => { + if (event.shiftKey) + _app.createNode(this.node.ParentUUID) + else + _app.createNode() + }) + + // Bind node menu items to handlers. + this.elNodeMenu.elFormatTables.addEventListener('click', event => { + this.elNodeMenu.hidePopover() + if (!event.shiftKey) this.elNodeContent.value = this.formatAllTables(this.elNodeContent.value) else { @@ -92,9 +169,12 @@ export class N2PageNodeUI extends CustomHTMLElement { this.node.setContent(this.elNodeContent.value) }) - this.elIconHistory.addEventListener('click', () => _mbus.dispatch('SHOW_PAGE', { page: 'history' })) - this.elIconSave.addEventListener('click', ()=>this.saveNode()) + this.elNodeMenu.elHistory.addEventListener('click', () => { + _mbus.dispatch('SHOW_PAGE', { page: 'history' }) + }) + // Default is to always show markdown. + this.classList.add('show-markdown') // TODO Should probably be moved to settings. this.showMarkdown(true) }// }}} renderName() {// {{{ @@ -111,35 +191,37 @@ export class N2PageNodeUI extends CustomHTMLElement { } else this.elNodeContent.focus({ preventScroll: true }) }// }}} + async renameNode() {// {{{ + const name = prompt('Change title', this.node.data.Name) + if (name === null) + return + + try { + // Document isn't only renamed, but also saved at once. + // Not really correct, but good enough to not have to implement + // a separate way to only rename the document. Since history is + // preserved it shouldn't be that horrible. + this.node.setName(name) + await this.node.save() + + // Re-render the parent treenode forcefully to sort it again. + const parentUUID = this.node.ParentUUID + if (!parentUUID) + return + const parentTreeNode = _app.sidebar.getTreeNode(parentUUID) + parentTreeNode?.render(true, true) + } catch (err) { + console.error(err) + alert(err) + } + }// }}} async saveNode() {// {{{ if (!this.node.isModified()) return - /* The node history is a local store for node history. - * This could be provisioned from the server or cleared if - * deemed unnecessary. - * - * The send queue is what will be sent back to the server - * to have a recorded history of the notes. - * - * A setting to be implemented in the future could be to - * not save the history locally at all. */ - - // The node is still in its old state and will present - // the unmodified content to the node store. - const history = nodeStore.nodesHistory.add(this.node) - - // Prepares the node object for saving. - // Sets Updated value to current date and time. + // node.save takes care of both "nodes" and "nodes_history" stores, also adds it to send queue. + // Sets "Updated" value to current date and time and generates a new history UUID. await this.node.save() - - // Updated node is added to the send queue to be stored on server. - const sendQueue = nodeStore.sendQueue.add(this.node) - - // Updated node is saved to the primary node store. - const nodeStoreAdding = nodeStore.add([this.node]) - - await Promise.all([history, sendQueue, nodeStoreAdding]) }// }}} contentChanged(event) {//{{{ @@ -289,6 +371,30 @@ export class N2PageNodeUI extends CustomHTMLElement { return lines }// }}} + // "marked" sends a messagebus event when checking/unchecking a checkbox. + // Updates node and content textarea. + checkboxUpdated(eventData) {// {{{ + const checkbox = eventData.checkbox + const pos = eventData.position + const content = this.node.content() + + // Basic validation to verify that Marked does what is known and expected at this writing. + const mdCheckboxStr = content.slice(pos.start, pos.end) + if (!mdCheckboxStr.match(/^\[[ xX]\] $/)) { + alert(`Checkbox string didn't pass validation: '${mdCheckboxStr}'`) + console.error(`Checkbox string didn't pass validation: '${mdCheckboxStr}'`) + } + + // Node is modified with the new value. User has to save manually, otherwise other changes could be saved + // when a save wasn't expected. + const newValue = `[${checkbox.checked ? 'x' : ' '}] ` + const modifiedContent = this.node.content().slice(0, pos.start) + newValue + this.node.content().slice(pos.end) + this.node.setContent(modifiedContent) + + // Also update the textarea since the node model doesn't know about it. + this.elNodeContent.setRangeText(newValue, pos.start, pos.end, 'select') + + }// }}} } customElements.define('n2-nodeui', N2PageNodeUI) @@ -306,15 +412,20 @@ export class Node { return 0 }//}}} static create(name, parentUUID) {// {{{ - return new Node({ + const node = new Node({ UUID: uuidv7(), Created: (new Date()).toISOString(), Content: '', Name: name, ParentUUID: parentUUID, Markdown: false, - History: false, }) + + // Newly created node (not constructed from existing data) is considered modified + // since node.save returns early if it isn't modified. + node._modified = true + + return node }// }}} constructor(nodeData, level) {//{{{ @@ -393,12 +504,23 @@ export class Node { getParent() {//{{{ return this._parent }//}}} + moveToParent(newParentUUID) {// {{{ + if (this.UUID === newParentUUID) + throw new Error("New parent UUID is the same as node UUID. Can't be your own parent.") + + this.ParentUUID = newParentUUID + this.data.ParentUUID = newParentUUID + this._modified = true + }// }}} isLastSibling() {//{{{ return this._sibling_after === null }//}}} isFirstSibling() {//{{{ return this._sibling_before === null }//}}} + isSpecial() {// {{{ + return this.data.Special + }// }}} content() {//{{{ /* TODO - implement crypto if (this.CryptoKeyID != 0 && !this._decrypted) @@ -420,17 +542,52 @@ export class Node { _mbus.dispatch('NODE_MODIFIED', { node: this }) }// }}} async save() {//{{{ + // Just safeguarding not using the root node, + // which sort of exist but isn't supposed to communicate to server. + if (this.UUID == ROOT_NODE) + return + this.data.Content = this._content this.data.Updated = new Date().toISOString() + this.data.HistoryUUID = uuidv7() // every time the node is saved a new history UUID identifies the changed node. this._modified = false _mbus.dispatch('NODE_UNMODIFIED') // When stored into database and ancestry was changed, // the ancestry path could be interesting. + /* const ancestors = await nodeStore.getNodeAncestry(this) this.data.Ancestors = ancestors.map(a => a.get('Name')).reverse() + */ + /* The node history is a local store for node history. + * This could be provisioned from the server or cleared if + * deemed unnecessary. + * + * The send queue is what will be sent back to the server + * to have a recorded history of the notes. + * + * A setting to be implemented in the future could be to + * not save the history locally at all. */ + + // Current node is added to history. It will be duplicated with the "nodes" store + // for simplicity, to hopefully avoid bugs. + const history = nodeStore.nodesHistory.add(this) + + // Updated node is added to the send queue to be stored on server. + + const sendQueue = nodeStore.sendQueue.add(this) + + // Updated node is saved to the primary node store. + const nodeStoreAdding = nodeStore.add([this]) + + console.log('waiting') + await Promise.all([history, sendQueue, nodeStoreAdding]) + console.log('waiting done') + + return }//}}} } + // vim: foldmethod=marker diff --git a/static/js/page_preferences.mjs b/static/js/page_preferences.mjs new file mode 100644 index 0000000..9655278 --- /dev/null +++ b/static/js/page_preferences.mjs @@ -0,0 +1,283 @@ +import { CustomHTMLElement } from "./lib/custom_html_element.mjs" +import { API } from './api.mjs' + +export class N2PagePreferences extends CustomHTMLElement { + static {// {{{ + this.tmpl = document.createElement('template') + this.tmpl.innerHTML = ` + +

    Preferences

    + +
    Changes preferences to not download images or files on the device doesn't remove the already downloaded data.
    + +
    +
    Device preference set
    + +
    + +
    + + + + ` + }// }}} + constructor() {// {{{ + super(true) + this.sets = [] + + this.elNewSet.addEventListener('click', () => this.newSet()) + this.elSave.addEventListener('click', () => this.save()) + this.elDevPreferenceSet.addEventListener('change', event=>this.changePreferenceSet(event)) + + window._mbus.subscribe('SHOW_PAGE', async event => { + if (event.detail.data?.page == 'preferences') { + this.sets = await this.getPreferenceSets() + this.render() + } + }) + + window._mbus.subscribe('PREFERENCE_SET_MODIFIED', () => this.preferencesModified()) + window._mbus.subscribe('PREFERENCE_SET_DELETE', event => this.preferencesDelete(event.detail.data.set)) + }// }}} + sortSets(a, b) {// {{{ + if (a.name == 'default') return -1 + if (b.name == 'default') return 1 + + if (a.name.toLowerCase() < b.name.toLowerCase()) return -1 + if (a.name.toLowerCase() > b.name.toLowerCase()) return 1 + + return 0 + }// }}} + async render() {// {{{ + try { + this.sets.sort(this.sortSets) + this.elSets.replaceChildren(...this.sets) + + const setNames = this.sets.entries().map(([i, set]) => { + const optn = document.createElement('option') + optn.innerText = set.name + return optn + }) + this.elDevPreferenceSet.replaceChildren(...setNames) + } catch (e) { + console.error(e) + alert(e.message) + } + }// }}} + async getPreferenceSets() {// {{{ + const userData = localStorage.getItem('user') + if (userData === null) + throw new Error('Could not find user in localStorage') + + const user = JSON.parse(userData) + const prefsData = user.Preferences + + if (prefsData === undefined) + throw new Error('User object is missing preferences') + + if (!prefsData.hasOwnProperty('default')) + throw new Error('The "default" preferences set is missing') + + return Object.keys(prefsData).map(name => new N2PreferenceSet(name, prefsData[name])) + }// }}} + async retrieveServerPreferences() {// {{{ + try { + API.query('GET', '/user/preferences') + } catch (e) { + console.error(e) + alert(`Error retrieving preferences: ${e.message}`) + } + }// }}} + changePreferenceSet(event) {// {{{ + this.preferencesModified() + }// }}} + newSet() {// {{{ + let name = prompt("Name for new preference set") + if (!name) + return + + name = name.trim() + if (name === '') + return + + if (name == 'default') { + alert(`Name can't be "default".`) + return + } + + const exists = this.sets.some(s => s.name.toLowerCase() == name.toLowerCase()) + if (exists) { + alert(`Set with name "${name}" already exist.`) + return + } + + this.sets.push(new N2PreferenceSet(name, {})) + this.preferencesModified() + this.render() + }// }}} + preferencesModified() {// {{{ + this.elSave.removeAttribute('disabled') + }// }}} + preferencesDelete(deleteSet) {// {{{ + if (deleteSet.name == 'default') { + alert("Can't delete the default set.") + return + } + + if (!confirm(`Confirm deleting "${deleteSet.name}"`)) + return + + this.sets = this.sets.filter(set => { + return !(set.name === deleteSet.name) + }) + + this.preferencesModified() + this.render() + }// }}} + async save() {// {{{ + try { + let newPrefs = {} + this.sets.forEach(s => { + const setState = s.getState() + newPrefs[setState.name] = setState.state + }) + + // Throws exception on both HTTP and application errors. + await API.query('POST', '/user/preferences', newPrefs) + + const userData = localStorage.getItem('user') + const user = JSON.parse(userData) + user.Preferences = newPrefs + localStorage.setItem('user', JSON.stringify(user)) + localStorage.setItem('device_preference_set', this.elDevPreferenceSet.value) + _mbus.dispatch('DEVICE_PREFERENCE_SET_UPDATED') + } catch (e) { + console.error(e) + alert(e.message) + } finally { + this.elSave.setAttribute('disabled', true) + } + + }// }}} +} +customElements.define('n2-pagepreferences', N2PagePreferences) + +// Preferences is a set of preferences, of which there can be many named. +export class N2PreferenceSet extends CustomHTMLElement { + static {// {{{ + this.tmpl = document.createElement('template') + this.tmpl.innerHTML = ` + + +
    +
    +
    +
    + +
    + + +
    + + ` + }// }}} + constructor(name, data) {// {{{ + super(true) + this.name = name + this.data = data + this.render() + + // Enable the save button when settings are modified. + this.allFields().forEach(f => + f.addEventListener('input', () => _mbus.dispatch('PREFERENCE_SET_MODIFIED')) + ) + + this.elName.addEventListener('click', () => this.updateName()) + this.elDelete.addEventListener('click', () => this.deleteSet()) + }// }}} + updateName() {// {{{ + if (this.name == 'default') { + alert('Can not change name of the default profile.') + return + } + + const name = prompt("Change name", this.name) + if (!name) + return + + this.name = name + this.render() + _mbus.dispatch('PREFERENCE_SET_MODIFIED') + }// }}} + deleteSet() {// {{{ + _mbus.dispatch('PREFERENCE_SET_DELETE', { set: this }) + }// }}} + render() {// {{{ + this.elName.innerText = this.name + + this.fieldDownloadImages.checked = this.data.DownloadImages + this.fieldDownloadFiles.checked = this.data.DownloadFiles + }// }}} + getState() {// {{{ + const name = this.name.trim() + if (name === '') + throw new Error('Name can not be empty.') + + return { + name: this.name.trim(), + state: this.fieldValues(), + } + }// }}} +} +customElements.define('n2-preferenceset', N2PreferenceSet) diff --git a/static/js/page_storage.mjs b/static/js/page_storage.mjs index 931a718..a007130 100644 --- a/static/js/page_storage.mjs +++ b/static/js/page_storage.mjs @@ -13,7 +13,10 @@ export class N2PageStorage extends CustomHTMLElement { constructor() { super() - window._mbus.subscribe('SHOW_PAGE', () => this.render()) + window._mbus.subscribe('SHOW_PAGE', event => { + if (event.detail.data?.page == 'storage') + this.render() + }) } async render() { const countNodes = await globalThis.nodeStore.nodeCount() diff --git a/static/js/sidebar.mjs b/static/js/sidebar.mjs index 285fd44..6cd5814 100644 --- a/static/js/sidebar.mjs +++ b/static/js/sidebar.mjs @@ -1,4 +1,5 @@ -import { ROOT_NODE } from 'node_store' +import { ROOT_NODE, ORPHANED_NODE, DELETED_NODE } from 'node_store' +import { Node } from 'node' import { CustomHTMLElement } from './lib/custom_html_element.mjs' import { Color, Solver } from './lib/css_colorize.mjs' @@ -117,7 +118,6 @@ export class N2Sidebar extends CustomHTMLElement { this.tabIndex = 0 this.treeNodeComponents = {} - this.treeTrunk = [] this.expandedNodes = {} // keyed on UUID this.selectedNode = null this.rendered = false @@ -128,6 +128,7 @@ export class N2Sidebar extends CustomHTMLElement { this.elSearch.addEventListener('click', () => _mbus.dispatch('op-search')) this.elSync.addEventListener('click', () => _sync.run()) this.elLogo.addEventListener('click', () => _app.goToNode(ROOT_NODE, false, false)) + this.elSettings.addEventListener('click', ()=> _mbus.dispatch('SHOW_PAGE', { page: 'preferences' })) this.elHideTree.addEventListener('click', event => { event.stopPropagation() _mbus.dispatch('TREE_EXPANSION', { expand: false }) @@ -157,8 +158,26 @@ export class N2Sidebar extends CustomHTMLElement { this.expandedNodes[ROOT_NODE] = true const startnode = await nodeStore.get(ROOT_NODE) const starttreenode = new N2TreeNode(this, startnode, null) + + const deletednode = await nodeStore.get(DELETED_NODE) + const deletedtreenode = new SpecialNodeDeleted(this, deletednode, null) + + const orphanednode = await nodeStore.get(ORPHANED_NODE) + const orphanedtreenode = new SpecialNodeOrphaned(this, orphanednode, null) + + startnode._sibling_after = deletednode + deletednode._sibling_before = startnode + + deletednode._sibling_after = orphanednode + orphanednode._sibling_before = deletednode + this.treeNodeComponents[startnode.UUID] = starttreenode + this.treeNodeComponents[deletednode.UUID] = deletedtreenode + this.treeNodeComponents[orphanednode.UUID] = orphanedtreenode + this.elTreenodes.appendChild(await starttreenode.render()) + this.elTreenodes.appendChild(await deletedtreenode.render()) + this.elTreenodes.appendChild(await orphanedtreenode.render()) // Notify the application that the initial tree is rendered (with children) // and that initial node selection can take place. App will check URL to @@ -170,19 +189,17 @@ export class N2Sidebar extends CustomHTMLElement { }// }}} reset() {// {{{ this.treeNodeComponents = {} - this.treeTrunk = [] this.rendered = false this.elTreenodes.replaceChildren() - this.populateFirstLevel() + this.render() }// }}} getNodeExpanded(UUID) {//{{{ if (this.expandedNodes[UUID] === undefined) this.expandedNodes[UUID] = false return this.expandedNodes[UUID] }//}}} - setNodeExpanded(node, value) {//{{{ + async setNodeExpanded(node, value) {//{{{ let expanded = this.expandedNodes[node.UUID] - if (expanded === undefined) { this.expandedNodes[node.UUID] = false expanded = false @@ -214,6 +231,9 @@ export class N2Sidebar extends CustomHTMLElement { isSelected(node) {//{{{ return this.selectedNode?.UUID === node.UUID }//}}} + getTreeNode(uuid) {// {{{ + return this.treeNodeComponents[uuid] + }// }}} async keyHandler(event) {//{{{ let handled = true @@ -229,8 +249,6 @@ export class N2Sidebar extends CustomHTMLElement { // Holding shift down does it recursively. case Space: case 'Enter': - if (n.UUID === ROOT_NODE) - return const expanded = this.getNodeExpanded(n.UUID) if (event.shiftKey) { this.recursiveExpand(n, !expanded) @@ -239,38 +257,31 @@ export class N2Sidebar extends CustomHTMLElement { } 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 } @@ -280,7 +291,7 @@ export class N2Sidebar extends CustomHTMLElement { } }//}}} async navigateLeft(n) {//{{{ - if (n === null || n === undefined) + if (n === null || n === undefined || n.UUID == ROOT_NODE) return const expanded = this.getNodeExpanded(n.UUID) @@ -330,7 +341,7 @@ export class N2Sidebar extends CustomHTMLElement { _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingAfter()?.UUID, dontPush: false, dontExpand: true }) }//}}} async navigateUp(n) {//{{{ - if (n === null || n === undefined) + if (n === null || n === undefined || n.UUID == ROOT_NODE) return let parent = null @@ -346,7 +357,8 @@ export class N2Sidebar extends CustomHTMLElement { } if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) { - _mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingBefore.Children[siblingBefore.Children.length - 1]?.UUID, dontPush: false, dontExpand: true }) + const nodeVisuallyAbove = this.getLastExpandedNode(siblingBefore) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: nodeVisuallyAbove.UUID, dontPush: false, dontExpand: true }) return } @@ -391,23 +403,26 @@ export class N2Sidebar extends CustomHTMLElement { }//}}} async navigateTop() {//{{{ const root = await nodeStore.get(ROOT_NODE) - if (root.Children.length === 0) - return - _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[0]?.UUID, dontPush: false, dontExpand: true }) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.UUID, dontPush: false, dontExpand: true }) }//}}} async navigateBottom() {//{{{ - const root = await nodeStore.get(ROOT_NODE) - if (root.Children.length === 0) - return + const orphaned = await nodeStore.get(ORPHANED_NODE) - const toplevel = root.Children[root.Children.length - 1] + if (!orphaned.hasChildren() || this.getNodeExpanded(orphaned.UUID)) { + _mbus.dispatch("GO_TO_NODE", { nodeUUID: orphaned.UUID, dontPush: false, dontExpand: true }) + return + } + + /* TODO - fix this when orphaned nodes are implemented. + const toplevel = orphaned.Children[orphaned.Children.length - 1] const toplevelExpanded = this.getNodeExpanded(toplevel?.UUID) if (toplevelExpanded) { const lastnode = this.getLastExpandedNode(toplevel) _mbus.dispatch("GO_TO_NODE", { nodeUUID: lastnode?.UUID, dontPush: false, dontExpand: true }) } else - _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[root.Children.length - 1]?.UUID, dontPush: false, dontExpand: true }) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: orphaned.Children[orphaned.Children.length - 1]?.UUID, dontPush: false, dontExpand: true }) + */ }//}}} getParentWithNextSibling(node) {//{{{ @@ -428,6 +443,10 @@ export class N2Sidebar extends CustomHTMLElement { if (state) await this.setNodeExpanded(node, true) + // An expanded node needs to have its children fetched. + if (!node.hasFetchedChildren()) + await node.fetchChildren() + for (const child of node.Children) await this.recursiveExpand(child, state) @@ -447,15 +466,22 @@ export class N2Sidebar extends CustomHTMLElement { treenode?.scrollIntoView({ block: 'nearest' }) }// }}} } -customElements.define('n2-sidebar', N2Sidebar) export class N2TreeNode extends CustomHTMLElement { + static DRAG_ICON = new Image() + static DRAG_ICON_OK = new Image() + static {// {{{ + N2TreeNode.DRAG_ICON.src = `/images/${_VERSION}/leaf.svg` + N2TreeNode.DRAG_ICON_OK.src = `/images/${_VERSION}/expanded.svg` + this.tmpl = document.createElement('template') this.tmpl.innerHTML = `
    - +
    @@ -488,6 +564,7 @@ export class N2TreeNode extends CustomHTMLElement { constructor(sidebar, node, parent) {//{{{ super() + this.setAttribute('draggable', 'true') this.classList.add('node') this.sidebar = sidebar @@ -496,13 +573,100 @@ export class N2TreeNode extends CustomHTMLElement { this.children_populated = false this.rendered = false + this.dragNode = null - this.elExpandToggle.addEventListener('click', () => this.sidebar.setNodeExpanded(this.node, !this.sidebar.getNodeExpanded(this.node.UUID))) + this.elExpandToggle.addEventListener('click', event => { + if (this.node.hasChildren()) + this.expandNode(event) + else + _mbus.dispatch('TREE_NODE_SELECTED', this.node) + }) this.elName.addEventListener('click', () => _mbus.dispatch('TREE_NODE_SELECTED', this.node)) _mbus.subscribe(`NODE_EXPAND_${node.UUID}`, _state => { this.render(true) }) + + // Drag-and-dropping of nodes + this.addEventListener('dragstart', event => this.dragStart(event)) + this.addEventListener('dragend', event => this.dragEnd(event)) + this.addEventListener('dragover', event => this.dragOver(event)) + this.addEventListener('drop', event => this.dragDrop(event)) + this.elName.addEventListener('dragenter', event => this.dragEnter(event)) + this.elName.addEventListener('dragleave', event => this.dragLeave(event)) + }// }}} + + dragStart(e) {// {{{ + if (this.node.isModified()) { + alert('Save note before moving it.') + e.stopPropagation() + e.preventDefault() + return + } + + this.classList.add('drag-source') + const blankPixel = new Image() + blankPixel.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' + e.dataTransfer.setDragImage(blankPixel, 0, 0) + e.dataTransfer.allowedEffects = 'none' + e.stopPropagation() + _app.dragIcon.setSource(this) + _app.dragIcon.start() + }// }}} + dragEnd(e) {// {{{ + this.classList.remove('drag-source') + _app.dragIcon.end() + e.stopPropagation() + }// }}} + dragOver(e) {// {{{ + e.dataTransfer.dropEffect = 'move' + e.preventDefault() + }// }}} + async dragDrop(e) {// {{{ + try { + e.stopPropagation() + const sourceNode = _app.dragIcon.getSource() + + // Abort if user drops the node back on itself. + if (sourceNode.node.UUID === this.node.UUID) + return + + await _app.moveNode(sourceNode.node, this.node.UUID) + + _app.sidebar.setNodeExpanded(this, true) + await this.render(true, true) + await sourceNode.render(true, true) + } catch (e) { + console.error(e) + alert(e) + } finally { + this.dragLeave(e) + } + }// }}} + dragEnter(e) {// {{{ + const targetNode = e.target.closest('n2-treenode') + if (targetNode.classList.contains('drag-source')) + return + e.stopPropagation() + _app.dragIcon.icon('ok') + this.classList.add('drag-target') + }// }}} + dragLeave(e) {// {{{ + e.stopPropagation() + e.dataTransfer.dropEffect = 'none' + e.dataTransfer.setDragImage(N2TreeNode.DRAG_ICON, -16, 8) + _app.dragIcon.icon('') + this.classList.remove('drag-target') + }// }}} + + async expandNode(event) {// {{{ + const expanded = _app.sidebar.getNodeExpanded(this.node.UUID) + + if (event.shiftKey) { + _app.sidebar.recursiveExpand(this.node, !expanded) + } else { + _app.sidebar.setNodeExpanded(this.node, !expanded) + } }// }}} async fetchChildren(force_fetch) {//{{{ if (this.children_populated && !force_fetch) @@ -515,8 +679,8 @@ export class N2TreeNode extends CustomHTMLElement { if (this.rendered && force_update !== true) return this - if (this.sidebar.getNodeExpanded(this.node.UUID)) - await this.fetchChildren() + if (this.sidebar.getNodeExpanded(this.node.UUID) || force_refetch_children) + await this.fetchChildren(force_refetch_children) // Update the name and selected status. this.elName.querySelector('span').innerText = this.node.get('Name') @@ -539,6 +703,17 @@ export class N2TreeNode extends CustomHTMLElement { // The expand icon is only changed to not get a flickering when re-rendering. if (this.node.UUID === ROOT_NODE) this.setImgSrc(this.elExpand, `/images/${window._VERSION}/icon_home.svg`) + + else if (this.node.UUID === DELETED_NODE) { + this.setImgSrc(this.elExpand, `/images/${window._VERSION}/leaf_deleted.svg`) + this.elExpand.classList.add('deleted') + } + + else if (this.node.UUID === ORPHANED_NODE) { + this.setImgSrc(this.elExpand, `/images/${window._VERSION}/leaf_orphaned.svg`) + this.elExpand.classList.add('deleted') + } + else if (!this.node.hasChildren()) this.setImgSrc(this.elExpand, `/images/${window._VERSION}/leaf.svg`) else if (this.sidebar.getNodeExpanded(this.node.UUID)) @@ -573,6 +748,24 @@ export class N2TreeNode extends CustomHTMLElement { img.setAttribute('src', newSrc) }// }}} } + +class SpecialNodeDeleted extends N2TreeNode { + constructor(sidebar, node, parent) {//{{{ + super(sidebar, node, parent) + this.removeAttribute('draggable') + }//}}} +} + +class SpecialNodeOrphaned extends N2TreeNode { + constructor(sidebar, node, parent) {//{{{ + super(sidebar, node, parent) + this.removeAttribute('draggable') + }//}}} +} + +customElements.define('n2-sidebar', N2Sidebar) customElements.define('n2-treenode', N2TreeNode) +customElements.define('n2-specialnodedeleted', SpecialNodeDeleted) +customElements.define('n2-specialnodeorphaned', SpecialNodeOrphaned) // vim: foldmethod=marker diff --git a/static/js/sync.mjs b/static/js/sync.mjs index 291e0b9..daa603f 100644 --- a/static/js/sync.mjs +++ b/static/js/sync.mjs @@ -17,10 +17,12 @@ export class Sync { const state = await nodeStore.getAppState('latest_sync_node') const oldMax = (state?.value ? state.value : 0) - let nodeCount = await this.getNodeCount(oldMax) - nodeCount += await nodeStore.sendQueue.count() + let nodeCountDownload = await this.getNodeCount(oldMax) + let nodeCountUpload = await nodeStore.sendQueue.count() - _mbus.dispatch('SYNC_COUNT', { count: nodeCount }) + _mbus.dispatch('SYNC_START') + _mbus.dispatch('SYNC_DOWNLOAD_COUNT', { count: nodeCountDownload }) + _mbus.dispatch('SYNC_UPLOAD_COUNT', { count: nodeCountUpload }) await this.nodesFromServer(oldMax) .then(durationNodes => { @@ -28,6 +30,7 @@ export class Sync { console.log(`Total time: ${Math.round(1000 * durationNodes) / 1000}s`) }) + // Uploads of modified nodes to server. await this.nodesToServer() } finally { _mbus.dispatch('SYNC_DONE') @@ -78,15 +81,16 @@ export class Sync { handled++ if (handled % 100 === 0) - _mbus.dispatch('SYNC_HANDLED', { handled }) + _mbus.dispatch('SYNC_DOWNLOADED', { handled }) } } while (res.Continue) - _mbus.dispatch('SYNC_HANDLED', { handled }) + _mbus.dispatch('SYNC_DOWNLOADED', { handled }) nodeStore.setAppState('latest_sync_node', currMax) } catch (e) { - console.log('sync node tree', e) + console.error('sync node tree', e) + alert(e.message) } finally { syncEnd = Date.now() const duration = (syncEnd - syncStart) / 1000 @@ -154,8 +158,8 @@ export class Sync { _mbus.dispatch('SYNC_UPLOADED', { count: nodesToSend.length }) } catch (e) { - console.trace(e) - alert(e) + console.error(e) + alert(e.message) return } } @@ -166,59 +170,80 @@ export class N2SyncProgress extends CustomHTMLElement { static {// {{{ this.tmpl = document.createElement('template') this.tmpl.innerHTML = ` - -
    0 / 0
    + +
    0
    /
    0
    +
    0
    /
    0
    ` }// }}} constructor() {//{{{ super() - this.reset() - _mbus.subscribe('SYNC_COUNT', event => this.progressHandler(event)) - _mbus.subscribe('SYNC_HANDLED', event => this.progressHandler(event)) + _mbus.subscribe('SYNC_START', () => this.reset()) + _mbus.subscribe('SYNC_DOWNLOAD_COUNT', event => this.progressHandler(event)) + _mbus.subscribe('SYNC_UPLOAD_COUNT', event => this.progressHandler(event)) + _mbus.subscribe('SYNC_DOWNLOADED', event => this.progressHandler(event)) + _mbus.subscribe('SYNC_UPLOADED', event => this.progressHandler(event)) _mbus.subscribe('SYNC_DONE', event => this.progressHandler(event)) }//}}} reset() {//{{{ + this.classList.remove('ok') this.state = { - nodesToSync: 0, - nodesSynced: 0, + nodesToDownload: 0, + nodesToUpload: 0, + nodesDowloaded: 0, + nodesUploaded: 0, } + this.render() }//}}} progressHandler(event) {//{{{ const eventData = event.detail.data switch (event.type) { - case 'SYNC_COUNT': - this.state.nodesToSync = eventData.count + case 'SYNC_DOWNLOAD_COUNT': + this.state.nodesToDownload = eventData.count this.setSyncState(true) break - case 'SYNC_HANDLED': - this.state.nodesSynced = eventData.handled + case 'SYNC_UPLOAD_COUNT': + this.state.nodesToUpload = eventData.count + this.setSyncState(true) + break + + case 'SYNC_DOWNLOADED': + this.state.nodesDowloaded = eventData.handled + break + + case 'SYNC_UPLOADED': + this.state.nodesUploaded += eventData.count break case 'SYNC_DONE': + this.classList.add('ok') + // Hides the progress bar. this.setSyncState(false) // Don't update anything if nothing was synced. - if (this.state.nodesSynced === 0) + if (this.state.nodesDowloaded === 0) break // Reload the tree nodes to reflect the new/updated nodes. - window._app.tree.reset() + window._app.sidebar.reset() break } this.render() }//}}} render() {//{{{ - this.elProgress.max = this.state.nodesToSync - this.elProgress.value = this.state.nodesSynced - this.elCount.innerText = `${this.state.nodesSynced} / ${this.state.nodesToSync}` + this.elDownloadTransferred.innerText = this.state.nodesDowloaded + this.elDownloadTotal.innerText = this.state.nodesToDownload + + this.elUploadTransferred.innerText = this.state.nodesUploaded + this.elUploadTotal.innerText = this.state.nodesToUpload }//}}} setSyncState(state) {// {{{ if (state) this.classList.add('show') else + // Give the user a chance to see what it ended on. setTimeout(() => this.classList.remove('show'), 1500) }// }}} } diff --git a/static/service_worker.js b/static/service_worker.js index 0689818..8522b20 100644 --- a/static/service_worker.js +++ b/static/service_worker.js @@ -43,8 +43,8 @@ const CACHED_ASSETS = [ '/js/{{ .VERSION }}/page_history.mjs', '/js/{{ .VERSION }}/page_node.mjs', '/js/{{ .VERSION }}/page_storage.mjs', + '/js/{{ .VERSION }}/sidebar.mjs', '/js/{{ .VERSION }}/sync.mjs', - '/js/{{ .VERSION }}/tree.mjs', ] async function precache() { diff --git a/user.go b/user.go deleted file mode 100644 index b1c2abf..0000000 --- a/user.go +++ /dev/null @@ -1,27 +0,0 @@ -package main - -import ( - // External - "github.com/golang-jwt/jwt/v5" -) - -type UserSession struct { - UserID int - Username string - Password string - Name string - ClientUUID string -} - -func NewUser(claims jwt.MapClaims) (u UserSession) { - uid, _ := claims["uid"].(float64) - name, _ := claims["name"].(string) - username, _ := claims["login"].(string) - clientUUID, _ := claims["cid"].(string) - - u.UserID = int(uid) - u.Username = username - u.Name = name - u.ClientUUID = clientUUID - return -} diff --git a/user/pkg.go b/user/pkg.go new file mode 100644 index 0000000..bcdfac8 --- /dev/null +++ b/user/pkg.go @@ -0,0 +1,63 @@ +package user + +import ( + // External + "github.com/golang-jwt/jwt/v5" + "github.com/jmoiron/sqlx" + + // Standard + "encoding/json" +) + +type User struct { + ID int + Username string + Name string + Preferences map[string]UserPreferences +} + +type UserSession struct { + UserID int + Username string + Password string + Name string + ClientUUID string + Db *sqlx.DB +} + +type UserPreferences struct { + DownloadImages bool + DownloadFiles bool +} + +func NewUser(claims jwt.MapClaims) (u UserSession) { + uid, _ := claims["uid"].(float64) + name, _ := claims["name"].(string) + username, _ := claims["login"].(string) + clientUUID, _ := claims["cid"].(string) + + u.UserID = int(uid) + u.Username = username + u.Name = name + u.ClientUUID = clientUUID + return +} + +func (u UserSession) Preferences() (prefs map[string]UserPreferences, err error) { + row := u.Db.QueryRow(`SELECT preferences FROM public.user WHERE id=$1`, u.UserID) + + var data []byte + err = row.Scan(&data) + if err != nil { + return + } + + err = json.Unmarshal(data, &prefs) + return +} + +func (u UserSession) SetPreferences(prefs map[string]UserPreferences) (err error) { + j, _ := json.Marshal(prefs) + _, err = u.Db.Exec(`UPDATE public.user SET preferences=$2 WHERE id=$1`, u.UserID, j) + return +} diff --git a/views/pages/notes2.gotmpl b/views/pages/notes2.gotmpl index 1142eda..2755aea 100644 --- a/views/pages/notes2.gotmpl +++ b/views/pages/notes2.gotmpl @@ -1,6 +1,12 @@ {{ define "page" }} + + + + + + -
    +
    >
    @@ -10,22 +16,30 @@
    +
    +
    + +
    {{ .VERSION }}
    + +
    Create note
    +
    +
    +
    -
    -
    - -
    -
    - + - - + + + + + +