diff --git a/authentication/pkg.go b/authentication/pkg.go index 9eb6245..c0b9a2e 100644 --- a/authentication/pkg.go +++ b/authentication/pkg.go @@ -8,6 +8,9 @@ import ( "github.com/jmoiron/sqlx" "github.com/lib/pq" + // Internal + appUser "notes2/user" + // Standard "database/sql" "encoding/hex" @@ -27,12 +30,6 @@ type Manager struct { ExpireDays int } -type User struct { - ID int - Username string - Name string -} - func httpError(w http.ResponseWriter, err error) { // {{{ j, _ := json.Marshal(struct { OK bool @@ -165,16 +162,16 @@ func (mngr *Manager) AuthenticationHandler(w http.ResponseWriter, r *http.Reques mngr.log.Info("authentication", "username", request.Username, "status", "accepted") j, _ := json.Marshal(struct { OK bool - User User + User appUser.User Token string }{true, user, token}) w.Write(j) } // }}} -func (mngr *Manager) Authenticate(username, password string) (authenticated bool, user User, err error) { // {{{ +func (mngr *Manager) Authenticate(username, password string) (authenticated bool, user appUser.User, err error) { // {{{ var row *sql.Row row = mngr.db.QueryRow(` - SELECT id, username, name + SELECT id, username, name, preferences FROM public.user WHERE LOWER(username) = LOWER($1) AND @@ -183,13 +180,21 @@ func (mngr *Manager) Authenticate(username, password string) (authenticated bool username, password, ) - err = row.Scan(&user.ID, &user.Username, &user.Name) + var data []byte + err = row.Scan(&user.ID, &user.Username, &user.Name, &data) if err != nil && err.Error() == "sql: no rows in result set" { err = nil authenticated = false return } if err != nil { + authenticated = false + return + } + + err = json.Unmarshal(data, &user.Preferences) + if err != nil { + authenticated = false return } @@ -278,7 +283,7 @@ func (mngr *Manager) ChangePassword(username, currentPassword, newPassword strin changed = (rowsAffected == 1) return } // }}} -func (mngr *Manager) NewClientUUID(user User) (clientUUID string, err error) { // {{{ +func (mngr *Manager) NewClientUUID(user appUser.User) (clientUUID string, err error) { // {{{ // Each client session has its own UUID. // Loop through until a unique one is established. var proposedClientUUID string diff --git a/main.go b/main.go index 1ce3364..6e3cf94 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( // Internal "notes2/authentication" "notes2/html_template" + appUser "notes2/user" "os" // Standard @@ -23,7 +24,7 @@ import ( "text/template" ) -const VERSION = "v22" +const VERSION = "v29" const CONTEXT_USER = 1 const SYNC_PAGINATION = 200 @@ -134,6 +135,8 @@ func main() { // {{{ http.HandleFunc("/offline", pageOffline) http.HandleFunc("/user/authenticate", AuthManager.AuthenticationHandler) + http.HandleFunc("GET /user/preferences", authenticated(actionUserGetPreferences)) + http.HandleFunc("POST /user/preferences", authenticated(actionUserSetPreferences)) http.HandleFunc("/sync/from_server/count/{sequence}", authenticated(actionSyncFromServerCount)) http.HandleFunc("/sync/from_server/{sequence}/{offset}", authenticated(actionSyncFromServer)) @@ -178,7 +181,7 @@ func authenticated(fn func(http.ResponseWriter, *http.Request)) func(http.Respon } // User object is added to the context for the next handler. - user := NewUser(claims) + user := appUser.NewUser(claims) r = r.WithContext(context.WithValue(r.Context(), CONTEXT_USER, user)) Log.Debug("webserver", "op", "request", "method", r.Method, "url", r.URL.String(), "username", user.Username, "client", user.ClientUUID) @@ -266,7 +269,7 @@ func actionSyncFromServer(w http.ResponseWriter, r *http.Request) { // {{{ // The purpose of the Client UUID is to avoid // sending nodes back once again to a client that // just created or modified it. - user := getUser(r) + user := getUserSession(r) changedFrom, _ := strconv.Atoi(r.PathValue("sequence")) offset, _ := strconv.Atoi(r.PathValue("offset")) @@ -289,7 +292,7 @@ func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{ // The purpose of the Client UUID is to avoid // sending nodes back once again to a client that // just created or modified it. - user := getUser(r) + user := getUserSession(r) changedFrom, _ := strconv.Atoi(r.PathValue("sequence")) count, err := NodesCount(user.UserID, uint64(changedFrom), user.ClientUUID) @@ -309,7 +312,7 @@ func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{ w.Write(j) } // }}} func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ - user := getUser(r) + user := getUserSession(r) var err error uuid := r.PathValue("uuid") @@ -325,7 +328,7 @@ func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ }) } // }}} func actionNodeHistoryRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ - user := getUser(r) + user := getUserSession(r) var err error uuid := r.PathValue("uuid") @@ -348,7 +351,7 @@ func actionNodeHistoryRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ }) } // }}} func actionNodeHistoryCount(w http.ResponseWriter, r *http.Request) { // {{{ - user := getUser(r) + user := getUserSession(r) var err error uuid := r.PathValue("uuid") @@ -360,12 +363,12 @@ func actionNodeHistoryCount(w http.ResponseWriter, r *http.Request) { // {{{ } responseData(w, map[string]any{ - "OK": true, - "Count": count, + "OK": true, + "Count": count, }) } // }}} func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{ - user := getUser(r) + user := getUserSession(r) body, _ := io.ReadAll(r.Body) var request struct { @@ -389,6 +392,47 @@ func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{ }) } // }}} +func actionUserGetPreferences(w http.ResponseWriter, r *http.Request) { // {{{ + user := getUserSession(r) + prefs, err := user.Preferences() + if err != nil { + httpError(w, err) + return + } + + responseData(w, map[string]any{ + "OK": true, + "Preferences": prefs, + }) +} // }}} +func actionUserSetPreferences(w http.ResponseWriter, r *http.Request) { // {{{ + session := getUserSession(r) + + // Verify the "default" profile is still there. + var newPrefs map[string]appUser.UserPreferences + body, _ := io.ReadAll(r.Body) + err := json.Unmarshal(body, &newPrefs) + if err != nil { + httpError(w, err) + return + } + + if _, found := newPrefs["default"]; !found { + httpError(w, fmt.Errorf("'default' profile missing.")) + return + } + + err = session.SetPreferences(newPrefs) + if err != nil { + httpError(w, err) + return + } + + responseData(w, map[string]any{ + "OK": true, + }) +} // }}} + func createNewUser(username string) { // {{{ reader := bufio.NewReader(os.Stdin) @@ -431,7 +475,8 @@ func changePassword(username string) { // {{{ fmt.Printf("\nPassword changed\n") } // }}} -func getUser(r *http.Request) UserSession { // {{{ - user, _ := r.Context().Value(CONTEXT_USER).(UserSession) +func getUserSession(r *http.Request) appUser.UserSession { // {{{ + user, _ := r.Context().Value(CONTEXT_USER).(appUser.UserSession) + user.Db = db return user } // }}} diff --git a/node.go b/node.go index e3a61d5..a25c771 100644 --- a/node.go +++ b/node.go @@ -54,6 +54,7 @@ type Node struct { DeletedSeq sql.NullInt64 `db:"deleted_seq"` Content string ContentEncrypted string `db:"content_encrypted" json:"-"` + Special bool } func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint64, moreRowsExist bool, err error) { // {{{ @@ -135,6 +136,7 @@ func Nodes(userID, offset int, synced uint64, clientUUID string) (nodes []Node, FROM public.node WHERE + NOT special AND user_id = $1 AND client != $5::uuid AND ( diff --git a/sql/00006.sql b/sql/00006.sql new file mode 100644 index 0000000..56f2acb --- /dev/null +++ b/sql/00006.sql @@ -0,0 +1,119 @@ +CREATE OR REPLACE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid uuid, IN p_nodes jsonb) + LANGUAGE plpgsql +AS $procedure$ + +DECLARE + node_data jsonb; + node_updated timestamptz; + db_updated timestamptz; + db_uuid uuid; + db_client uuid; + db_history_uuid uuid; + node_uuid uuid; + node_parent_uuid uuid; + node_history_uuid uuid; + +BEGIN + FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes) + LOOP + node_uuid = (node_data->>'UUID')::uuid; + node_history_uuid = (node_data->>'HistoryUUID')::uuid; + node_updated = (node_data->>'Updated')::timestamptz; + + + + -- Frontend is using an all-zero UUID to define the root node. + -- Database is using NULL. + IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' OR node_data->>'ParentUUID' = '' THEN + node_parent_uuid = NULL; + ELSE + node_parent_uuid = (node_data->>'ParentUUID')::uuid; + END IF; + + + + -- Every jode has a new history UUID to keep the history entry uniquely identifiable + -- across clients. A history entry could potentially be sent again, but should be + -- safe to ignore as every change to a node should have a new history UUID. + -- + -- The current node is also stored as history. + INSERT INTO node_history( + user_id, "uuid", "history_uuid", parents, created, updated, + "name", "content", "content_encrypted", + client + ) + VALUES( + p_user_id, -- combined key + node_uuid, -- combined key + node_history_uuid, -- combined key + (jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors", + COALESCE((node_data->>'Created')::timestamptz, NOW()), + COALESCE((node_data->>'Updated')::timestamptz, NOW()), + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + '', /* content_encrypted */ + p_client_uuid + ) + ON CONFLICT ("user_id", "uuid", "history_uuid") + DO NOTHING; + + + + -- Retrieve the current modified timestamp for this node from the database. + SELECT + uuid, updated, client + INTO + db_uuid, db_updated, db_client + FROM public."node" + WHERE + user_id = p_user_id AND + uuid::uuid = node_uuid::uuid; + + + + -- Is the node not in database? It needs to be created. + IF db_uuid IS NULL THEN + RAISE NOTICE '01 New node %', node_uuid; + + INSERT INTO public."node" ( + user_id, "uuid", parent_uuid, created, updated, + "name", "content", "content_encrypted", + client + ) + VALUES( + p_user_id, + node_uuid, + node_parent_uuid, + COALESCE((node_data->>'Created')::timestamptz, NOW()), + COALESCE((node_data->>'Updated')::timestamptz, NOW()), + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + '', /* content_encrypted */ + p_client_uuid + ); + + CONTINUE; + + END IF; + + + + -- Update the public node as well if it was older than incoming node. + IF node_updated > db_updated THEN + UPDATE public."node" + SET + updated = (node_data->>'Updated')::timestamptz, + updated_seq = nextval('node_updates'), + parent_uuid = (node_data->>'ParentUUID')::uuid, + name = (node_data->>'Name')::varchar, + content = (node_data->>'Content')::text, + client = p_client_uuid + WHERE + user_id = p_user_id AND + uuid::uuid = node_uuid::uuid; + END IF; + + END LOOP; +END +$procedure$ +; diff --git a/sql/00007.sql b/sql/00007.sql new file mode 100644 index 0000000..0b79d9c --- /dev/null +++ b/sql/00007.sql @@ -0,0 +1,119 @@ +CREATE OR REPLACE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid uuid, IN p_nodes jsonb) + LANGUAGE plpgsql +AS $procedure$ + +DECLARE + node_data jsonb; + node_updated timestamptz; + db_updated timestamptz; + db_uuid uuid; + db_client uuid; + db_history_uuid uuid; + node_uuid uuid; + node_parent_uuid uuid; + node_history_uuid uuid; + +BEGIN + FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes) + LOOP + node_uuid = (node_data->>'UUID')::uuid; + node_history_uuid = (node_data->>'HistoryUUID')::uuid; + node_updated = (node_data->>'Updated')::timestamptz; + + + + -- Frontend is using an all-zero UUID to define the root node. + -- Database is using NULL. + IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' OR node_data->>'ParentUUID' = '' THEN + node_parent_uuid = NULL; + ELSE + node_parent_uuid = (node_data->>'ParentUUID')::uuid; + END IF; + + + + -- Every jode has a new history UUID to keep the history entry uniquely identifiable + -- across clients. A history entry could potentially be sent again, but should be + -- safe to ignore as every change to a node should have a new history UUID. + -- + -- The current node is also stored as history. + INSERT INTO node_history( + user_id, "uuid", "history_uuid", parents, created, updated, + "name", "content", "content_encrypted", + client + ) + VALUES( + p_user_id, -- combined key + node_uuid, -- combined key + node_history_uuid, -- combined key + (jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors", + COALESCE((node_data->>'Created')::timestamptz, NOW()), + COALESCE((node_data->>'Updated')::timestamptz, NOW()), + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + '', /* content_encrypted */ + p_client_uuid + ) + ON CONFLICT ("user_id", "uuid", "history_uuid") + DO NOTHING; + + + + -- Retrieve the current modified timestamp for this node from the database. + SELECT + uuid, updated, client + INTO + db_uuid, db_updated, db_client + FROM public."node" + WHERE + user_id = p_user_id AND + uuid::uuid = node_uuid::uuid; + + + + -- Is the node not in database? It needs to be created. + IF db_uuid IS NULL THEN + RAISE NOTICE '01 New node %', node_uuid; + + INSERT INTO public."node" ( + user_id, "uuid", parent_uuid, created, updated, + "name", "content", "content_encrypted", + client + ) + VALUES( + p_user_id, + node_uuid, + node_parent_uuid, + COALESCE((node_data->>'Created')::timestamptz, NOW()), + COALESCE((node_data->>'Updated')::timestamptz, NOW()), + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + '', /* content_encrypted */ + p_client_uuid + ); + + CONTINUE; + + END IF; + + + + -- Update the public node as well if it was older than incoming node. + IF node_updated > db_updated THEN + UPDATE public."node" + SET + updated = (node_data->>'Updated')::timestamptz, + updated_seq = nextval('node_updates'), + parent_uuid = node_parent_uuid, + name = (node_data->>'Name')::varchar, + content = (node_data->>'Content')::text, + client = p_client_uuid + WHERE + user_id = p_user_id AND + uuid::uuid = node_uuid::uuid; + END IF; + + END LOOP; +END +$procedure$ +; diff --git a/sql/00008.sql b/sql/00008.sql new file mode 100644 index 0000000..2701ba5 --- /dev/null +++ b/sql/00008.sql @@ -0,0 +1,123 @@ +CREATE OR REPLACE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid uuid, IN p_nodes jsonb) + LANGUAGE plpgsql +AS $procedure$ + +DECLARE + node_data jsonb; + node_updated timestamptz; + db_updated timestamptz; + db_uuid uuid; + db_client uuid; + db_history_uuid uuid; + node_uuid uuid; + node_parent_uuid uuid; + node_history_uuid uuid; + +BEGIN + FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes) + LOOP + node_uuid = (node_data->>'UUID')::uuid; + node_history_uuid = (node_data->>'HistoryUUID')::uuid; + node_updated = (node_data->>'Updated')::timestamptz; + + + + -- Frontend is using an all-zero UUID to define the root node. + -- Database is using NULL. + IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' OR node_data->>'ParentUUID' = '' THEN + node_parent_uuid = NULL; + ELSE + node_parent_uuid = (node_data->>'ParentUUID')::uuid; + END IF; + + -- Safeguard against being your own parent. + IF node_uuid = node_parent_uuid THEN + RAISE EXCEPTION 'Node UUID is same as node parent UUID.' USING ERRCODE = 'XPRNT'; + END IF; + + + -- Every jode has a new history UUID to keep the history entry uniquely identifiable + -- across clients. A history entry could potentially be sent again, but should be + -- safe to ignore as every change to a node should have a new history UUID. + -- + -- The current node is also stored as history. + INSERT INTO node_history( + user_id, "uuid", "history_uuid", parents, created, updated, + "name", "content", "content_encrypted", + client + ) + VALUES( + p_user_id, -- combined key + node_uuid, -- combined key + node_history_uuid, -- combined key + (jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors", + COALESCE((node_data->>'Created')::timestamptz, NOW()), + COALESCE((node_data->>'Updated')::timestamptz, NOW()), + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + '', /* content_encrypted */ + p_client_uuid + ) + ON CONFLICT ("user_id", "uuid", "history_uuid") + DO NOTHING; + + + + -- Retrieve the current modified timestamp for this node from the database. + SELECT + uuid, updated, client + INTO + db_uuid, db_updated, db_client + FROM public."node" + WHERE + user_id = p_user_id AND + uuid::uuid = node_uuid::uuid; + + + + -- Is the node not in database? It needs to be created. + IF db_uuid IS NULL THEN + RAISE NOTICE '01 New node %', node_uuid; + + INSERT INTO public."node" ( + user_id, "uuid", parent_uuid, created, updated, + "name", "content", "content_encrypted", + client + ) + VALUES( + p_user_id, + node_uuid, + node_parent_uuid, + COALESCE((node_data->>'Created')::timestamptz, NOW()), + COALESCE((node_data->>'Updated')::timestamptz, NOW()), + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + '', /* content_encrypted */ + p_client_uuid + ); + + CONTINUE; + + END IF; + + + + -- Update the public node as well if it was older than incoming node. + IF node_updated > db_updated THEN + UPDATE public."node" + SET + updated = (node_data->>'Updated')::timestamptz, + updated_seq = nextval('node_updates'), + parent_uuid = node_parent_uuid, + name = (node_data->>'Name')::varchar, + content = (node_data->>'Content')::text, + client = p_client_uuid + WHERE + user_id = p_user_id AND + uuid::uuid = node_uuid::uuid; + END IF; + + END LOOP; +END +$procedure$ +; diff --git a/sql/00009.sql b/sql/00009.sql new file mode 100644 index 0000000..50487f3 --- /dev/null +++ b/sql/00009.sql @@ -0,0 +1,35 @@ +-- Special node such as orphaned and deleted nodes. +ALTER TABLE public.node ADD special bool DEFAULT false NOT NULL; + + +-- Needs to be dropped in order to drop the index on UUID. +ALTER TABLE public.node DROP CONSTRAINT node_node_fk; + +-- Index was missing user ID. +DROP INDEX public.node_uuid_idx; +CREATE UNIQUE INDEX node_user_uuid_idx ON public.node (user_id,"uuid"); + +-- Restore the "foreign" key of parent UUID back to UUID. +ALTER TABLE public.node ADD CONSTRAINT node_node_fk FOREIGN KEY (user_id,parent_uuid) REFERENCES public.node(user_id,"uuid") ON DELETE RESTRICT ON UPDATE RESTRICT; + + +-- Auto-create the special nodes for each user. +CREATE OR REPLACE FUNCTION create_user_nodes() +RETURNS TRIGGER AS $$ +BEGIN + -- NEW holds the row being created. + -- No semi-colons omitted here, PL/pgSQL requires them. + INSERT INTO public.node (user_id, uuid, parent_uuid, special, name) + VALUES + (NEW.id, '00000000-0000-0000-0000-000000000000'::uuid, null, true, 'Start'), + (NEW.id, '00000000-0000-0000-0000-000000000001'::uuid, null, true, 'Orphaned nodes'), + (NEW.id, '00000000-0000-0000-0000-000000000002'::uuid, null, true, 'Deleted nodes'); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_after_user_insert +AFTER INSERT ON public.user +FOR EACH ROW +EXECUTE FUNCTION create_user_nodes(); diff --git a/sql/00010.sql b/sql/00010.sql new file mode 100644 index 0000000..ecd8ab4 --- /dev/null +++ b/sql/00010.sql @@ -0,0 +1 @@ +ALTER TABLE public."user" ADD preferences jsonb DEFAULT '{}' NOT NULL; diff --git a/static/css/markdown.css b/static/css/markdown.css index 1ecbc94..832d4a2 100644 --- a/static/css/markdown.css +++ b/static/css/markdown.css @@ -102,6 +102,11 @@ border: 1px solid #ccc; padding: 2px 4px; border-radius: 4px; + + &.copy { + border: var(--markdown-copy-border); + background-color: var(--markdown-copy-background); + } } pre { @@ -111,6 +116,14 @@ border-radius: 4px; white-space: pre-wrap; + &.copy { + border: var(--markdown-copy-border); + background-color: var(--markdown-copy-background); + code { + background-color: inherit !important; + } + } + code { border: unset; padding: unset; diff --git a/static/css/notes2.css b/static/css/notes2.css index 04c9f68..7fdea0b 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -9,7 +9,15 @@ --line-color: #ccc; --tree-expander: 0px; - --functions-width: 180px; + --functions-width: 150px; + + --menu-color: #fff; + --menu-item-hover-color: #f4f4f4; + + --font-monospace: "Liberation Mono", monospace; + + --markdown-copy-border: 1px solid #0a0; + --markdown-copy-background: #e3f4d7; } html { @@ -20,6 +28,10 @@ html { filter: var(--colorize); } +textarea { + font-family: var(--font-monospace); +} + button { font-size: 1em; padding: 4px 8px; @@ -61,9 +73,10 @@ button { 1fr; } - &.page-history { + /* The other pages just gets the whole page without dividing it up. */ + &:not(.page-node) { grid-template-areas: - "tree-expander tree pad1 n2-pagehistory pad2" + "tree-expander tree pad1 n2-page pad2" ; grid-template-columns: @@ -186,6 +199,11 @@ button { img { width: auto; height: 18px; + + &.deleted { + height: 24px; + transform: translateX(3px) translateY(3px); + } } } @@ -228,7 +246,6 @@ button { #notes2 { &.page-node { - #page-root { display: none; } @@ -243,7 +260,7 @@ button { display: contents; n2-pagestorage { - grid-area: content; + grid-area: n2-page; } } } @@ -251,9 +268,14 @@ button { &.page-history { #page-history { display: grid; - grid-area: n2-pagehistory; + grid-area: n2-page; + } + } - n2-pagehistory {} + &.page-preferences { + #page-preferences { + display: block; + grid-area: n2-page; } } @@ -265,7 +287,6 @@ button { #page-root { display: contents !important; } - } } @@ -418,6 +439,8 @@ n2-nodeui { font-size: 1.75em; margin-top: 8px; margin-bottom: 0px; + white-space: nowrap; + width: min-content; } .el-functions { @@ -431,7 +454,6 @@ n2-nodeui { grid-area: content; justify-self: center; word-wrap: break-word; - font-family: monospace; font-size: 1em; color: #333; @@ -455,6 +477,10 @@ n2-nodeui { grid-area: content; display: none; + font-family: var(--font-monospace); + font-size: 1em; + font-weight: 400; + border-top: 1px solid #e0e0e0; margin-top: 8px; margin-bottom: 32px; diff --git a/static/images/icon_drag.svg b/static/images/icon_drag.svg new file mode 100644 index 0000000..02d628e --- /dev/null +++ b/static/images/icon_drag.svg @@ -0,0 +1,71 @@ + + + +folder-openfolder-open-outlinenotebook-outlinetext-box-outline diff --git a/static/images/icon_drag_ok.svg b/static/images/icon_drag_ok.svg new file mode 100644 index 0000000..94ba949 --- /dev/null +++ b/static/images/icon_drag_ok.svg @@ -0,0 +1,75 @@ + + + +folder-openfolder-open-outlinenotebook-outlinetext-box-outlinetext-box-check-outline diff --git a/static/images/icon_drag_source.svg b/static/images/icon_drag_source.svg new file mode 100644 index 0000000..6378ed9 --- /dev/null +++ b/static/images/icon_drag_source.svg @@ -0,0 +1,49 @@ + + + + + + + + drag-variant + + + diff --git a/static/images/icon_menu.svg b/static/images/icon_menu.svg new file mode 100644 index 0000000..cfdd1e8 --- /dev/null +++ b/static/images/icon_menu.svg @@ -0,0 +1,56 @@ + + + +menuhamburger diff --git a/static/images/leaf.svg b/static/images/leaf.svg index 9d200c3..17f4fe2 100644 --- a/static/images/leaf.svg +++ b/static/images/leaf.svg @@ -24,12 +24,12 @@ inkscape:deskcolor="#d1d1d1" inkscape:document-units="px" inkscape:zoom="31.614857" - inkscape:cx="5.0609117" - inkscape:cy="9.5524708" + inkscape:cx="5.0450964" + inkscape:cy="9.5682862" inkscape:window-width="2190" inkscape:window-height="1401" inkscape:window-x="1463" - inkscape:window-y="0" + inkscape:window-y="18" inkscape:window-maximized="1" inkscape:current-layer="layer1" showgrid="false" /> + + +folder-openfolder-open-outlinenotebook-outlinetext-box-outlinedelete-circle diff --git a/static/images/leaf_orphaned.svg b/static/images/leaf_orphaned.svg new file mode 100644 index 0000000..8b1cc37 --- /dev/null +++ b/static/images/leaf_orphaned.svg @@ -0,0 +1,61 @@ + + + +folder-openfolder-open-outlinenotebook-outlinetext-box-outlinedelete-circleghost diff --git a/static/js/api.mjs b/static/js/api.mjs index 3fff10a..26a19de 100644 --- a/static/js/api.mjs +++ b/static/js/api.mjs @@ -1,7 +1,7 @@ export class API { // query resolves into the JSON data produced by the application, or an exception with 'type' and 'error' properties. static async query(method, path, request) { - return new Promise((resolve, reject) => { + try { const body = JSON.stringify(request) const headers = {} @@ -12,33 +12,22 @@ export class API { headers.Authorization = `Bearer ${token}` } - fetch(path, { method, headers, body }) - .then(response => { - // An HTTP communication level error occured. - if (!response.ok || response.status != 200) - return reject({ - type: 'http', - error: response, - }) - return response.json() - }) - .then(json => { - // Application level response are handled here. - if (!json.OK) - return reject({ - type: 'application', - error: json.Error, - application: json, - }) - resolve(json) - }) - .catch(err => - // Catch any other errors from fetch. - reject({ - type: 'http', - error: err, - })) - }) + const res = await fetch(path, { method, headers, body }) + // An HTTP communication level error occured. + if (!res.ok || res.status != 200) + throw new Error('HTTP error', { cause: { type: 'http', error: res, }}) + + // Application level response are handled here. + const json = await res.json() + if (!json.OK) + throw new Error(json.Error, { cause: { type: 'application', application: json, }}) + + return json + + } catch (err) { + // Catch any other errors from fetch. + throw new Error(err.message, { cause: { type: 'http', error: err, }}) + } } static hasAuthenticationToken() {//{{{ diff --git a/static/js/app.mjs b/static/js/app.mjs index 876d11d..90bad39 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -2,6 +2,7 @@ import { ROOT_NODE } from 'node_store' import { CustomHTMLElement } from './lib/custom_html_element.mjs' import { N2Sidebar } from 'sidebar' import { Node } from 'node' +import { N2PreferenceSet } from './page_preferences.mjs' export class App { static PAGES = ['node', 'history', 'storage'] @@ -12,6 +13,9 @@ export class App { 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) @@ -60,6 +64,11 @@ export class App { classList.add('page-' + page) }) + _mbus.subscribe('DEVICE_PREFERENCE_SET_UPDATED', ()=>{ + this.preferences = this.getPreferences() + console.log(this.preferences.data) + }) + window.addEventListener('keydown', event => this.keyHandler(event)) window.addEventListener('popstate', event => this.popState(event)) document.getElementById('notes2').addEventListener('click', event => { @@ -68,6 +77,7 @@ export class App { }) document.querySelector('#page-root .create').addEventListener('click', () => this.createNode()) + document.body.append(this.dragIcon) _mbus.dispatch('SHOW_PAGE', { page: 'node' }) @@ -78,13 +88,13 @@ 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. // 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 @@ -93,7 +103,7 @@ export class App { this.nodeUI.renameNode() break case 'T': - if (!SHIFT_ALT) break + if (!SHIFT_ALT) { handled = false; break } if (document.activeElement.id === 'tree-nodes') this.nodeUI.takeFocus() else @@ -101,12 +111,12 @@ export class App { break case 'F': - if (!SHIFT_ALT) break + if (!SHIFT_ALT) { handled = false; break } _mbus.dispatch('op-search') break case 'M': - if (!SHIFT_ALT) break + if (!SHIFT_ALT) { handled = false; break } globalThis._mbus.dispatch('MARKDOWN_TOGGLE') break @@ -115,11 +125,13 @@ export class App { this.createNode() else if (SHIFT_CTRL_ALT) { this.createNode(this.currentNode?.ParentUUID) - } + } else { + handled = false + } break case 'S': - if (!SHIFT_ALT) break + if (!CTRL) { handled = false; break } this.nodeUI.saveNode() break @@ -148,6 +160,10 @@ export class App { async saveNode() {//{{{ }//}}} + async moveNode(node, targetNodeUUID) {// {{{ + node.moveToParent(targetNodeUUID) + await node.save() + }// }}} async createNode(createUnderUUID) {//{{{ const parentUUID = createUnderUUID ? createUnderUUID : this.currentNode.UUID const p = createUnderUUID ? 'Name for sibling document' : 'Name for sub-document' @@ -203,6 +219,12 @@ export class App { 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 { @@ -236,7 +258,6 @@ class N2Crumbs extends CustomHTMLElement { return this }// }}} } -customElements.define('n2-crumbs', N2Crumbs) class N2Crumb extends CustomHTMLElement { static {// {{{ @@ -267,7 +288,6 @@ class N2Crumb extends CustomHTMLElement { this.elLink.addEventListener('click', () => _mbus.dispatch("GO_TO_NODE", { nodeUUID: this.uuid, dontPush: false, dontExpand: true })) }// }}} } -customElements.define('n2-crumb', N2Crumb) function tmpl(html) {// {{{ const el = document.createElement('template') @@ -341,4 +361,52 @@ class OpSearch extends Op { }// }}} } +class N2DragIcon extends CustomHTMLElement { + static {// {{{ + this.tmpl = document.createElement('template') + this.tmpl.innerHTML = ` + + + ` + }// }}} + constructor() {// {{{ + super(true) + + document.addEventListener('dragover', e => { + this.style.left = `${e.clientX + 8}px` + this.style.top = `${e.clientY}px` + }) + + this.dragSource = null + }// }}} + start() {// {{{ + this.style.display = 'block' + }// }}} + end() {// {{{ + this.style.display = 'none' + }// }}} + icon(name) {// {{{ + if (name != '') + name = '_' + name + this.elIcon.setAttribute('src', `/images/${_VERSION}/icon_drag${name}.svg`) + }// }}} + setSource(s) {// {{{ + this.dragSource = s + }// }}} + getSource() {// {{{ + return this.dragSource + }// }}} +} + +customElements.define('n2-crumbs', N2Crumbs) +customElements.define('n2-crumb', N2Crumb) +customElements.define('n2-dragicon', N2DragIcon) + // vim: foldmethod=marker diff --git a/static/js/lib/custom_html_element.mjs b/static/js/lib/custom_html_element.mjs index 2cec808..d1fb7ae 100644 --- a/static/js/lib/custom_html_element.mjs +++ b/static/js/lib/custom_html_element.mjs @@ -1,7 +1,17 @@ +/* Use data-el or data-field attribute. + * Element with data-el="hum-ding" is accessible as this.elHumDing and fields with + * data-field="long-dong" as this.fieldLongDong. + * + * All field values can be retrieved with fieldValues() and uses the data-field attribute + * as LongDong as key. + */ + export class CustomHTMLElement extends HTMLElement { constructor(useShadow) {// {{{ super() + this._fields = new Map() + const workOn = useShadow ? this.attachShadow({ mode: 'open' }) : this workOn.appendChild(this.constructor.tmpl.content.cloneNode(true)) workOn.querySelectorAll('*').forEach(el => { @@ -9,6 +19,7 @@ export class CustomHTMLElement extends HTMLElement { if (field !== undefined) { const fieldName = this.toElementName('field', field) this[fieldName] = el + this._fields.set(this.toElementName('', field), el) } const name = el.dataset.el @@ -19,39 +30,22 @@ export class CustomHTMLElement extends HTMLElement { } }) }// }}} + allFields() {// {{{ + return this._fields + }// }}} + fieldValues() {// {{{ + const state = {} + for (const [name, field] of this._fields) { + if (field.tagName.toLowerCase() == 'input' && field.getAttribute('type').toLowerCase() == 'checkbox') + state[name] = field.checked + else + state[name] = field.value + + } + return state + }// }}} toElementName(prefix, str) {// {{{ str = prefix + '-' + str return str.replace(/-(id|[a-z])/g, match => match.toUpperCase().replace('-', '')) }// }}} } - -export class StupidPreactCustomHTMLElement extends HTMLElement { - constructor() {// {{{ - super() - - // Stupid stuff because of Preact. - this.clonedNodes = this.constructor.tmpl.content.cloneNode(true) - this.clonedNodes.querySelectorAll('*').forEach(el => { - const field = el.dataset.field - if (field !== undefined) { - const fieldName = this.toElementName('field', field) - this[fieldName] = el - } - - const name = el.dataset.el - if (name !== undefined) { - const elName = this.toElementName('el', name) - this[elName] = el - el.classList.add('el-' + name) - } - }) - }// }}} - toElementName(prefix, str) {// {{{ - str = prefix + '-' + str - return str.replace(/-(id|[a-z])/g, match => match.toUpperCase().replace('-', '')) - }// }}} - connectedCallback() {// {{{ - // Stupid stuff because of Preact. - this.appendChild(this.clonedNodes) - }// }}} -} diff --git a/static/js/marked_position.mjs b/static/js/marked_position.mjs index 5f77251..8c81eb4 100644 --- a/static/js/marked_position.mjs +++ b/static/js/marked_position.mjs @@ -94,6 +94,7 @@ 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) this.render() }// }}} setpos(event) {// {{{ @@ -119,8 +120,32 @@ export class MarkedPosition { } }) }// }}} + 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({ @@ -165,12 +190,12 @@ export class MarkedPosition { const code = token.text.replace(other.endingNewline, '') + '\n' if (!langString) { - return `
`
+						return `
`
 							+ (token.escaped ? code : escapeHtmlEntities(code, true))
 							+ '
\n' } - return `
'
 						+ (token.escaped ? code : escapeHtmlEntities(code, true))
@@ -260,7 +285,7 @@ export class MarkedPosition {
 				},
 
 				codespan(token) {
-					return `${escapeHtmlEntities(token.text, true)}`
+					return `${escapeHtmlEntities(token.text, true)}`
 				},
 
 				br(token) {
diff --git a/static/js/node_store.mjs b/static/js/node_store.mjs
index f31a4b6..6be8f82 100644
--- a/static/js/node_store.mjs
+++ b/static/js/node_store.mjs
@@ -1,6 +1,8 @@
 import { Node } from 'node'
 
 export const ROOT_NODE = '00000000-0000-0000-0000-000000000000'
+export const ORPHANED_NODE = '00000000-0000-0000-0000-000000000001'
+export const DELETED_NODE = '00000000-0000-0000-0000-000000000002'
 
 export class NodeStore {
 	constructor() {//{{{
@@ -13,6 +15,8 @@ export class NodeStore {
 		this.sendQueue = null
 		this.nodesHistory = null
 		this.files = null
+
+		this.initializeSpecialNodes()
 	}//}}}
 	initializeDB() {//{{{
 		return new Promise((resolve, reject) => {
@@ -76,8 +80,7 @@ export class NodeStore {
 				this.sendQueue = new SimpleNodeStore(this.db, 'send_queue')
 				this.nodesHistory = new NodeHistoryStore(this.db, 'nodes_history')
 				this.files = new SimpleNodeStore(this.db, 'files')
-				this.initializeRootNode()
-					.then(() => resolve())
+				resolve()
 			}
 
 			req.onerror = (event) => {
@@ -85,40 +88,11 @@ export class NodeStore {
 			}
 		})
 	}//}}}
-	initializeRootNode() {//{{{
-		return new Promise((resolve, reject) => {
-			// The root node is a magical node which displays as the first node if none is specified.
-			// If not already existing, it will be created.
-			const trx = this.db.transaction('nodes', 'readwrite')
-			const nodes = trx.objectStore('nodes')
-			const getRequest = nodes.get(ROOT_NODE)
-			getRequest.onsuccess = (event) => {
-				// Root node exists - nice!
-				if (event.target.result !== undefined) {
-					resolve(event.target.result)
-					return
-				}
-
-				const putRequest = nodes.put({
-					UUID: ROOT_NODE,
-					Name: 'Notes2',
-					Content: 'Hello, World!',
-					Updated: new Date().toISOString(),
-					ParentUUID: '',
-				})
-				putRequest.onsuccess = (event) => {
-					resolve(event.target.result)
-				}
-				putRequest.onerror = (event) => {
-					reject(event.target.error)
-				}
-			}
-			getRequest.onerror = (event) => reject(event.target.error)
-		})
-	}//}}}
-	purgeCache() {//{{{
-		this.nodes = {}
-	}//}}}
+	initializeSpecialNodes() {// {{{
+		this.nodes[ROOT_NODE] = new Node({ UUID: ROOT_NODE, Name: 'Start', Special: true }, -1)
+		this.nodes[DELETED_NODE] = new Node({ UUID: DELETED_NODE, Name: 'Deleted nodes', Special: true }, -1)
+		this.nodes[ORPHANED_NODE] = new Node({ UUID: ORPHANED_NODE, Name: 'Orphaned nodes', Special: true }, -1)
+	}// }}}
 
 	node(uuid, dataIfUndefined, newLevel) {//{{{
 		let n = this.nodes[uuid]
@@ -247,6 +221,7 @@ export class NodeStore {
 					nodeStore = t.objectStore('nodes')
 
 					t.oncomplete = (_event) => {
+						console.log('complete')
 						resolve()
 					}
 
@@ -271,6 +246,14 @@ export class NodeStore {
 	}//}}}
 	get(uuid, suppliedNodestore) {//{{{
 		return new Promise((resolve, reject) => {
+			switch (uuid) {
+				case ROOT_NODE:
+				case DELETED_NODE:
+				case ORPHANED_NODE:
+					resolve(this.nodes[uuid])
+					return
+			}
+
 			// A nodestore can be provided in order to
 			// avoid creating new transactions.
 			let trx
@@ -308,6 +291,16 @@ export class NodeStore {
 				return
 			}
 
+			if (node.UUID === DELETED_NODE || node.ParentUUID === DELETED_NODE) {
+				resolve(accumulated)
+				return
+			}
+
+			if (node.UUID === ORPHANED_NODE || node.ParentUUID === ORPHANED_NODE) {
+				resolve(accumulated)
+				return
+			}
+
 			const getRequest = nodeParentIndex.get(node.ParentUUID)
 			getRequest.onsuccess = (event) => {
 				// Node not found in IndexedDB.
@@ -358,6 +351,7 @@ class SimpleNodeStore {
 			// Node to be moved is first stored in the new queue.
 			const req = store.put(node.data)
 			req.onsuccess = () => {
+				console.log('here')
 				resolve()
 			}
 			req.onerror = (event) => {
diff --git a/static/js/page_node.mjs b/static/js/page_node.mjs
index 834e22f..2106ada 100644
--- a/static/js/page_node.mjs
+++ b/static/js/page_node.mjs
@@ -2,14 +2,70 @@ 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 = `
@@ -28,10 +92,13 @@ export class N2PageNodeUI extends CustomHTMLElement {
- - + +
+ ` }// }}} @@ -40,12 +107,14 @@ export class N2PageNodeUI extends CustomHTMLElement { this.node = null this.style.display = 'contents' - this.classList.add('show-markdown') // TODO Should probably be moved to settings. this.marked = new MarkedPosition() _mbus.subscribe('NODE_UI_OPEN', event => { this.node = event.detail.data - this.showMarkdown(true) + + + if (!this.node.isSpecial()) + this.showMarkdown(true) this.render() }) @@ -66,11 +135,27 @@ export class N2PageNodeUI extends CustomHTMLElement { _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()) + + // Bind handlers for content keyboard input and paste. this.elNodeContent.addEventListener('input', event => this.contentChanged(event)) this.elNodeContent.addEventListener('paste', async (event) => this.pasteHandler(event)) + + // Bind node icon handlers. + this.elIconSave.addEventListener('click', () => this.saveNode()) this.elIconMarkdown.addEventListener('click', () => this.showMarkdown(!this.showMarkdown())) - this.elIconTableFormat.addEventListener('click', event => { + this.elIconNewDocument.addEventListener('click', event => { + if (event.shiftKey) + _app.createNode(this.node.ParentUUID) + else + _app.createNode() + }) + + // Bind node menu items to handlers. + this.elNodeMenu.elFormatTables.addEventListener('click', event => { + this.elNodeMenu.hidePopover() + if (!event.shiftKey) this.elNodeContent.value = this.formatAllTables(this.elNodeContent.value) else { @@ -84,15 +169,12 @@ export class N2PageNodeUI extends CustomHTMLElement { this.node.setContent(this.elNodeContent.value) }) - this.elIconHistory.addEventListener('click', () => _mbus.dispatch('SHOW_PAGE', { page: 'history' })) - this.elIconSave.addEventListener('click', () => this.saveNode()) - this.elIconNewDocument.addEventListener('click', event => { - if (event.shiftKey) - _app.createNode(this.node.ParentUUID) - else - _app.createNode() + this.elNodeMenu.elHistory.addEventListener('click', () => { + _mbus.dispatch('SHOW_PAGE', { page: 'history' }) }) + // Default is to always show markdown. + this.classList.add('show-markdown') // TODO Should probably be moved to settings. this.showMarkdown(true) }// }}} renderName() {// {{{ @@ -305,7 +387,7 @@ export class N2PageNodeUI extends CustomHTMLElement { // 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 newValue = `[${checkbox.checked ? 'x' : ' '}] ` const modifiedContent = this.node.content().slice(0, pos.start) + newValue + this.node.content().slice(pos.end) this.node.setContent(modifiedContent) @@ -422,12 +504,23 @@ export class Node { getParent() {//{{{ return this._parent }//}}} + moveToParent(newParentUUID) {// {{{ + if (this.UUID === newParentUUID) + throw new Error("New parent UUID is the same as node UUID. Can't be your own parent.") + + this.ParentUUID = newParentUUID + this.data.ParentUUID = newParentUUID + this._modified = true + }// }}} isLastSibling() {//{{{ return this._sibling_after === null }//}}} isFirstSibling() {//{{{ return this._sibling_before === null }//}}} + isSpecial() {// {{{ + return this.data.Special + }// }}} content() {//{{{ /* TODO - implement crypto if (this.CryptoKeyID != 0 && !this._decrypted) @@ -463,9 +556,10 @@ export class Node { // 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. @@ -481,13 +575,19 @@ export class Node { 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]) - return Promise.all([history, sendQueue, nodeStoreAdding]) + console.log('waiting') + await Promise.all([history, sendQueue, nodeStoreAdding]) + console.log('waiting done') + + return }//}}} } + // vim: foldmethod=marker diff --git a/static/js/page_preferences.mjs b/static/js/page_preferences.mjs new file mode 100644 index 0000000..9655278 --- /dev/null +++ b/static/js/page_preferences.mjs @@ -0,0 +1,283 @@ +import { CustomHTMLElement } from "./lib/custom_html_element.mjs" +import { API } from './api.mjs' + +export class N2PagePreferences extends CustomHTMLElement { + static {// {{{ + this.tmpl = document.createElement('template') + this.tmpl.innerHTML = ` + +

Preferences

+ +
Changes preferences to not download images or files on the device doesn't remove the already downloaded data.
+ +
+
Device preference set
+ +
+ +
+ + + + ` + }// }}} + constructor() {// {{{ + super(true) + this.sets = [] + + this.elNewSet.addEventListener('click', () => this.newSet()) + this.elSave.addEventListener('click', () => this.save()) + this.elDevPreferenceSet.addEventListener('change', event=>this.changePreferenceSet(event)) + + window._mbus.subscribe('SHOW_PAGE', async event => { + if (event.detail.data?.page == 'preferences') { + this.sets = await this.getPreferenceSets() + this.render() + } + }) + + window._mbus.subscribe('PREFERENCE_SET_MODIFIED', () => this.preferencesModified()) + window._mbus.subscribe('PREFERENCE_SET_DELETE', event => this.preferencesDelete(event.detail.data.set)) + }// }}} + sortSets(a, b) {// {{{ + if (a.name == 'default') return -1 + if (b.name == 'default') return 1 + + if (a.name.toLowerCase() < b.name.toLowerCase()) return -1 + if (a.name.toLowerCase() > b.name.toLowerCase()) return 1 + + return 0 + }// }}} + async render() {// {{{ + try { + this.sets.sort(this.sortSets) + this.elSets.replaceChildren(...this.sets) + + const setNames = this.sets.entries().map(([i, set]) => { + const optn = document.createElement('option') + optn.innerText = set.name + return optn + }) + this.elDevPreferenceSet.replaceChildren(...setNames) + } catch (e) { + console.error(e) + alert(e.message) + } + }// }}} + async getPreferenceSets() {// {{{ + const userData = localStorage.getItem('user') + if (userData === null) + throw new Error('Could not find user in localStorage') + + const user = JSON.parse(userData) + const prefsData = user.Preferences + + if (prefsData === undefined) + throw new Error('User object is missing preferences') + + if (!prefsData.hasOwnProperty('default')) + throw new Error('The "default" preferences set is missing') + + return Object.keys(prefsData).map(name => new N2PreferenceSet(name, prefsData[name])) + }// }}} + async retrieveServerPreferences() {// {{{ + try { + API.query('GET', '/user/preferences') + } catch (e) { + console.error(e) + alert(`Error retrieving preferences: ${e.message}`) + } + }// }}} + changePreferenceSet(event) {// {{{ + this.preferencesModified() + }// }}} + newSet() {// {{{ + let name = prompt("Name for new preference set") + if (!name) + return + + name = name.trim() + if (name === '') + return + + if (name == 'default') { + alert(`Name can't be "default".`) + return + } + + const exists = this.sets.some(s => s.name.toLowerCase() == name.toLowerCase()) + if (exists) { + alert(`Set with name "${name}" already exist.`) + return + } + + this.sets.push(new N2PreferenceSet(name, {})) + this.preferencesModified() + this.render() + }// }}} + preferencesModified() {// {{{ + this.elSave.removeAttribute('disabled') + }// }}} + preferencesDelete(deleteSet) {// {{{ + if (deleteSet.name == 'default') { + alert("Can't delete the default set.") + return + } + + if (!confirm(`Confirm deleting "${deleteSet.name}"`)) + return + + this.sets = this.sets.filter(set => { + return !(set.name === deleteSet.name) + }) + + this.preferencesModified() + this.render() + }// }}} + async save() {// {{{ + try { + let newPrefs = {} + this.sets.forEach(s => { + const setState = s.getState() + newPrefs[setState.name] = setState.state + }) + + // Throws exception on both HTTP and application errors. + await API.query('POST', '/user/preferences', newPrefs) + + const userData = localStorage.getItem('user') + const user = JSON.parse(userData) + user.Preferences = newPrefs + localStorage.setItem('user', JSON.stringify(user)) + localStorage.setItem('device_preference_set', this.elDevPreferenceSet.value) + _mbus.dispatch('DEVICE_PREFERENCE_SET_UPDATED') + } catch (e) { + console.error(e) + alert(e.message) + } finally { + this.elSave.setAttribute('disabled', true) + } + + }// }}} +} +customElements.define('n2-pagepreferences', N2PagePreferences) + +// Preferences is a set of preferences, of which there can be many named. +export class N2PreferenceSet extends CustomHTMLElement { + static {// {{{ + this.tmpl = document.createElement('template') + this.tmpl.innerHTML = ` + + +
+
+
+
+ +
+ + +
+ + ` + }// }}} + constructor(name, data) {// {{{ + super(true) + this.name = name + this.data = data + this.render() + + // Enable the save button when settings are modified. + this.allFields().forEach(f => + f.addEventListener('input', () => _mbus.dispatch('PREFERENCE_SET_MODIFIED')) + ) + + this.elName.addEventListener('click', () => this.updateName()) + this.elDelete.addEventListener('click', () => this.deleteSet()) + }// }}} + updateName() {// {{{ + if (this.name == 'default') { + alert('Can not change name of the default profile.') + return + } + + const name = prompt("Change name", this.name) + if (!name) + return + + this.name = name + this.render() + _mbus.dispatch('PREFERENCE_SET_MODIFIED') + }// }}} + deleteSet() {// {{{ + _mbus.dispatch('PREFERENCE_SET_DELETE', { set: this }) + }// }}} + render() {// {{{ + this.elName.innerText = this.name + + this.fieldDownloadImages.checked = this.data.DownloadImages + this.fieldDownloadFiles.checked = this.data.DownloadFiles + }// }}} + getState() {// {{{ + const name = this.name.trim() + if (name === '') + throw new Error('Name can not be empty.') + + return { + name: this.name.trim(), + state: this.fieldValues(), + } + }// }}} +} +customElements.define('n2-preferenceset', N2PreferenceSet) diff --git a/static/js/page_storage.mjs b/static/js/page_storage.mjs index 931a718..a007130 100644 --- a/static/js/page_storage.mjs +++ b/static/js/page_storage.mjs @@ -13,7 +13,10 @@ export class N2PageStorage extends CustomHTMLElement { constructor() { super() - window._mbus.subscribe('SHOW_PAGE', () => this.render()) + window._mbus.subscribe('SHOW_PAGE', event => { + if (event.detail.data?.page == 'storage') + this.render() + }) } async render() { const countNodes = await globalThis.nodeStore.nodeCount() diff --git a/static/js/sidebar.mjs b/static/js/sidebar.mjs index 7d73d6a..6cd5814 100644 --- a/static/js/sidebar.mjs +++ b/static/js/sidebar.mjs @@ -1,4 +1,5 @@ -import { ROOT_NODE } from 'node_store' +import { ROOT_NODE, ORPHANED_NODE, DELETED_NODE } from 'node_store' +import { Node } from 'node' import { CustomHTMLElement } from './lib/custom_html_element.mjs' import { Color, Solver } from './lib/css_colorize.mjs' @@ -127,6 +128,7 @@ export class N2Sidebar extends CustomHTMLElement { this.elSearch.addEventListener('click', () => _mbus.dispatch('op-search')) this.elSync.addEventListener('click', () => _sync.run()) this.elLogo.addEventListener('click', () => _app.goToNode(ROOT_NODE, false, false)) + this.elSettings.addEventListener('click', ()=> _mbus.dispatch('SHOW_PAGE', { page: 'preferences' })) this.elHideTree.addEventListener('click', event => { event.stopPropagation() _mbus.dispatch('TREE_EXPANSION', { expand: false }) @@ -156,8 +158,26 @@ export class N2Sidebar extends CustomHTMLElement { this.expandedNodes[ROOT_NODE] = true const startnode = await nodeStore.get(ROOT_NODE) const starttreenode = new N2TreeNode(this, startnode, null) + + const deletednode = await nodeStore.get(DELETED_NODE) + const deletedtreenode = new SpecialNodeDeleted(this, deletednode, null) + + const orphanednode = await nodeStore.get(ORPHANED_NODE) + const orphanedtreenode = new SpecialNodeOrphaned(this, orphanednode, null) + + startnode._sibling_after = deletednode + deletednode._sibling_before = startnode + + deletednode._sibling_after = orphanednode + orphanednode._sibling_before = deletednode + this.treeNodeComponents[startnode.UUID] = starttreenode + this.treeNodeComponents[deletednode.UUID] = deletedtreenode + this.treeNodeComponents[orphanednode.UUID] = orphanedtreenode + this.elTreenodes.appendChild(await starttreenode.render()) + this.elTreenodes.appendChild(await deletedtreenode.render()) + this.elTreenodes.appendChild(await orphanedtreenode.render()) // Notify the application that the initial tree is rendered (with children) // and that initial node selection can take place. App will check URL to @@ -178,9 +198,8 @@ export class N2Sidebar extends CustomHTMLElement { this.expandedNodes[UUID] = false return this.expandedNodes[UUID] }//}}} - setNodeExpanded(node, value) {//{{{ + async setNodeExpanded(node, value) {//{{{ let expanded = this.expandedNodes[node.UUID] - if (expanded === undefined) { this.expandedNodes[node.UUID] = false expanded = false @@ -230,8 +249,6 @@ export class N2Sidebar extends CustomHTMLElement { // Holding shift down does it recursively. case Space: case 'Enter': - if (n.UUID === ROOT_NODE) - return const expanded = this.getNodeExpanded(n.UUID) if (event.shiftKey) { this.recursiveExpand(n, !expanded) @@ -240,38 +257,31 @@ export class N2Sidebar extends CustomHTMLElement { } break - case 'g': case 'Home': this.navigateTop() break - case 'G': case 'End': this.navigateBottom() break - case 'j': case 'ArrowDown': await this.navigateDown(this.selectedNode) break - case 'k': case 'ArrowUp': await this.navigateUp(this.selectedNode) break - case 'h': case 'ArrowLeft': await this.navigateLeft(this.selectedNode) break - case 'l': case 'ArrowRight': await this.navigateRight(this.selectedNode) break default: - // nonsole.log(event.key) handled = false } @@ -393,23 +403,26 @@ export class N2Sidebar extends CustomHTMLElement { }//}}} async navigateTop() {//{{{ const root = await nodeStore.get(ROOT_NODE) - if (root.Children.length === 0) - return - _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[0]?.UUID, dontPush: false, dontExpand: true }) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.UUID, dontPush: false, dontExpand: true }) }//}}} async navigateBottom() {//{{{ - const root = await nodeStore.get(ROOT_NODE) - if (root.Children.length === 0) - return + const orphaned = await nodeStore.get(ORPHANED_NODE) - const toplevel = root.Children[root.Children.length - 1] + if (!orphaned.hasChildren() || this.getNodeExpanded(orphaned.UUID)) { + _mbus.dispatch("GO_TO_NODE", { nodeUUID: orphaned.UUID, dontPush: false, dontExpand: true }) + return + } + + /* TODO - fix this when orphaned nodes are implemented. + const toplevel = orphaned.Children[orphaned.Children.length - 1] const toplevelExpanded = this.getNodeExpanded(toplevel?.UUID) if (toplevelExpanded) { const lastnode = this.getLastExpandedNode(toplevel) _mbus.dispatch("GO_TO_NODE", { nodeUUID: lastnode?.UUID, dontPush: false, dontExpand: true }) } else - _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[root.Children.length - 1]?.UUID, dontPush: false, dontExpand: true }) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: orphaned.Children[orphaned.Children.length - 1]?.UUID, dontPush: false, dontExpand: true }) + */ }//}}} getParentWithNextSibling(node) {//{{{ @@ -430,6 +443,10 @@ export class N2Sidebar extends CustomHTMLElement { if (state) await this.setNodeExpanded(node, true) + // An expanded node needs to have its children fetched. + if (!node.hasFetchedChildren()) + await node.fetchChildren() + for (const child of node.Children) await this.recursiveExpand(child, state) @@ -449,15 +466,22 @@ export class N2Sidebar extends CustomHTMLElement { treenode?.scrollIntoView({ block: 'nearest' }) }// }}} } -customElements.define('n2-sidebar', N2Sidebar) export class N2TreeNode extends CustomHTMLElement { + static DRAG_ICON = new Image() + static DRAG_ICON_OK = new Image() + static {// {{{ + N2TreeNode.DRAG_ICON.src = `/images/${_VERSION}/leaf.svg` + N2TreeNode.DRAG_ICON_OK.src = `/images/${_VERSION}/expanded.svg` + this.tmpl = document.createElement('template') this.tmpl.innerHTML = `
- +
@@ -490,6 +564,7 @@ export class N2TreeNode extends CustomHTMLElement { constructor(sidebar, node, parent) {//{{{ super() + this.setAttribute('draggable', 'true') this.classList.add('node') this.sidebar = sidebar @@ -498,13 +573,100 @@ export class N2TreeNode extends CustomHTMLElement { this.children_populated = false this.rendered = false + this.dragNode = null - this.elExpandToggle.addEventListener('click', () => this.sidebar.setNodeExpanded(this.node, !this.sidebar.getNodeExpanded(this.node.UUID))) + this.elExpandToggle.addEventListener('click', event => { + if (this.node.hasChildren()) + this.expandNode(event) + else + _mbus.dispatch('TREE_NODE_SELECTED', this.node) + }) this.elName.addEventListener('click', () => _mbus.dispatch('TREE_NODE_SELECTED', this.node)) _mbus.subscribe(`NODE_EXPAND_${node.UUID}`, _state => { this.render(true) }) + + // Drag-and-dropping of nodes + this.addEventListener('dragstart', event => this.dragStart(event)) + this.addEventListener('dragend', event => this.dragEnd(event)) + this.addEventListener('dragover', event => this.dragOver(event)) + this.addEventListener('drop', event => this.dragDrop(event)) + this.elName.addEventListener('dragenter', event => this.dragEnter(event)) + this.elName.addEventListener('dragleave', event => this.dragLeave(event)) + }// }}} + + dragStart(e) {// {{{ + if (this.node.isModified()) { + alert('Save note before moving it.') + e.stopPropagation() + e.preventDefault() + return + } + + this.classList.add('drag-source') + const blankPixel = new Image() + blankPixel.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' + e.dataTransfer.setDragImage(blankPixel, 0, 0) + e.dataTransfer.allowedEffects = 'none' + e.stopPropagation() + _app.dragIcon.setSource(this) + _app.dragIcon.start() + }// }}} + dragEnd(e) {// {{{ + this.classList.remove('drag-source') + _app.dragIcon.end() + e.stopPropagation() + }// }}} + dragOver(e) {// {{{ + e.dataTransfer.dropEffect = 'move' + e.preventDefault() + }// }}} + async dragDrop(e) {// {{{ + try { + e.stopPropagation() + const sourceNode = _app.dragIcon.getSource() + + // Abort if user drops the node back on itself. + if (sourceNode.node.UUID === this.node.UUID) + return + + await _app.moveNode(sourceNode.node, this.node.UUID) + + _app.sidebar.setNodeExpanded(this, true) + await this.render(true, true) + await sourceNode.render(true, true) + } catch (e) { + console.error(e) + alert(e) + } finally { + this.dragLeave(e) + } + }// }}} + dragEnter(e) {// {{{ + const targetNode = e.target.closest('n2-treenode') + if (targetNode.classList.contains('drag-source')) + return + e.stopPropagation() + _app.dragIcon.icon('ok') + this.classList.add('drag-target') + }// }}} + dragLeave(e) {// {{{ + e.stopPropagation() + e.dataTransfer.dropEffect = 'none' + e.dataTransfer.setDragImage(N2TreeNode.DRAG_ICON, -16, 8) + _app.dragIcon.icon('') + this.classList.remove('drag-target') + }// }}} + + async expandNode(event) {// {{{ + const expanded = _app.sidebar.getNodeExpanded(this.node.UUID) + + if (event.shiftKey) { + _app.sidebar.recursiveExpand(this.node, !expanded) + } else { + _app.sidebar.setNodeExpanded(this.node, !expanded) + } }// }}} async fetchChildren(force_fetch) {//{{{ if (this.children_populated && !force_fetch) @@ -541,6 +703,17 @@ export class N2TreeNode extends CustomHTMLElement { // The expand icon is only changed to not get a flickering when re-rendering. if (this.node.UUID === ROOT_NODE) this.setImgSrc(this.elExpand, `/images/${window._VERSION}/icon_home.svg`) + + else if (this.node.UUID === DELETED_NODE) { + this.setImgSrc(this.elExpand, `/images/${window._VERSION}/leaf_deleted.svg`) + this.elExpand.classList.add('deleted') + } + + else if (this.node.UUID === ORPHANED_NODE) { + this.setImgSrc(this.elExpand, `/images/${window._VERSION}/leaf_orphaned.svg`) + this.elExpand.classList.add('deleted') + } + else if (!this.node.hasChildren()) this.setImgSrc(this.elExpand, `/images/${window._VERSION}/leaf.svg`) else if (this.sidebar.getNodeExpanded(this.node.UUID)) @@ -575,6 +748,24 @@ export class N2TreeNode extends CustomHTMLElement { img.setAttribute('src', newSrc) }// }}} } + +class SpecialNodeDeleted extends N2TreeNode { + constructor(sidebar, node, parent) {//{{{ + super(sidebar, node, parent) + this.removeAttribute('draggable') + }//}}} +} + +class SpecialNodeOrphaned extends N2TreeNode { + constructor(sidebar, node, parent) {//{{{ + super(sidebar, node, parent) + this.removeAttribute('draggable') + }//}}} +} + +customElements.define('n2-sidebar', N2Sidebar) customElements.define('n2-treenode', N2TreeNode) +customElements.define('n2-specialnodedeleted', SpecialNodeDeleted) +customElements.define('n2-specialnodeorphaned', SpecialNodeOrphaned) // vim: foldmethod=marker diff --git a/static/js/sync.mjs b/static/js/sync.mjs index fe72c3f..daa603f 100644 --- a/static/js/sync.mjs +++ b/static/js/sync.mjs @@ -90,6 +90,7 @@ export class Sync { nodeStore.setAppState('latest_sync_node', currMax) } catch (e) { console.error('sync node tree', e) + alert(e.message) } finally { syncEnd = Date.now() const duration = (syncEnd - syncStart) / 1000 @@ -157,8 +158,8 @@ export class Sync { _mbus.dispatch('SYNC_UPLOADED', { count: nodesToSend.length }) } catch (e) { - console.trace(e) - alert(e.error) + console.error(e) + alert(e.message) return } } diff --git a/user.go b/user.go deleted file mode 100644 index b1c2abf..0000000 --- a/user.go +++ /dev/null @@ -1,27 +0,0 @@ -package main - -import ( - // External - "github.com/golang-jwt/jwt/v5" -) - -type UserSession struct { - UserID int - Username string - Password string - Name string - ClientUUID string -} - -func NewUser(claims jwt.MapClaims) (u UserSession) { - uid, _ := claims["uid"].(float64) - name, _ := claims["name"].(string) - username, _ := claims["login"].(string) - clientUUID, _ := claims["cid"].(string) - - u.UserID = int(uid) - u.Username = username - u.Name = name - u.ClientUUID = clientUUID - return -} diff --git a/user/pkg.go b/user/pkg.go new file mode 100644 index 0000000..bcdfac8 --- /dev/null +++ b/user/pkg.go @@ -0,0 +1,63 @@ +package user + +import ( + // External + "github.com/golang-jwt/jwt/v5" + "github.com/jmoiron/sqlx" + + // Standard + "encoding/json" +) + +type User struct { + ID int + Username string + Name string + Preferences map[string]UserPreferences +} + +type UserSession struct { + UserID int + Username string + Password string + Name string + ClientUUID string + Db *sqlx.DB +} + +type UserPreferences struct { + DownloadImages bool + DownloadFiles bool +} + +func NewUser(claims jwt.MapClaims) (u UserSession) { + uid, _ := claims["uid"].(float64) + name, _ := claims["name"].(string) + username, _ := claims["login"].(string) + clientUUID, _ := claims["cid"].(string) + + u.UserID = int(uid) + u.Username = username + u.Name = name + u.ClientUUID = clientUUID + return +} + +func (u UserSession) Preferences() (prefs map[string]UserPreferences, err error) { + row := u.Db.QueryRow(`SELECT preferences FROM public.user WHERE id=$1`, u.UserID) + + var data []byte + err = row.Scan(&data) + if err != nil { + return + } + + err = json.Unmarshal(data, &prefs) + return +} + +func (u UserSession) SetPreferences(prefs map[string]UserPreferences) (err error) { + j, _ := json.Marshal(prefs) + _, err = u.Db.Exec(`UPDATE public.user SET preferences=$2 WHERE id=$1`, u.UserID, j) + return +} diff --git a/views/pages/notes2.gotmpl b/views/pages/notes2.gotmpl index abec2b0..2755aea 100644 --- a/views/pages/notes2.gotmpl +++ b/views/pages/notes2.gotmpl @@ -1,6 +1,12 @@ {{ define "page" }} + + + + + + -
+
>
@@ -26,23 +32,22 @@
-
- -
+ + + +
- - -