diff --git a/authentication/pkg.go b/authentication/pkg.go index c0b9a2e..9eb6245 100644 --- a/authentication/pkg.go +++ b/authentication/pkg.go @@ -8,9 +8,6 @@ import ( "github.com/jmoiron/sqlx" "github.com/lib/pq" - // Internal - appUser "notes2/user" - // Standard "database/sql" "encoding/hex" @@ -30,6 +27,12 @@ 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 @@ -162,16 +165,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 appUser.User + User User Token string }{true, user, token}) w.Write(j) } // }}} -func (mngr *Manager) Authenticate(username, password string) (authenticated bool, user appUser.User, err error) { // {{{ +func (mngr *Manager) Authenticate(username, password string) (authenticated bool, user User, err error) { // {{{ var row *sql.Row row = mngr.db.QueryRow(` - SELECT id, username, name, preferences + SELECT id, username, name FROM public.user WHERE LOWER(username) = LOWER($1) AND @@ -180,21 +183,13 @@ func (mngr *Manager) Authenticate(username, password string) (authenticated bool username, password, ) - var data []byte - err = row.Scan(&user.ID, &user.Username, &user.Name, &data) + err = row.Scan(&user.ID, &user.Username, &user.Name) 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 } @@ -283,7 +278,7 @@ func (mngr *Manager) ChangePassword(username, currentPassword, newPassword strin changed = (rowsAffected == 1) return } // }}} -func (mngr *Manager) NewClientUUID(user appUser.User) (clientUUID string, err error) { // {{{ +func (mngr *Manager) NewClientUUID(user 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 6e3cf94..169c3de 100644 --- a/main.go +++ b/main.go @@ -4,7 +4,6 @@ import ( // Internal "notes2/authentication" "notes2/html_template" - appUser "notes2/user" "os" // Standard @@ -24,7 +23,7 @@ import ( "text/template" ) -const VERSION = "v29" +const VERSION = "v10" const CONTEXT_USER = 1 const SYNC_PAGINATION = 200 @@ -135,16 +134,12 @@ 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)) http.HandleFunc("/sync/to_server", authenticated(actionSyncToServer)) http.HandleFunc("/node/retrieve/{uuid}", authenticated(actionNodeRetrieve)) - http.HandleFunc("/node/history/retrieve/{uuid}/{offset}", authenticated(actionNodeHistoryRetrieve)) - http.HandleFunc("/node/history/count/{uuid}", authenticated(actionNodeHistoryCount)) http.HandleFunc("/service_worker.js", pageServiceWorker) @@ -181,7 +176,7 @@ func authenticated(fn func(http.ResponseWriter, *http.Request)) func(http.Respon } // User object is added to the context for the next handler. - user := appUser.NewUser(claims) + user := 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) @@ -269,7 +264,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 := getUserSession(r) + user := getUser(r) changedFrom, _ := strconv.Atoi(r.PathValue("sequence")) offset, _ := strconv.Atoi(r.PathValue("offset")) @@ -280,6 +275,12 @@ 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 @@ -292,7 +293,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 := getUserSession(r) + user := getUser(r) changedFrom, _ := strconv.Atoi(r.PathValue("sequence")) count, err := NodesCount(user.UserID, uint64(changedFrom), user.ClientUUID) @@ -312,7 +313,7 @@ func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{ w.Write(j) } // }}} func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ - user := getUserSession(r) + user := getUser(r) var err error uuid := r.PathValue("uuid") @@ -327,48 +328,8 @@ func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ "Node": node, }) } // }}} -func actionNodeHistoryRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ - user := getUserSession(r) - var err error - - uuid := r.PathValue("uuid") - offset, err := strconv.Atoi(r.PathValue("offset")) - if err != nil { - responseError(w, err) - return - } - - nodes, hasMore, err := RetrieveNodeHistory(user.UserID, uuid, offset) - if err != nil { - responseError(w, err) - return - } - - responseData(w, map[string]any{ - "OK": true, - "Nodes": nodes, - "HasMore": hasMore, - }) -} // }}} -func actionNodeHistoryCount(w http.ResponseWriter, r *http.Request) { // {{{ - user := getUserSession(r) - var err error - - uuid := r.PathValue("uuid") - - count, err := RetrieveNodeHistoryCount(user.UserID, uuid) - if err != nil { - responseError(w, err) - return - } - - responseData(w, map[string]any{ - "OK": true, - "Count": count, - }) -} // }}} func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{ - user := getUserSession(r) + user := getUser(r) body, _ := io.ReadAll(r.Body) var request struct { @@ -380,50 +341,9 @@ func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{ return } - _, 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) + _, err = db.Exec(`CALL add_nodes($1, $2, $3::jsonb)`, user.UserID, user.ClientUUID, request.NodeData) if err != nil { + Log.Error("sync", "error", err) httpError(w, err) return } @@ -475,8 +395,7 @@ func changePassword(username string) { // {{{ fmt.Printf("\nPassword changed\n") } // }}} -func getUserSession(r *http.Request) appUser.UserSession { // {{{ - user, _ := r.Context().Value(CONTEXT_USER).(appUser.UserSession) - user.Db = db +func getUser(r *http.Request) UserSession { // {{{ + user, _ := r.Context().Value(CONTEXT_USER).(UserSession) return user } // }}} diff --git a/node.go b/node.go index a25c771..ffcc89f 100644 --- a/node.go +++ b/node.go @@ -3,8 +3,8 @@ package main import ( // External werr "git.gibonuddevalla.se/go/wrappederror" - "github.com/derektata/lorem/ipsum" "github.com/jmoiron/sqlx" + "github.com/derektata/lorem/ipsum" // Standard "database/sql" @@ -44,7 +44,6 @@ 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 @@ -54,7 +53,11 @@ type Node struct { DeletedSeq sql.NullInt64 `db:"deleted_seq"` Content string ContentEncrypted string `db:"content_encrypted" json:"-"` - Special bool + Markdown bool + + // CryptoKeyID int `db:"crypto_key_id"` + //Files []File + //ChecklistGroups []ChecklistGroup } func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint64, moreRowsExist bool, err error) { // {{{ @@ -75,7 +78,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 @@ -123,7 +126,7 @@ func Nodes(userID, offset int, synced uint64, clientUUID string) (nodes []Node, rows, err = db.Queryx(` SELECT uuid, - COALESCE(parent_uuid, '00000000-0000-0000-0000-000000000000'::uuid) AS parent_uuid, + COALESCE(parent_uuid, '') AS parent_uuid, name, created, updated, @@ -132,14 +135,14 @@ func Nodes(userID, offset int, synced uint64, clientUUID string) (nodes []Node, updated_seq, deleted_seq, content, - content_encrypted + content_encrypted, + markdown FROM public.node WHERE - NOT special AND user_id = $1 AND - client != $5::uuid AND - ( + client != $5 AND + NOT history AND ( created_seq > $4 OR updated_seq > $4 OR deleted_seq > $4 @@ -192,7 +195,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 @@ -245,72 +248,6 @@ func RetrieveNode(userID int, nodeUUID string) (node Node, err error) { // {{{ return } // }}} -func RetrieveNodeHistory(userID int, nodeUUID string, offset int) (nodes []Node, hasMore bool, err error) { // {{{ - nodes = []Node{} - - var rows *sqlx.Rows - rows, err = db.Queryx(` - SELECT - uuid, - history_uuid, - user_id, - name, - created, - updated, - content, - content_encrypted - FROM node_history - WHERE - user_id = $1 AND - uuid = $2 - LIMIT $3 OFFSET $4 - `, - userID, - nodeUUID, - SYNC_PAGINATION+1, - offset, - ) - if err != nil { - err = werr.Wrap(err) - return - } - defer rows.Close() - - for rows.Next() { - node := Node{} - if err = rows.StructScan(&node); err != nil { - err = werr.Wrap(err) - return - } - nodes = append(nodes, node) - } - - if len(nodes) > SYNC_PAGINATION { - hasMore = true - nodes = nodes[0 : len(nodes)-1] - } - return -} // }}} -func RetrieveNodeHistoryCount(userID int, nodeUUID string) (count int, err error) { // {{{ - var row *sql.Row - row = db.QueryRow(` - SELECT - COUNT(*) - FROM node_history - WHERE - user_id = $1 AND - uuid = $2 - `, - userID, - nodeUUID, - ) - if err = row.Scan(&count); err != nil { - err = werr.Wrap(err) - return - } - - return -} // }}} func NodeCrumbs(nodeUUID string) (nodes []Node, err error) { // {{{ var rows *sqlx.Rows rows, err = db.Queryx(` diff --git a/sql/00001.sql b/sql/00001.sql index 4aecc91..7eb8273 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 uuid DEFAULT '00000000-0000-0000-0000-000000000000'::uuid NOT NULL, + client_uuid character(36) DEFAULT ''::bpchar NOT NULL, created timestamp with time zone DEFAULT now() NOT NULL, description character varying DEFAULT ''::character varying NOT NULL ); @@ -302,8 +302,8 @@ CREATE SEQUENCE public.node_updates CREATE TABLE public.node ( id integer NOT NULL, user_id integer NOT NULL, - "uuid" uuid DEFAULT gen_random_uuid() NOT NULL, - parent_uuid uuid, + uuid character(36) DEFAULT gen_random_uuid() NOT NULL, + parent_uuid character(36), created timestamp with time zone DEFAULT now() NOT NULL, updated timestamp with time zone DEFAULT now() NOT NULL, deleted timestamp with time zone, @@ -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 uuid DEFAULT '00000000-0000-0000-0000-000000000000'::uuid NOT NULL, + client character(36) DEFAULT ''::bpchar 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" uuid NOT NULL, + uuid character(36) 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 uuid DEFAULT '00000000-0000-0000-0000-000000000000'::uuid NOT NULL, + client character(36) DEFAULT ''::bpchar NOT NULL, client_sequence integer ); diff --git a/sql/00003.sql b/sql/00003.sql deleted file mode 100644 index a0cd4b1..0000000 --- a/sql/00003.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE public.node_history ADD history_uuid uuid NULL; diff --git a/sql/00004.sql b/sql/00004.sql deleted file mode 100644 index eafbad2..0000000 --- a/sql/00004.sql +++ /dev/null @@ -1,135 +0,0 @@ -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 deleted file mode 100644 index b272085..0000000 --- a/sql/00005.sql +++ /dev/null @@ -1,129 +0,0 @@ --- 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 deleted file mode 100644 index 56f2acb..0000000 --- a/sql/00006.sql +++ /dev/null @@ -1,119 +0,0 @@ -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 deleted file mode 100644 index 0b79d9c..0000000 --- a/sql/00007.sql +++ /dev/null @@ -1,119 +0,0 @@ -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 deleted file mode 100644 index 2701ba5..0000000 --- a/sql/00008.sql +++ /dev/null @@ -1,123 +0,0 @@ -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 deleted file mode 100644 index 50487f3..0000000 --- a/sql/00009.sql +++ /dev/null @@ -1,35 +0,0 @@ --- 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 deleted file mode 100644 index ecd8ab4..0000000 --- a/sql/00010.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE public."user" ADD preferences jsonb DEFAULT '{}' NOT NULL; diff --git a/static/css/markdown.css b/static/css/markdown.css index 832d4a2..631f578 100644 --- a/static/css/markdown.css +++ b/static/css/markdown.css @@ -1,73 +1,34 @@ .el-node-markdown { padding-top: 16px; - .heading-container { - display: grid; - grid-template-columns: min-content 1fr; - grid-gap: 12px; - white-space: nowrap; - align-items: center; - margin-bottom: 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; &:first-child { - margin-top: 32px !important; - .line { - display: none !important; - } + margin-top: 32px; } - - .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); - } - } - } - a { + h2 { + font-size: 1.25em; + margin-top: 32px; + margin-bottom: 0px; + color: var(--color1); + } + + h3:before { + font-size: 1.0em; + content: "> "; color: var(--color1); } @@ -83,7 +44,7 @@ table { border: 1px solid #ccc; border-collapse: collapse; - margin-top: 16px; + margin-top: 14px; th { text-align: left; @@ -102,11 +63,6 @@ border: 1px solid #ccc; padding: 2px 4px; border-radius: 4px; - - &.copy { - border: var(--markdown-copy-border); - background-color: var(--markdown-copy-background); - } } pre { @@ -114,15 +70,6 @@ border: 1px solid #ccc; padding: 8px; 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; diff --git a/static/css/notes2.css b/static/css/notes2.css index 7fdea0b..a2dbc11 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -5,19 +5,13 @@ --thumbnail-width: 300px; --thumbnail-height: 100px; + /* + --colorize: invert(10%) sepia(61%) saturate(5017%) hue-rotate(323deg) brightness(90%) contrast(109%); + */ --colorize: invert(59%) sepia(71%) saturate(3270%) hue-rotate(327deg) brightness(100%) contrast(99%); - --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 { @@ -28,15 +22,6 @@ html { filter: var(--colorize); } -textarea { - font-family: var(--font-monospace); -} - -button { - font-size: 1em; - padding: 4px 8px; -} - /* ------------------------------------- * * Default application grid in wide mode * * ------------------------------------- */ @@ -44,56 +29,31 @@ button { min-height: 100vh; display: grid; + grid-template-areas: + "tree-expander tree pad1 crumbs crumbs pad2" + "tree-expander tree pad1 name functions pad2" + "tree-expander tree pad1 content content pad2" + ; - &.page-node { - grid-template-areas: - "tree-expander tree pad1 crumbs crumbs pad2" - "tree-expander tree pad1 name functions pad2" - "tree-expander tree pad1 content content pad2" - ; - - grid-template-columns: - /* Tree-expander */ - var(--tree-expander) - /* Tree */ - min-content minmax(32px, 1fr) - /* Sync */ - minmax(min-content, calc(var(--content-width) - var(--functions-width))) - /* Functions */ - var(--functions-width) - /* Content */ - minmax(32px, 1fr); - - grid-template-rows: - /* Crumbs */ - min-content - /* Name */ - min-content - /* Content */ - 1fr; - } - - /* The other pages just gets the whole page without dividing it up. */ - &:not(.page-node) { - grid-template-areas: - "tree-expander tree pad1 n2-page pad2" - ; - - grid-template-columns: - /* Tree-expander */ - var(--tree-expander) - /* Tree */ - min-content - /* pad1 */ - 32px - /* Content */ - 1fr - /* pad2 */ - 32px; - - grid-template-rows: 1fr; - } + grid-template-columns: + /* Tree-expander */ + var(--tree-expander) + /* Tree */ + min-content minmax(32px, 1fr) + /* Sync */ + minmax(min-content, calc(var(--content-width) - var(--functions-width))) + /* Functions */ + var(--functions-width) + /* Content */ + minmax(32px, 1fr); + grid-template-rows: + /* Crumbs */ + min-content + /* Name */ + min-content + /* Content */ + 1fr; /* Tree expander is collapsed as default */ --tree-expander: 0px; @@ -105,7 +65,7 @@ button { border-right: none; } - n2-sidebar { + n2-tree { display: none; } @@ -151,7 +111,7 @@ button { color: #333; background-color: #eee; font-weight: bold; - border-right: 1px solid var(--line-color); + border-right: 2px solid #ddd; display: grid; justify-items: center; @@ -167,7 +127,6 @@ button { } } - #tree { grid-area: tree; display: grid; @@ -175,12 +134,42 @@ button { color: #444; z-index: 100; - border-right: 1px solid var(--line-color); + border-right: 2px solid #ddd; - n2-sidebar { - .el-treenodes { - margin: 24px 32px 32px 32px; + #logo { + display: grid; + grid-template-columns: min-content 1fr min-content; + align-items: center; + justify-items: start; + cursor: pointer; + padding: 16px; + border-bottom: 1px solid #ccc; + + .el-search { + justify-self: end; } + + img:first-child { + height: 24px; + margin-right: 8px; + } + } + + .icons { + display: flex; + justify-content: center; + margin: 16px 0px 32px 0px; + gap: 8px; + } + + n2-tree { + .el-treenodes { + margin: 32px; + } + } + + &:focus-within { + n2-tree {} } .node { @@ -199,11 +188,6 @@ button { img { width: auto; height: 18px; - - &.deleted { - height: 24px; - transform: translateX(3px) translateY(3px); - } } } @@ -236,68 +220,38 @@ 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; - &:focus-within { - background-color: #faf; + &.node { + #page-node { + display: contents; + } } + &.storage { + #page-storage { + display: contents; + + n2-pagestorage { + grid-area: content; + } + } + } + + &.history { + #page-history { + display: contents; + + n2-pagehistory { + grid-area: content; + } + } + } } #crumbs { @@ -352,66 +306,65 @@ button { } n2-syncprogress { + --radius: 8px; + display: grid; - 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; + grid-area: sync; + display: grid; + justify-items: center; + align-items: center; + + position: relative; opacity: 0; - transition: opacity 250ms; + transition: height 0s 500ms, opacity 500ms linear, visibility 0s 500ms; &.show { opacity: 1; + transition: visibility, height 0s, opacity 500ms linear; } - &.ok { - background-color: #5aa02c; + progress { + width: 100%; + height: 24px; + border-radius: 8px; } - grid-template-columns: min-content repeat(3, min-content); - grid-gap: 8px 8px; - white-space: nowrap; - align-items: center; - justify-items: end; - - img { - grid-row: 1/3; - height: 34px; - margin-right: 8px; + .count { + position: absolute; + top: 16px; + width: 100%; + white-space: nowrap; + color: #888; + text-align: center; + font-size: 12pt; + font-weight: bold; } -} -#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-bar { + background-color: #eee; + box-shadow: 0 2px var(--radius) rgba(0, 0, 0, 0.25) inset; + border-radius: var(--radius); } + + 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); + } + + 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); + } + } /* ============================================================= */ @@ -439,8 +392,6 @@ n2-nodeui { font-size: 1.75em; margin-top: 8px; margin-bottom: 0px; - white-space: nowrap; - width: min-content; } .el-functions { @@ -454,6 +405,7 @@ n2-nodeui { grid-area: content; justify-self: center; word-wrap: break-word; + font-family: monospace; font-size: 1em; color: #333; @@ -477,10 +429,6 @@ 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; @@ -546,46 +494,3 @@ dialog.op { } } } - - -/* ------------------------------------------- * - * Whole page is 100vh with scrolling sections * - * ------------------------------------------- */ -#app.full-height { - #notes2 { - height: 100vh; - - - } - - #tree { - n2-sidebar { - .el-treenodes { - height: calc(100vh - 64px - 64px); - margin: 0px; - padding: 12px 32px 32px 32px; - overflow-y: auto; - - &::-webkit-scrollbar { - display: none; - } - - -ms-overflow-style: none; - scrollbar-width: none; - } - } - } - - n2-nodeui { - .el-node-markdown { - overflow-y: scroll; - - &::-webkit-scrollbar { - display: none; - } - - -ms-overflow-style: none; - scrollbar-width: none; - } - } -} diff --git a/static/css/page_history.css b/static/css/page_history.css deleted file mode 100644 index bc807df..0000000 --- a/static/css/page_history.css +++ /dev/null @@ -1,210 +0,0 @@ -#page-history { - container-type: inline-size; -} - -/* View when two columns doesn't fit on screen. */ -@container (width < 1100px) { - n2-pagehistory { - grid-template-columns: 1fr minmax(300px, 900px) 1fr !important; - - .column-2 { - grid-column: 2 / 3 !important; - } - } -} - -/* View when not even one column with well on screen */ -/* Node name is placed on a separate row. */ -@container (width < 500px) { - .el-nodes { - grid-template-columns: min-content minmax(min-content, max-content) 1fr !important; - background-color: unset !important; - border: unset !important; - gap: unset !important; - - .el-index { - border-top-left-radius: 6px; - border-left: 1px solid var(--line-color); - } - - .el-index, .el-updated, .el-size { - border-top: 1px solid var(--line-color); - } - - .el-size { - text-align: right; - border-right: 1px solid var(--line-color); - border-top-right-radius: 6px; - padding-right: 8px !important; - } - - .el-name { - grid-column: 1 / -1; - padding-bottom: 8px; - padding-top: 0px; - border-bottom: 1px solid var(--line-color); - border-left: 1px solid var(--line-color); - border-right: 1px solid var(--line-color); - border-bottom-left-radius: 6px; - border-bottom-right-radius: 6px; - margin-bottom: 16px; - } - - n2-pagehistorynode > * { - padding-left: 8px !important; - padding-right: 0px !important; - } - } -} - -n2-pagehistory { - display: grid; - grid-template-rows: min-content min-content min-content; - grid-template-columns: 1fr minmax(600px, 800px) minmax(400px, 900px) 1fr; - grid-gap: 0px 32px; - - .column-1 { - grid-column: 2 / 3; - } - - .column-2 { - grid-column: 3 / 4; - max-width: 900px; - - .group { - background-color: #fff; - } - } - - .back, - .node-name { - grid-column: 2 / 4; - display: grid; - grid-template-columns: min-content 1fr; - grid-gap: 8px; - align-items: center; - } - - .group-label { - font-weight: bold; - background-color: #444; - color: #fff; - padding: 8px 32px; - display: inline-block; - margin-left: 32px; - transform: translateY(14px); - border-radius: 6px; - } - - .group { - border: 1px solid #ccc; - padding: 32px; - margin-bottom: 32px; - border-radius: 8px; - background-color: #fafafa; - - box-shadow: - rgba(0, 0, 0, 0.4) 0px 2px 4px, - rgba(0, 0, 0, 0.3) 0px 7px 13px -3px, - rgba(0, 0, 0, 0.2) 0px -3px 0px inset; - } - - .el-stats { - margin-bottom: 16px; - display: grid; - grid-template-columns: min-content 1fr; - grid-gap: 8px 12px; - white-space: nowrap; - } - - .el-fetch-history-progress { - margin-top: 16px; - } - - .el-back-image, - .el-back-text { - cursor: pointer; - } - - .el-node-name { - margin-left: 8px; - } - - .el-nodes { - grid-column: 1 / -1; - - display: grid; - grid-template-columns: min-content minmax(min-content, max-content) min-content 1fr; - - background-color: var(--line-color); - gap: 1px; - border: 1px solid var(--line-color); - - n2-pagehistorynode>* { - padding: 8px 12px; - background-color: #fff; - white-space: nowrap; - } - - n2-pagehistorynode { - - &.selected .el-index:after { - position: absolute; - left: -20px; - - content: '>'; - color: var(--color1); - font-weight: bold; - margin-right: 8px; - } - - .el-index { - position: relative; - text-align: right; - } - - .el-updated { - white-space: initial; - } - - .el-date { - white-space: nowrap; - font-weight: bold; - } - - .el-time { - white-space: nowrap; - color: #555; - } - - .el-name { - white-space: initial; - /*overflow-wrap: anywhere;*/ - word-break: break-all; - color: var(--color1); - } - } - } - - .el-pagination { - grid-column: 1 / -1; - margin-top: 16px; - - display: grid; - grid-template-columns: repeat(3, min-content); - grid-gap: 16px; - align-items: center; - white-space: nowrap; - user-select: none; - - .el-prev, - .el-next { - font-weight: bold; - cursor: pointer; - border: 1px solid #aaa; - background-color: #eee; - padding: 8px 16px; - border-radius: 4px; - } - } -} diff --git a/static/images/icon_back.svg b/static/images/icon_back.svg deleted file mode 100644 index 504976b..0000000 --- a/static/images/icon_back.svg +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - arrow-left-circle - - - diff --git a/static/images/icon_drag.svg b/static/images/icon_drag.svg deleted file mode 100644 index 02d628e..0000000 --- a/static/images/icon_drag.svg +++ /dev/null @@ -1,71 +0,0 @@ - - - -folder-openfolder-open-outlinenotebook-outlinetext-box-outline diff --git a/static/images/icon_drag_ok.svg b/static/images/icon_drag_ok.svg deleted file mode 100644 index 94ba949..0000000 --- a/static/images/icon_drag_ok.svg +++ /dev/null @@ -1,75 +0,0 @@ - - - -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 deleted file mode 100644 index 6378ed9..0000000 --- a/static/images/icon_drag_source.svg +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - drag-variant - - - diff --git a/static/images/icon_menu.svg b/static/images/icon_menu.svg deleted file mode 100644 index cfdd1e8..0000000 --- a/static/images/icon_menu.svg +++ /dev/null @@ -1,56 +0,0 @@ - - - -menuhamburger diff --git a/static/images/icon_new_document.svg b/static/images/icon_new_document.svg deleted file mode 100644 index a105e05..0000000 --- a/static/images/icon_new_document.svg +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - file-document-plus-outline - - - diff --git a/static/images/icon_transfer.svg b/static/images/icon_transfer.svg deleted file mode 100644 index 59c900e..0000000 --- a/static/images/icon_transfer.svg +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - file-arrow-up-down-outline - - - diff --git a/static/images/leaf.svg b/static/images/leaf.svg index 17f4fe2..306a2a0 100644 --- a/static/images/leaf.svg +++ b/static/images/leaf.svg @@ -8,7 +8,7 @@ version="1.1" id="svg1" sodipodi:docname="leaf.svg" - inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)" + inkscape:version="1.4.2 (ebf0e94, 2025-05-08)" xml:space="preserve" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" @@ -23,12 +23,12 @@ inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:document-units="px" - inkscape:zoom="31.614857" - inkscape:cx="5.0450964" - inkscape:cy="9.5682862" - inkscape:window-width="2190" - inkscape:window-height="1401" - inkscape:window-x="1463" + inkscape:zoom="11.17754" + inkscape:cx="8.0965937" + inkscape:cy="22.903072" + inkscape:window-width="1916" + inkscape:window-height="1161" + inkscape:window-x="0" inkscape:window-y="18" inkscape:window-maximized="1" inkscape:current-layer="layer1" @@ -42,10 +42,6 @@ id="title1-1">folder-open-outlinenotebook-outlinetext-box-outline - - -folder-openfolder-open-outlinenotebook-outlinetext-box-outlinedelete-circle diff --git a/static/images/leaf_orphaned.svg b/static/images/leaf_orphaned.svg deleted file mode 100644 index 8b1cc37..0000000 --- a/static/images/leaf_orphaned.svg +++ /dev/null @@ -1,61 +0,0 @@ - - - -folder-openfolder-open-outlinenotebook-outlinetext-box-outlinedelete-circleghost diff --git a/static/images/logo_small.svg b/static/images/logo_small.svg index 47eabde..cb83d39 100644 --- a/static/images/logo_small.svg +++ b/static/images/logo_small.svg @@ -7,7 +7,7 @@ viewBox="0 0 7.5652731 5.2916666" version="1.1" id="svg1" - inkscape:version="1.4.2 (ebf0e94, 2025-05-08)" + inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)" sodipodi:docname="logo_small.svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" @@ -23,13 +23,13 @@ inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:document-units="px" - inkscape:zoom="8.2386085" - inkscape:cx="48.491198" - inkscape:cy="5.219328" - inkscape:window-width="1916" - inkscape:window-height="1161" - inkscape:window-x="0" - inkscape:window-y="0" + inkscape:zoom="16.477217" + inkscape:cx="48.460855" + inkscape:cy="5.2193281" + inkscape:window-width="2190" + inkscape:window-height="1401" + inkscape:window-x="1463" + inkscape:window-y="18" inkscape:window-maximized="1" inkscape:current-layer="layer1" /> - N2 + y="207.99469">N2 diff --git a/static/js/api.mjs b/static/js/api.mjs index 26a19de..3fff10a 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) { - try { + return new Promise((resolve, reject) => { const body = JSON.stringify(request) const headers = {} @@ -12,22 +12,33 @@ export class API { headers.Authorization = `Bearer ${token}` } - 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, }}) - } + 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, + })) + }) } static hasAuthenticationToken() {//{{{ diff --git a/static/js/app.mjs b/static/js/app.mjs index 90bad39..4978b98 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -1,72 +1,40 @@ import { ROOT_NODE } from 'node_store' import { CustomHTMLElement } from './lib/custom_html_element.mjs' -import { N2Sidebar } from 'sidebar' +import { N2Tree } from 'tree' 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.tree = new N2Tree() 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) + _mbus.subscribe('TREE_TRUNK_FETCHED', async () => { + document.getElementById('tree').append(this.tree.render()) 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() - 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 } } }) => { - const classList = document.getElementById('notes2').classList - classList.forEach(e => { - if (e.startsWith('page-')) - classList.remove(e) - }) - classList.add('page-' + page) - }) - - _mbus.subscribe('DEVICE_PREFERENCE_SET_UPDATED', ()=>{ - this.preferences = this.getPreferences() - console.log(this.preferences.data) + const classList = document.querySelector('#main-page').classList + classList.forEach(e => + classList.remove(e) + ) + classList.add(page) }) window.addEventListener('keydown', event => this.keyHandler(event)) @@ -76,9 +44,6 @@ 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() @@ -88,53 +53,71 @@ export class App { // There a slight delay to initiate sync seems reasonable. setTimeout(() => window._sync.run(), 1000) }// }}} + keyHandler(event) {//{{{ let handled = true - // Most keybindings is Alt+Shift, since the popular browsers at the time (2023) allows to override thees. + // All 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. - 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 + if (!(event.shiftKey && event.altKey) && !(event.key.toUpperCase() === 'S' && event.ctrlKey)) + return switch (event.key.toUpperCase()) { - case 'F2': - this.nodeUI.renameNode() - break case 'T': - if (!SHIFT_ALT) { handled = false; break } - if (document.activeElement.id === 'tree-nodes') + if (document.activeElement.id === 'tree-nodes') { + console.log('take focus') this.nodeUI.takeFocus() - else - this.sidebar.focus() + } else { + this.tree.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': - if (SHIFT_ALT) - this.createNode() - else if (SHIFT_CTRL_ALT) { - this.createNode(this.currentNode?.ParentUUID) - } else { - handled = false - } + this.createNode() break - case 'S': - if (!CTRL) { handled = false; break } - this.nodeUI.saveNode() + /* + case 'P': + this.showPage('node-properties') break + */ + case 'S': + this.saveNode() + /* + else if (this.page.value === 'node-properties') + this.nodeProperties.current.save() + */ + break + /* + + case 'U': + this.showPage('upload') + break + + case 'F': + this.showPage('search') + break + */ + default: handled = false } @@ -158,28 +141,47 @@ export class App { return await nodeStore.get(nodeUUID) }//}}} async saveNode() {//{{{ + if (!this.currentNode.isModified()) + return - }//}}} - async moveNode(node, targetNodeUUID) {// {{{ - node.moveToParent(targetNodeUUID) + /* 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. */ + const node = this.currentNode + + // The node is still in its old state and will present + // the unmodified content to the node store. + const history = nodeStore.nodesHistory.add(node) + + // Prepares the node object for saving. + // Sets Updated value to current date and time. 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) + // Updated node is added to the send queue to be stored on server. + const sendQueue = nodeStore.sendQueue.add(node) + + // Updated node is saved to the primary node store. + const nodeStoreAdding = nodeStore.add([node]) + + await Promise.all([history, sendQueue, nodeStoreAdding]) + }//}}} + async createNode() {//{{{ + let name = prompt("Name") if (!name) return - const nn = Node.create(name, parentUUID) - await nn.save() + const nn = Node.create(name, this.currentNode.UUID) + nn.save() + + nodeStore.sendQueue.add(nn) + nodeStore.add([nn]) - // 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) @@ -198,33 +200,17 @@ export class App { node.reset() // any modifications are discarded. this.currentNode = node - this.sidebar.setSelected(node, dontExpand) + this.tree.setSelected(node, dontExpand) const ancestors = await nodeStore.getNodeAncestry(node) - - // Scrolls node into view. - // makeVisible normally expands all ancestor nodes to make the whole chain visible. - // This is a bad idea when quickly navigating the tree, since the arrow navigation - // has collapsed nodes which the event calling goToNode can come to undo, if the - // event processing lags behind. - await this.sidebar.makeVisible(node, ancestors, dontExpand) - _mbus.dispatch('CRUMBS_SET', ancestors, () => this.crumbsElement.replaceChildren(this.crumbs.render())) _mbus.dispatch('NODE_UI_OPEN', node) - _mbus.dispatch('TREE_EXPANSION', { expand: false, when: 'narrow' }) _mbus.dispatch('NODE_UNMODIFIED') - _mbus.dispatch('SHOW_PAGE', { page: 'node' }) + _mbus.dispatch('TREE_EXPANSION', { expand: false, when: 'narrow' }) + + // Scrolls node into view. + this.tree.makeVisible(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 { @@ -258,6 +244,7 @@ class N2Crumbs extends CustomHTMLElement { return this }// }}} } +customElements.define('n2-crumbs', N2Crumbs) class N2Crumb extends CustomHTMLElement { static {// {{{ @@ -288,6 +275,7 @@ 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') @@ -361,52 +349,4 @@ 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 d1fb7ae..2cec808 100644 --- a/static/js/lib/custom_html_element.mjs +++ b/static/js/lib/custom_html_element.mjs @@ -1,17 +1,7 @@ -/* 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 => { @@ -19,7 +9,6 @@ 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 @@ -30,22 +19,39 @@ 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 8c81eb4..62a6996 100644 --- a/static/js/marked_position.mjs +++ b/static/js/marked_position.mjs @@ -92,9 +92,7 @@ function escapeHtmlEntities(html, encode) {// {{{ export class MarkedPosition { constructor() {// {{{ - 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) + window.setpos = (event) => this.setpos(event) this.render() }// }}} setpos(event) {// {{{ @@ -108,61 +106,20 @@ 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 -
\n -
- ` + return `${content}\n` }, paragraph(token) { const content = this.parser.parseInline(token.tokens) - return `

${content}

\n` + return `

${content}

\n` }, list(token) { @@ -181,7 +138,7 @@ export class MarkedPosition { }, listitem(token) { - return `
  • ${this.parser.parse(token.tokens)}
  • \n` + return `
  • ${this.parser.parse(token.tokens)}
  • \n` }, code(token) { @@ -190,12 +147,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))
    @@ -204,7 +161,7 @@ export class MarkedPosition {
     
     				blockquote(token) {
     					const body = this.parser.parse(token.tokens)
    -					return `
    \n${body}
    \n` + return `
    \n${body}
    \n` }, html(token) { @@ -216,13 +173,13 @@ export class MarkedPosition { }, hr(token) { - return `
    \n` + return `
    \n` }, checkbox(token) { - return ` ' + + 'disabled="" type="checkbox"> ' }, table(token) { @@ -265,7 +222,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="marked_setpos(event)" data-offset-start="${start}" data-offset-end="${end}"` + ofs = `ondblclick="setpos(event)" data-offset-start="${start}" data-offset-end="${end}"` } const content = this.parser.parseInline(token.tokens); @@ -277,23 +234,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) { @@ -303,7 +260,7 @@ export class MarkedPosition { return text } token.href = cleanHref - let out = ' { @@ -61,7 +57,7 @@ export class NodeStore { break case 6: - nodesHistory = db.createObjectStore('nodes_history', { keyPath: ['UUID', 'HistoryUUID'] }) + nodesHistory = db.createObjectStore('nodes_history', { keyPath: ['UUID', 'Updated'] }) break case 7: @@ -78,9 +74,10 @@ export class NodeStore { req.onsuccess = (event) => { this.db = event.target.result this.sendQueue = new SimpleNodeStore(this.db, 'send_queue') - this.nodesHistory = new NodeHistoryStore(this.db, 'nodes_history') + this.nodesHistory = new SimpleNodeStore(this.db, 'nodes_history') this.files = new SimpleNodeStore(this.db, 'files') - resolve() + this.initializeRootNode() + .then(() => resolve()) } req.onerror = (event) => { @@ -88,11 +85,40 @@ export class NodeStore { } }) }//}}} - 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) - }// }}} + 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 = {} + }//}}} node(uuid, dataIfUndefined, newLevel) {//{{{ let n = this.nodes[uuid] @@ -173,7 +199,9 @@ export class NodeStore { } Promise.all(hasChildrenPromises) - .then(() => resolve(nodes)) + .then(() => { + resolve(nodes) + }) } req.onerror = (event) => reject(event.target.error) }) @@ -221,7 +249,6 @@ export class NodeStore { nodeStore = t.objectStore('nodes') t.oncomplete = (_event) => { - console.log('complete') resolve() } @@ -246,14 +273,6 @@ 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 @@ -291,16 +310,6 @@ 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. @@ -351,7 +360,6 @@ 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) => { @@ -429,91 +437,28 @@ class SimpleNodeStore { }//}}} } -class NodeHistoryStore extends SimpleNodeStore { - constructor(db, storeName) {//{{{ - super(db, storeName) - }//}}} - count(uuid) {//{{{ - if (uuid === undefined) - return super.count() +export class StoreFile { + static createFromFileObject(f) { + const obj = new StoreFile() + obj.name = f.name + obj.size = f.size + obj.mime = f.type + return obj + } + constructor() { + this.name = '' + this.size = 0 + this.mime = '' - const index = this.db - .transaction(['nodes', this.storeName], 'readonly') - .objectStore(this.storeName) - .index('byUUID') - - return new Promise((resolve, reject) => { - const request = index.count(uuid) - request.onsuccess = (event) => resolve(event.target.result) - request.onerror = (event) => reject(event.target.error) - }) - }//}}} - hasNode(uuid, updated) {// {{{ - return new Promise((resolve, reject) => { - const req = this.db - .transaction(['nodes', this.storeName], 'readonly') - .objectStore(this.storeName) - .getKey([uuid, updated]) - - req.onsuccess = (event) => { - resolve(event.target.result !== undefined) - } - - req.onerror = (event) => { - console.log(event.target.error) - reject(event.target.error) - } - }) - }// }}} - 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) - .openCursor(range, 'prev') - - let retrieved = 0 - let first = true - const nodes = [] - - cursor.onsuccess = (event) => { - const cursor = event.target.result - if (!cursor) { - resolve(nodes) - return - } - - // openCursor returns the first value which is only useful - // if the first page is requested. - if (page == 1 || !first) { - retrieved++ - nodes.push(new Node(cursor.value)) - if (retrieved === perPage) { - resolve(nodes) - return - } - cursor.continue() - return - } - - // Jump to the start of the requested page. - // Minus one since the first record was already returned. - if (page > 1 && first) { - first = false - cursor.advance((perPage * (page - 1))) - return - } - } - }) - }// }}} + this.objectURL = null // URL.createObjectURL(blob) + } + data() { + return { + } + } } -export function uuidv7() {// {{{ +export function uuidv7() { // random bytes const value = new Uint8Array(16) crypto.getRandomValues(value) @@ -537,6 +482,6 @@ export function uuidv7() {// {{{ .map((b) => b.toString(16).padStart(2, "0")) .join("") return `${str.slice(0, 8)}-${str.slice(8, 12)}-${str.slice(12, 16)}-${str.slice(16, 20)}-${str.slice(20)}` -}// }}} +} // vim: foldmethod=marker diff --git a/static/js/page_history.mjs b/static/js/page_history.mjs index 44061f6..a931faa 100644 --- a/static/js/page_history.mjs +++ b/static/js/page_history.mjs @@ -1,319 +1,15 @@ import { CustomHTMLElement } from './lib/custom_html_element.mjs' -import { Node } from './page_node.mjs' -import { MarkedPosition } from './marked_position.mjs' - export class N2PageHistory extends CustomHTMLElement { - static PAGESIZE = 15 - static {// {{{ + static { this.tmpl = document.createElement('template') this.tmpl.innerHTML = ` - - -
    - -
    Back to node
    -
    - -
    - -

    -
    - -
    -
    Actions
    -
    - -
    -
    - - -
    History
    -
    -
    -
    History on server:
    -
    - -
    History on client:
    -
    -
    - -
    - -
    -
    <
    -
    -
    >
    -
    -
    -
    - -
    -
    Document
    -
    -
    -
    -
    +
    History
    ` - }// }}} + } - constructor() {// {{{ + constructor() { super() - this.selectedNode = null - - this.setAttribute('tabindex', '-1') - this.addEventListener('keydown', event => this.keyHandler(event)) - - // Connect back icon and text to give the user a way back to the node. - this.elBackImage.addEventListener('click', () => _mbus.dispatch('SHOW_PAGE', { page: 'node' })) - this.elBackText.addEventListener('click', () => _mbus.dispatch('SHOW_PAGE', { page: 'node' })) - this.elPrev.addEventListener('click', () => this.prevPage()) - this.elNext.addEventListener('click', () => this.nextPage()) - this.elDownloadHistory.addEventListener('click', async () => { - await this.downloadHistory() - await this.useNode(this.node) - this.render(true) - - }) - - _mbus.subscribe('SHOW_PAGE', async (event) => { - if(event.detail.data.page != 'history') - return - - await this.useNode(_app.nodeUI.node) - this.render() - }) - - _mbus.subscribe('HISTORY_NODE_SELECTED', (event) => { - this.selectedNode = event.detail.data.historyNode - - // Any selected history node is rendered with markdown. - const marked = new MarkedPosition() - this.elNodeMarkdown.innerHTML = marked.parse(this.selectedNode?.node.content()) - }) - }// }}} - async render(keepFetchHistoryProgress) {// {{{ - this.elNodeName.innerText = this.node.get('Name') - this.elPage.innerText = `${this.page} / ${this.pages}` - this.elStatsOnClient.innerText = `${this.nodesTotal}` - this.elStatsOnServer.innerText = `${this.historyOnServerTotal}` - - if (this.nodesTotal <= N2PageHistory.PAGESIZE) - this.elPagination.style.display = 'none' - else - this.elPagination.style.display = '' - - let nodes = await nodeStore.nodesHistory.retrievePage(this.node.UUID, N2PageHistory.PAGESIZE, this.page) - let i = 0 - let divs = nodes.map(n => { - i++ - const index = 1 + this.nodesTotal - (N2PageHistory.PAGESIZE * (this.page - 1) + i) - const div = new N2PageHistoryNode(n, index) - div.render() - return div - }) - this.elNodes.replaceChildren(...divs) - - if (!keepFetchHistoryProgress) - this.elFetchHistoryProgress.innerText = '' - - // Select the first node. - if (!this.selectedNode) { - this.elNodes.firstElementChild?.select() - } - }// }}} - - async useNode(node) {// {{{ - this.node = node - this.page = 1 - - this.nodesTotal = await nodeStore.nodesHistory.count(this.node.UUID) - this.historyOnServerTotal = await this.getServerTotal() - this.pages = Math.ceil(this.nodesTotal / N2PageHistory.PAGESIZE) - }// }}} - keyHandler(event) {// {{{ - let handled = true - switch (event.key) { - case 'ArrowLeft': - this.prevPage() - break - - case 'ArrowRight': - this.nextPage() - break - - case 'ArrowUp': - const prevNode = this.selectedNode?.previousElementSibling - if (prevNode) - prevNode.select() - break - - case 'ArrowDown': - const nextNode = this.selectedNode?.nextElementSibling - if (nextNode) - nextNode.select() - break - - default: - handled = false - } - - if (handled) { - event.stopPropagation() - event.preventDefault() - } - }// }}} - - prevPage() {// {{{ - if (this.page == 1) - return - - // Selecting a node on another page is wrong. - this.selectedNode = null - this.page-- - this.render() - }// }}} - nextPage() {// {{{ - if (this.page >= this.pages) - return - // Selecting a node on another page is wrong. - this.selectedNode = null - this.page++ - this.render() - }// }}} - - async getServerTotal() {// {{{ - const res = await fetch(`/node/history/count/${this.node.UUID}`, { - headers: { - "Authorization": 'Bearer ' + localStorage.getItem('token'), - } - }) - const json = await res.json() - - if (!json.OK) { - alert(json.Error) - return - } - - return json.Count - }// }}} - async downloadHistory() {// {{{ - try { - const nodes = [] - let offset = 0 - let hasMore = true - - while (hasMore) { - const history = await this.downloadHistoryPage(offset) - hasMore = history.HasMore - for (const nodeData of history.Nodes) { - nodes.push(new Node(nodeData)) - } - offset = nodes.length - this.elFetchHistoryProgress.innerText = `${nodes.length} fetched.` - } - - let num = 0 - for (const node of nodes) { - const ok = await nodeStore.nodesHistory.hasNode(node.UUID, node.get('Updated')) - if (ok) num++ - await nodeStore.nodesHistory.add(node) - } - - this.elFetchHistoryProgress.innerText = `${nodes.length} fetched - all history fetched.` - } catch (e) { - console.error(e) - alert(e) - } - }// }}} - async downloadHistoryPage(offset) {// {{{ - const res = await fetch(`/node/history/retrieve/${this.node.UUID}/${offset}`, { - headers: { - "Authorization": 'Bearer ' + localStorage.getItem('token'), - } - }) - const json = await res.json() - - if (!json.OK) { - alert(json.Error) - return - } - - return json - }// }}} + } } customElements.define('n2-pagehistory', N2PageHistory) - - -class N2PageHistoryNode extends CustomHTMLElement { - static {// {{{ - this.tmpl = document.createElement('template') - this.tmpl.innerHTML = ` -
    -
    -
    -
    - ` - }// }}} - constructor(node, index) {// {{{ - super() - - this.node = node - this.index = index - - this.style.display = 'contents' - this.selected = false - - this.addEventListener('click', () => this.select()) - - // Another history node has been selected. - _mbus.subscribe('HISTORY_NODE_SELECTED', (event) => { - if (this.node.get('Updated') == event.detail.data.historyNode.node.get('Updated')) - return - this.selected = false - this.render() - }) - }// }}} - - select() {// {{{ - this.selected = true - // Other nodes are told to unselect and rerender. - _mbus.dispatch('HISTORY_NODE_SELECTED', { historyNode: this }) - this.render() - }// }}} - render() {// {{{ - const date = this.node.get('Updated').slice(0, 10) - const time = this.node.get('Updated').slice(11, 19) - - if (this.selected) - this.classList.add('selected') - else - this.classList.remove('selected') - - this.elIndex.innerText = this.index - this.elDate.innerText = date - this.elTime.innerText = time - this.elSize.innerText = this.formatSize(this.node.get('Content').length) - this.elName.innerText = this.node.get('Name') - }// }}} - formatSize(s) {// {{{ - let div = 1 - let unit = 'B' - if (s >= 1048576) { - div = 1048576 - unit = 'MB' - } else if (s >= 1024) { - div = 1024 - unit = 'kB' - } - - return new Intl.NumberFormat(undefined, { - maximumFractionDigits: 0 - }).format(Math.round(s / div)) + ' ' + unit - }// }}} -} -customElements.define('n2-pagehistorynode', N2PageHistoryNode) diff --git a/static/js/page_node.mjs b/static/js/page_node.mjs index 2106ada..81d7c33 100644 --- a/static/js/page_node.mjs +++ b/static/js/page_node.mjs @@ -2,86 +2,21 @@ 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 = `
    @@ -92,13 +27,9 @@ export class N2PageNodeUI extends CustomHTMLElement {
    - - - + +
    - ` }// }}} @@ -107,14 +38,12 @@ 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 - - - if (!this.node.isSpecial()) - this.showMarkdown(true) + this.showMarkdown(true) this.render() }) @@ -133,29 +62,23 @@ 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)) - // Binding the node rename handler. - this.elName.addEventListener('click', async () => this.renameNode()) + this.elName.addEventListener('click', () => { + const name = prompt('Change title', this.node.data.Name) + if (name === null) + return - // Bind handlers for content keyboard input and paste. + try { + this.node.setName(name) + } catch (err) { + console.error(err) + alert(err) + } + }) 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.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() - + this.elIconTableFormat.addEventListener('click', event => { if (!event.shiftKey) this.elNodeContent.value = this.formatAllTables(this.elNodeContent.value) else { @@ -169,12 +92,8 @@ export class N2PageNodeUI extends CustomHTMLElement { this.node.setContent(this.elNodeContent.value) }) - this.elNodeMenu.elHistory.addEventListener('click', () => { - _mbus.dispatch('SHOW_PAGE', { page: 'history' }) - }) + this.elIconHistory.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() {// {{{ @@ -187,41 +106,9 @@ export class N2PageNodeUI extends CustomHTMLElement { }// }}} takeFocus() {// {{{ if (this.showMarkdown()) { - this.elNodeMarkdown.focus({ preventScroll: true }) + this.elNodeMarkdown.focus() } 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 - - // 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() + this.elNodeContent.focus() }// }}} contentChanged(event) {//{{{ @@ -371,30 +258,6 @@ 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) @@ -412,20 +275,15 @@ export class Node { return 0 }//}}} static create(name, parentUUID) {// {{{ - const node = new Node({ + return 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) {//{{{ @@ -487,6 +345,10 @@ export class Node { this.Children[i]._parent = this } + // Notify the tree that all children are fetched and ready to process. + //_notes2.current.tree.fetchChildrenOn(this.UUID) + _mbus.dispatch(`NODE_CHILDREN_FETCHED_${this.UUID}`) + return this.Children }//}}} setHasChildren(v) {// {{{ @@ -504,23 +366,12 @@ 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) @@ -542,52 +393,17 @@ 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 deleted file mode 100644 index 9655278..0000000 --- a/static/js/page_preferences.mjs +++ /dev/null @@ -1,283 +0,0 @@ -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 a007130..931a718 100644 --- a/static/js/page_storage.mjs +++ b/static/js/page_storage.mjs @@ -13,10 +13,7 @@ export class N2PageStorage extends CustomHTMLElement { constructor() { super() - window._mbus.subscribe('SHOW_PAGE', event => { - if (event.detail.data?.page == 'storage') - this.render() - }) + window._mbus.subscribe('SHOW_PAGE', () => this.render()) } async render() { const countNodes = await globalThis.nodeStore.nodeCount() diff --git a/static/js/sync.mjs b/static/js/sync.mjs index daa603f..291e0b9 100644 --- a/static/js/sync.mjs +++ b/static/js/sync.mjs @@ -17,12 +17,10 @@ export class Sync { const state = await nodeStore.getAppState('latest_sync_node') const oldMax = (state?.value ? state.value : 0) - let nodeCountDownload = await this.getNodeCount(oldMax) - let nodeCountUpload = await nodeStore.sendQueue.count() + let nodeCount = await this.getNodeCount(oldMax) + nodeCount += await nodeStore.sendQueue.count() - _mbus.dispatch('SYNC_START') - _mbus.dispatch('SYNC_DOWNLOAD_COUNT', { count: nodeCountDownload }) - _mbus.dispatch('SYNC_UPLOAD_COUNT', { count: nodeCountUpload }) + _mbus.dispatch('SYNC_COUNT', { count: nodeCount }) await this.nodesFromServer(oldMax) .then(durationNodes => { @@ -30,7 +28,6 @@ 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') @@ -81,16 +78,15 @@ export class Sync { handled++ if (handled % 100 === 0) - _mbus.dispatch('SYNC_DOWNLOADED', { handled }) + _mbus.dispatch('SYNC_HANDLED', { handled }) } } while (res.Continue) - _mbus.dispatch('SYNC_DOWNLOADED', { handled }) + _mbus.dispatch('SYNC_HANDLED', { handled }) nodeStore.setAppState('latest_sync_node', currMax) } catch (e) { - console.error('sync node tree', e) - alert(e.message) + console.log('sync node tree', e) } finally { syncEnd = Date.now() const duration = (syncEnd - syncStart) / 1000 @@ -158,8 +154,8 @@ export class Sync { _mbus.dispatch('SYNC_UPLOADED', { count: nodesToSend.length }) } catch (e) { - console.error(e) - alert(e.message) + console.trace(e) + alert(e) return } } @@ -170,80 +166,59 @@ 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_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_COUNT', event => this.progressHandler(event)) + _mbus.subscribe('SYNC_HANDLED', event => this.progressHandler(event)) _mbus.subscribe('SYNC_DONE', event => this.progressHandler(event)) }//}}} reset() {//{{{ - this.classList.remove('ok') this.state = { - nodesToDownload: 0, - nodesToUpload: 0, - nodesDowloaded: 0, - nodesUploaded: 0, + nodesToSync: 0, + nodesSynced: 0, } - this.render() }//}}} progressHandler(event) {//{{{ const eventData = event.detail.data switch (event.type) { - case 'SYNC_DOWNLOAD_COUNT': - this.state.nodesToDownload = eventData.count + case 'SYNC_COUNT': + this.state.nodesToSync = eventData.count this.setSyncState(true) break - 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 + case 'SYNC_HANDLED': + this.state.nodesSynced = eventData.handled 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.nodesDowloaded === 0) + if (this.state.nodesSynced === 0) break // Reload the tree nodes to reflect the new/updated nodes. - window._app.sidebar.reset() + window._app.tree.reset() break } this.render() }//}}} render() {//{{{ - this.elDownloadTransferred.innerText = this.state.nodesDowloaded - this.elDownloadTotal.innerText = this.state.nodesToDownload - - this.elUploadTransferred.innerText = this.state.nodesUploaded - this.elUploadTotal.innerText = this.state.nodesToUpload + this.elProgress.max = this.state.nodesToSync + this.elProgress.value = this.state.nodesSynced + this.elCount.innerText = `${this.state.nodesSynced} / ${this.state.nodesToSync}` }//}}} 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/js/sidebar.mjs b/static/js/tree.mjs similarity index 55% rename from static/js/sidebar.mjs rename to static/js/tree.mjs index 6cd5814..04555f6 100644 --- a/static/js/sidebar.mjs +++ b/static/js/tree.mjs @@ -1,5 +1,4 @@ -import { ROOT_NODE, ORPHANED_NODE, DELETED_NODE } from 'node_store' -import { Node } from 'node' +import { ROOT_NODE } from 'node_store' import { CustomHTMLElement } from './lib/custom_html_element.mjs' import { Color, Solver } from './lib/css_colorize.mjs' @@ -59,46 +58,21 @@ class TreeExpansionHandler {// {{{ } }// }}} -export class N2Sidebar extends CustomHTMLElement { +export class N2Tree extends CustomHTMLElement { static {// {{{ this.tmpl = document.createElement('template') this.tmpl.innerHTML = ` @@ -118,6 +92,7 @@ export class N2Sidebar extends CustomHTMLElement { this.tabIndex = 0 this.treeNodeComponents = {} + this.treeTrunk = [] this.expandedNodes = {} // keyed on UUID this.selectedNode = null this.rendered = false @@ -128,7 +103,6 @@ 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 }) @@ -145,61 +119,64 @@ export class N2Sidebar extends CustomHTMLElement { treenode.render(true) }) + this.populateFirstLevel() + /* XXX - set color */ let color = new Color(0x80, 0x00, 0x33) let solver = new Solver(color) let result = solver.solve() - console.log(result.filter) + // console.log(result.filter) }// }}} - async render() {// {{{ + render() {// {{{ if (this.rendered) alert('Tree should only be rendered once.') - 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 - // select the correct one. - _mbus.dispatch('TREE_RENDERED') + for (const node of this.treeTrunk) { + const treenode = new N2TreeNode(this, node) + this.treeNodeComponents[node.UUID] = treenode + this.elTreenodes.appendChild(treenode.render()) + } this.rendered = true return this }// }}} reset() {// {{{ + console.log('tree reset') this.treeNodeComponents = {} + this.treeTrunk = [] this.rendered = false this.elTreenodes.replaceChildren() - this.render() + this.populateFirstLevel() }// }}} + populateFirstLevel() {//{{{ + nodeStore.get(ROOT_NODE) + .then(node => node.fetchChildren()) + .then(children => { + this.treeNodeComponents = {} + this.treeTrunk = [] + for (const node of children) { + // The root node isn't supposed to be shown in the tree. + if (node.UUID === ROOT_NODE) + continue + if (node.ParentUUID === ROOT_NODE) + this.treeTrunk.push(node) + } + _mbus.dispatch('TREE_TRUNK_FETCHED') + }) + .catch(e => { + console.error(e) + console.log(e.type, e.error) + alert(e.error) + }) + }//}}} getNodeExpanded(UUID) {//{{{ if (this.expandedNodes[UUID] === undefined) this.expandedNodes[UUID] = false return this.expandedNodes[UUID] }//}}} - async setNodeExpanded(node, value) {//{{{ + setNodeExpanded(node, value) {//{{{ let expanded = this.expandedNodes[node.UUID] + if (expanded === undefined) { this.expandedNodes[node.UUID] = false expanded = false @@ -231,9 +208,6 @@ 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 @@ -257,31 +231,38 @@ 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 } @@ -291,16 +272,16 @@ export class N2Sidebar extends CustomHTMLElement { } }//}}} async navigateLeft(n) {//{{{ - if (n === null || n === undefined || n.UUID == ROOT_NODE) + if (n === null || n === undefined) return const expanded = this.getNodeExpanded(n.UUID) - if (expanded && n.hasChildren() && n.UUID !== ROOT_NODE) { + if (expanded && n.hasChildren()) { this.setNodeExpanded(n, false) return } - if (n.isFirstSibling()) { + if (n.isFirstSibling() && n.getParent().UUID !== ROOT_NODE) { _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getParent()?.UUID, dontPush: false, dontExpand: true }) return } @@ -341,7 +322,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 || n.UUID == ROOT_NODE) + if (n === null || n === undefined) return let parent = null @@ -352,13 +333,14 @@ export class N2Sidebar extends CustomHTMLElement { if (n.isFirstSibling()) { parent = n.getParent() + if (parent?.UUID === ROOT_NODE) + return _mbus.dispatch("GO_TO_NODE", { nodeUUID: parent?.UUID, dontPush: false, dontExpand: true }) return } if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) { - const nodeVisuallyAbove = this.getLastExpandedNode(siblingBefore) - _mbus.dispatch("GO_TO_NODE", { nodeUUID: nodeVisuallyAbove.UUID, dontPush: false, dontExpand: true }) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingBefore.Children[siblingBefore.Children.length - 1]?.UUID, dontPush: false, dontExpand: true }) return } @@ -403,26 +385,23 @@ export class N2Sidebar extends CustomHTMLElement { }//}}} async navigateTop() {//{{{ const root = await nodeStore.get(ROOT_NODE) - _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.UUID, dontPush: false, dontExpand: true }) + if (root.Children.length === 0) + return + _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[0]?.UUID, dontPush: false, dontExpand: true }) }//}}} async navigateBottom() {//{{{ - const orphaned = await nodeStore.get(ORPHANED_NODE) - - if (!orphaned.hasChildren() || this.getNodeExpanded(orphaned.UUID)) { - _mbus.dispatch("GO_TO_NODE", { nodeUUID: orphaned.UUID, dontPush: false, dontExpand: true }) + const root = await nodeStore.get(ROOT_NODE) + if (root.Children.length === 0) return - } - /* TODO - fix this when orphaned nodes are implemented. - const toplevel = orphaned.Children[orphaned.Children.length - 1] + const toplevel = root.Children[root.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: orphaned.Children[orphaned.Children.length - 1]?.UUID, dontPush: false, dontExpand: true }) - */ + _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[root.Children.length - 1]?.UUID, dontPush: false, dontExpand: true }) }//}}} getParentWithNextSibling(node) {//{{{ @@ -443,255 +422,86 @@ 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) if (!state) await this.setNodeExpanded(node, false) }//}}} - async makeVisible(node, providedAncestors, dontExpand) {// {{{ + async makeVisible(node) {// {{{ const treenode = this.treeNodeComponents[node.UUID] - if (!dontExpand) { - const ancestors = providedAncestors || await nodeStore.getNodeAncestry(node) - for (const ancestor of ancestors.reverse()) { - this.setNodeExpanded(ancestor, true) - } + const ancestors = await nodeStore.getNodeAncestry(node) + for (const ancestor of ancestors.reverse()) { + this.setNodeExpanded(ancestor, true) } + // The ROOT_NODE for example hasn't got a treenode. treenode?.scrollIntoView({ block: 'nearest' }) }// }}} } +customElements.define('n2-tree', N2Tree) 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 = ` - -
    - +
    -
    +
    ` }// }}} - constructor(sidebar, node, parent) {//{{{ + constructor(tree, node, parent) {//{{{ super() - this.setAttribute('draggable', 'true') this.classList.add('node') - this.sidebar = sidebar + this.tree = tree this.node = node this.parent = parent this.children_populated = false this.rendered = false - this.dragNode = null - this.elExpandToggle.addEventListener('click', event => { - if (this.node.hasChildren()) - this.expandNode(event) - else - _mbus.dispatch('TREE_NODE_SELECTED', this.node) - }) + this.elExpandToggle.addEventListener('click', () => this.tree.setNodeExpanded(this.node, !this.tree.getNodeExpanded(this.node.UUID))) this.elName.addEventListener('click', () => _mbus.dispatch('TREE_NODE_SELECTED', this.node)) + _mbus.subscribe(`NODE_CHILDREN_FETCHED_${node.UUID}`, () => { + this.render(true) + }) + _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)) + if (this.node.Level === 0 || this.tree.getNodeExpanded(this.node.UUID)) + this.fetchChildren() }// }}} - - 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) - return - + async fetchChildren() {//{{{ await this.node.fetchChildren() this.children_populated = true }//}}} - async render(force_update, force_refetch_children) {//{{{ + render(force_update) {//{{{ if (this.rendered && force_update !== true) return this - if (this.sidebar.getNodeExpanded(this.node.UUID) || force_refetch_children) - await this.fetchChildren(force_refetch_children) + // Fetch the next level of children if the parent tree node is expanded and our children thus will be visible. + const expanded = this.node.hasChildren() && this.tree.getNodeExpanded(this.node.UUID) - // Update the name and selected status. - this.elName.querySelector('span').innerText = this.node.get('Name') + if (!this.children_populated && this.tree.getNodeExpanded(this.parent?.node.UUID)) { + this.node.fetchChildren().then(() => this.children_populated = true) + } - if (this.sidebar.isSelected(this.node)) + // Update the name and selected status + this.elName.innerText = this.node.get('Name') + if (this.tree.isSelected(this.node)) this.elName.classList.add('selected') else this.elName.classList.remove('selected') // Update expansion state - const expanded = this.node.hasChildren() && this.sidebar.getNodeExpanded(this.node.UUID) if (expanded) { this.elChildren.classList.add('expanded') this.elChildren.classList.remove('collapsed') @@ -701,42 +511,28 @@ 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()) + if (!this.node.hasChildren()) this.setImgSrc(this.elExpand, `/images/${window._VERSION}/leaf.svg`) - else if (this.sidebar.getNodeExpanded(this.node.UUID)) + else if (this.tree.getNodeExpanded(this.node.UUID)) this.setImgSrc(this.elExpand, `/images/${window._VERSION}/expanded.svg`) else this.setImgSrc(this.elExpand, `/images/${window._VERSION}/collapsed.svg`) // Should children be rendered? + this.elChildren.innerHTML = '' let children = [] if (expanded) children = this.node.Children.map(node => { - let treenode = this.sidebar.treeNodeComponents[node.UUID] + let treenode = this.tree.treeNodeComponents[node.UUID] if (treenode === undefined) { - treenode = new N2TreeNode(this.sidebar, node, this) - this.sidebar.treeNodeComponents[node.UUID] = treenode + treenode = new N2TreeNode(this.tree, node, this) + this.tree.treeNodeComponents[node.UUID] = treenode } return treenode }) - const renderedChildren = [] for (const c of children) - renderedChildren.push(await c.render()) - this.elChildren.replaceChildren(...renderedChildren) + this.elChildren.appendChild(c.render()) this.rendered = true return this @@ -748,24 +544,6 @@ 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/service_worker.js b/static/service_worker.js index 8522b20..b6a1a13 100644 --- a/static/service_worker.js +++ b/static/service_worker.js @@ -6,7 +6,6 @@ const CACHED_ASSETS = [ '/css/{{ .VERSION }}/main.css', '/css/{{ .VERSION }}/markdown.css', '/css/{{ .VERSION }}/notes2.css', - '/css/{{ .VERSION }}/page_history.css', '/css/{{ .VERSION }}/theme.css', '/images/{{ .VERSION }}/collapsed.svg', @@ -43,8 +42,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() { @@ -120,13 +119,9 @@ self.addEventListener('activate', event => { }) self.addEventListener('fetch', event => { - // The fetch event is also seeing requests to other domains. - // Just let the browser handle those for itself. - const ourDomain = event.request.url.startsWith(self.location.origin) - if (!ourDomain) - return event + // console.debug('SERVICE WORKER: fetch', event.request.url) - if (`{{ .DevMode }}` == 'true') + if ({{ .DevMode }}) return event event.respondWith(fetchAsset(event)) diff --git a/user.go b/user.go new file mode 100644 index 0000000..b1c2abf --- /dev/null +++ b/user.go @@ -0,0 +1,27 @@ +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 deleted file mode 100644 index bcdfac8..0000000 --- a/user/pkg.go +++ /dev/null @@ -1,63 +0,0 @@ -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/layouts/main.gotmpl b/views/layouts/main.gotmpl index c5fbead..ead95a9 100644 --- a/views/layouts/main.gotmpl +++ b/views/layouts/main.gotmpl @@ -15,7 +15,7 @@ "crypto": "/js/{{ .VERSION }}/crypto.mjs", "node_store": "/js/{{ .VERSION }}/node_store.mjs", "node": "/js/{{ .VERSION }}/page_node.mjs", - "sidebar": "/js/{{ .VERSION }}/sidebar.mjs" + "tree": "/js/{{ .VERSION }}/tree.mjs" } } diff --git a/views/pages/notes2.gotmpl b/views/pages/notes2.gotmpl index 2755aea..f15bbb5 100644 --- a/views/pages/notes2.gotmpl +++ b/views/pages/notes2.gotmpl @@ -1,12 +1,5 @@ {{ define "page" }} - - - - - - - -
    +
    >
    @@ -16,38 +9,28 @@
    -
    -
    - -
    {{ .VERSION }}
    - -
    Create note
    -
    -
    -
    +
    - - - - +
    + +
    - - + +