diff --git a/.gitignore b/.gitignore index 7d5585e..1202234 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ notes2 -untracked 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/config.go b/config.go index 71d5f5d..0ba4ea2 100644 --- a/config.go +++ b/config.go @@ -3,6 +3,7 @@ package main import ( // Standard "encoding/json" + "fmt" "os" ) @@ -26,8 +27,9 @@ type Config struct { } } -func readConfig(fname string) (err error) { +func readConfig() (err error) { var configData []byte + fname := fmt.Sprintf("%s/.config/notes2.json", os.Getenv("HOME")) configData, err = os.ReadFile(fname) if err != nil { return diff --git a/html_template/pkg.go b/html_template/pkg.go index a478789..4140f89 100644 --- a/html_template/pkg.go +++ b/html_template/pkg.go @@ -11,7 +11,6 @@ import ( "net/http" "os" "regexp" - "sync" ) type Engine struct { @@ -23,10 +22,6 @@ type Engine struct { DevMode bool } -var ( - templateLock sync.Mutex -) - func NewEngine(viewFS, staticFS fs.FS, devmode bool) (e Engine, err error) { // {{{ e.parsedTemplates = make(map[string]*template.Template) e.viewFS = viewFS @@ -67,12 +62,12 @@ func (e *Engine) getComponentFilenames() (files []string, err error) { // {{{ } // }}} func (e *Engine) ReloadTemplates() { // {{{ - templateLock.Lock() e.parsedTemplates = make(map[string]*template.Template) - templateLock.Unlock() } // }}} func (e *Engine) StaticResource(w http.ResponseWriter, r *http.Request) { // {{{ + var err error + // URLs with pattern /(css|images)/v1.0.0/foobar are stripped of the version. // To get rid of problems with cached content in browser on a new version release, // while also not disabling cache altogether. @@ -88,7 +83,11 @@ func (e *Engine) StaticResource(w http.ResponseWriter, r *http.Request) { // {{{ r.URL.Path = fmt.Sprintf("/%s/%s", comp[1], comp[2]) if e.DevMode { - e.staticLocalFS.ServeHTTP(w, r) + p := fmt.Sprintf("static/%s/%s", comp[1], comp[2]) + _, err = os.Stat(p) + if err == nil { + e.staticLocalFS.ServeHTTP(w, r) + } return } } @@ -126,9 +125,7 @@ func (e *Engine) getPage(layout, page string) (tmpl *template.Template, err erro return } - templateLock.Lock() e.parsedTemplates[page] = tmpl - templateLock.Unlock() return } // }}} func (e *Engine) Render(p Page, w http.ResponseWriter, r *http.Request) (err error) { // {{{ diff --git a/main.go b/main.go index 6e3cf94..d25f72f 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 = "v2" const CONTEXT_USER = 1 const SYNC_PAGINATION = 200 @@ -76,7 +75,7 @@ func initLog() { // {{{ } // }}} func main() { // {{{ initLog() - err := readConfig(FlagConfig) + err := readConfig() if err != nil { Log.Error("config", "error", err) os.Exit(1) @@ -130,21 +129,18 @@ func main() { // {{{ } http.HandleFunc("/", rootHandler) + http.HandleFunc("/notes2", pageNotes2) http.HandleFunc("/login", pageLogin) http.HandleFunc("/sync", pageSync) 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 +177,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) @@ -193,13 +189,7 @@ func rootHandler(w http.ResponseWriter, r *http.Request) { // {{{ // All URLs not specifically handled are routed to this function. // Everything going here should be a static resource. if r.URL.Path == "/" { - page := NewPage("notes2") - - err := Webengine.Render(page, w, r) - if err != nil { - w.Write([]byte(err.Error())) - return - } + http.Redirect(w, r, "/notes2", http.StatusSeeOther) return } @@ -255,6 +245,15 @@ func pageLogin(w http.ResponseWriter, r *http.Request) { // {{{ return } } // }}} +func pageNotes2(w http.ResponseWriter, r *http.Request) { // {{{ + page := NewPage("notes2") + + err := Webengine.Render(page, w, r) + if err != nil { + w.Write([]byte(err.Error())) + return + } +} // }}} func pageSync(w http.ResponseWriter, r *http.Request) { // {{{ page := NewPage("sync") @@ -269,7 +268,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 +279,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 +297,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 +317,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 +332,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 +345,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 +399,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..08d5266 100644 --- a/sql/00001.sql +++ b/sql/00001.sql @@ -1,218 +1,48 @@ --- --- Name: pg_trgm; Type: EXTENSION; Schema: -; Owner: - --- +CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE EXTENSION IF NOT EXISTS pgcrypto; -CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public; +CREATE SEQUENCE node_updates; - --- --- Name: EXTENSION pg_trgm; Type: COMMENT; Schema: -; Owner: --- - -COMMENT ON EXTENSION pg_trgm IS 'text similarity measurement and index searching based on trigrams'; - - --- --- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: - --- - -CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public; - - --- --- Name: EXTENSION pgcrypto; Type: COMMENT; Schema: -; Owner: --- - -COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions'; - - --- --- Name: json_ancestor_array; Type: TYPE; Schema: public; Owner: postgres --- - -CREATE TYPE public.json_ancestor_array AS ( - "Ancestors" character varying[] +CREATE TABLE public."user" ( + id serial4 NOT NULL, + username varchar NOT NULL, + "name" varchar NOT NULL, + "password" varchar NOT NULL, + last_login timestamp DEFAULT now() NOT NULL, + timezone varchar DEFAULT 'UTC'::character varying NOT NULL, + CONSTRAINT user_pk PRIMARY KEY (id) ); --- --- Name: add_nodes(integer, character varying, jsonb); Type: PROCEDURE; Schema: public; Owner: postgres --- +CREATE TABLE public.node ( + id serial4 NOT NULL, + user_id int4 NOT NULL, + "uuid" bpchar(36) DEFAULT gen_random_uuid() NOT NULL, + parent_uuid bpchar(36) NULL, -CREATE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid character varying, IN p_nodes jsonb) - LANGUAGE plpgsql - AS $$ + created timestamptz DEFAULT NOW() NOT NULL, + updated timestamptz DEFAULT NOW() NOT NULL, + deleted timestamptz NULL, -DECLARE - node_data jsonb; - node_updated timestamptz; - db_updated timestamptz; - db_uuid bpchar; - db_client bpchar; - db_client_seq int; - node_uuid bpchar; - parent_uuid_nullable bpchar; + created_seq bigint NOT NULL DEFAULT nextval('node_updates'), + updated_seq bigint NOT NULL DEFAULT nextval('node_updates'), + deleted_seq bigint NULL, -BEGIN - RAISE NOTICE '--------------------------'; - FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes) - LOOP - node_uuid = (node_data->>'UUID')::bpchar; - node_updated = (node_data->>'Updated')::timestamptz; + "name" varchar(256) DEFAULT ''::character varying NOT NULL, + "content" text DEFAULT ''::text NOT NULL, + content_encrypted text DEFAULT ''::text NOT NULL, + markdown bool DEFAULT false NOT NULL, - IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' THEN - parent_uuid_nullable = NULL; - ELSE - parent_uuid_nullable = node_data->>'ParentUUID'; - END IF; + CONSTRAINT name_length CHECK ((length(TRIM(BOTH FROM name)) > 0)), + CONSTRAINT node_pk PRIMARY KEY (id), + CONSTRAINT user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE RESTRICT ON UPDATE RESTRICT +); +CREATE UNIQUE INDEX node_uuid_idx ON public.node USING btree (uuid); +CREATE INDEX node_search_index ON public.node USING gin (name gin_trgm_ops, content gin_trgm_ops); - /* Retrieve the current modified timestamp for this node from the database. */ - SELECT - uuid, updated, client, client_sequence - INTO - db_uuid, db_updated, db_client, db_client_seq - FROM public."node" - WHERE - user_id = p_user_id AND - uuid = node_uuid; - - /* Is the node not in database? It needs to be created. */ - IF db_uuid IS NULL THEN - RAISE NOTICE '01 New node %', node_uuid; - INSERT INTO public."node" ( - user_id, "uuid", parent_uuid, created, updated, - "name", "content", markdown, "content_encrypted", - client, client_sequence - ) - VALUES( - p_user_id, - node_uuid, - parent_uuid_nullable, - (node_data->>'Created')::timestamptz, - (node_data->>'Updated')::timestamptz, - (node_data->>'Name')::varchar, - (node_data->>'Content')::text, - (node_data->>'Markdown')::bool, - '', /* content_encrypted */ - p_client_uuid, - (node_data->>'ClientSequence')::int - ); - CONTINUE; - END IF; - - - /* The client could send a specific node again if it didn't receive the OK from this procedure before. */ - IF db_updated = node_updated AND db_client = p_client_uuid AND db_client_seq = (node_data->>'ClientSequence')::int THEN - RAISE NOTICE '04, already recorded, %, %', db_client, db_client_seq; - CONTINUE; - END IF; - - /* Determine if the incoming node data is to go into history or replace the current node. */ - IF db_updated > node_updated THEN - RAISE NOTICE '02 DB newer, % > % (%))', db_updated, node_updated, node_uuid; - /* Incoming node is going straight to history since it is older than the current node. */ - INSERT INTO node_history( - user_id, "uuid", parents, created, updated, - "name", "content", markdown, "content_encrypted", - client, client_sequence - ) - VALUES( - p_user_id, - node_uuid, - (jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors", - (node_data->>'Created')::timestamptz, - (node_data->>'Updated')::timestamptz, - (node_data->>'Name')::varchar, - (node_data->>'Content')::text, - (node_data->>'Markdown')::bool, - '', /* content_encrypted */ - p_client_uuid, - (node_data->>'ClientSequence')::int - ) - ON CONFLICT (client, client_sequence) - DO NOTHING; - ELSE - RAISE NOTICE '03 Client newer, % > % (%, %)', node_updated, db_updated, node_uuid, (node_data->>'ClientSequence'); - /* Incoming node is newer and will replace the current node. - * - * The current node is copied to the node_history table and then modified in place - * with the incoming data. */ - INSERT INTO node_history( - user_id, "uuid", parents, - created, updated, "name", "content", markdown, "content_encrypted", - client, client_sequence - ) - SELECT - user_id, - "uuid", - ( - WITH RECURSIVE nodes AS ( - SELECT - uuid, - COALESCE(parent_uuid, '') AS parent_uuid, - name, - 0 AS depth - FROM node - WHERE - uuid = node_uuid - - UNION - - SELECT - n.uuid, - COALESCE(n.parent_uuid, '') AS parent_uuid, - n.name, - nr.depth+1 AS depth - FROM node n - INNER JOIN nodes nr ON n.uuid = nr.parent_uuid - ) - SELECT ARRAY ( - SELECT name - FROM nodes - ORDER BY depth DESC - OFFSET 1 /* discard itself */ - ) - ), - created, - updated, - name, - content, - markdown, - content_encrypted, - client, - client_sequence - FROM public."node" - WHERE - user_id = p_user_id AND - uuid = node_uuid - ON CONFLICT (client, client_sequence) - DO NOTHING; - - /* Current node in database is updated with incoming data. */ - UPDATE public."node" - SET - updated = (node_data->>'Updated')::timestamptz, - updated_seq = nextval('node_updates'), - name = (node_data->>'Name')::varchar, - content = (node_data->>'Content')::text, - markdown = (node_data->>'Markdown')::bool, - client = p_client_uuid, - client_sequence = (node_data->>'ClientSequence')::int - WHERE - user_id = p_user_id AND - uuid = node_uuid; - END IF; - - END LOOP; -END -$$; - --- --- Name: node_update_timestamp(); Type: FUNCTION; Schema: public; Owner: postgres --- - -CREATE FUNCTION public.node_update_timestamp() RETURNS trigger - LANGUAGE plpgsql - AS $$ +CREATE OR REPLACE FUNCTION node_update_timestamp() +RETURNS TRIGGER +LANGUAGE PLPGSQL +AS $$ BEGIN IF NEW.updated = OLD.updated THEN UPDATE node @@ -226,335 +56,6 @@ BEGIN END; $$; - --- --- Name: password_hash(character, bytea); Type: FUNCTION; Schema: public; Owner: postgres --- - -CREATE FUNCTION public.password_hash(salt_hex character, pass bytea) RETURNS character - LANGUAGE plpgsql - AS $$ -BEGIN - RETURN ( - SELECT - salt_hex || - encode( - sha256( - decode(salt_hex, 'hex') || /* salt in binary */ - pass /* password */ - ), - 'hex' - ) - ); -END; -$$; - - --- --- Name: client; Type: TABLE; Schema: public; Owner: postgres --- - -CREATE TABLE public.client ( - id integer NOT NULL, - user_id integer NOT NULL, - client_uuid uuid DEFAULT '00000000-0000-0000-0000-000000000000'::uuid NOT NULL, - created timestamp with time zone DEFAULT now() NOT NULL, - description character varying DEFAULT ''::character varying NOT NULL -); - - --- --- Name: client_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres --- - -CREATE SEQUENCE public.client_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: client_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres --- - -ALTER SEQUENCE public.client_id_seq OWNED BY public.client.id; - - --- --- Name: node_updates; Type: SEQUENCE; Schema: public; Owner: postgres --- - -CREATE SEQUENCE public.node_updates - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: node; Type: TABLE; Schema: public; Owner: postgres --- - -CREATE TABLE public.node ( - id integer NOT NULL, - user_id integer NOT NULL, - "uuid" uuid DEFAULT gen_random_uuid() NOT NULL, - parent_uuid uuid, - created timestamp with time zone DEFAULT now() NOT NULL, - updated timestamp with time zone DEFAULT now() NOT NULL, - deleted timestamp with time zone, - created_seq bigint DEFAULT nextval('public.node_updates'::regclass) NOT NULL, - updated_seq bigint DEFAULT nextval('public.node_updates'::regclass) NOT NULL, - deleted_seq bigint, - name character varying(256) DEFAULT ''::character varying NOT NULL, - content text DEFAULT ''::text NOT NULL, - content_encrypted text DEFAULT ''::text NOT NULL, - markdown boolean DEFAULT false NOT NULL, - history boolean DEFAULT false NOT NULL, - client uuid DEFAULT '00000000-0000-0000-0000-000000000000'::uuid NOT NULL, - client_sequence integer, - CONSTRAINT name_length CHECK ((length(TRIM(BOTH FROM name)) > 0)) -); - - --- --- Name: node_history; Type: TABLE; Schema: public; Owner: postgres --- - -CREATE TABLE public.node_history ( - id integer NOT NULL, - user_id integer NOT NULL, - "uuid" uuid NOT NULL, - parents character varying[], - created timestamp with time zone NOT NULL, - updated timestamp with time zone NOT NULL, - name character varying(256) NOT NULL, - content text NOT NULL, - content_encrypted text NOT NULL, - markdown boolean DEFAULT false NOT NULL, - client uuid DEFAULT '00000000-0000-0000-0000-000000000000'::uuid NOT NULL, - client_sequence integer -); - - --- --- Name: node_history_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres --- - -CREATE SEQUENCE public.node_history_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: node_history_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres --- - -ALTER SEQUENCE public.node_history_id_seq OWNED BY public.node_history.id; - - --- --- Name: node_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres --- - -CREATE SEQUENCE public.node_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: node_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres --- - -ALTER SEQUENCE public.node_id_seq OWNED BY public.node.id; - - --- --- Name: test_data; Type: SEQUENCE; Schema: public; Owner: postgres --- - -CREATE SEQUENCE public.test_data - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: user; Type: TABLE; Schema: public; Owner: postgres --- - -CREATE TABLE public."user" ( - id integer NOT NULL, - username character varying NOT NULL, - name character varying NOT NULL, - password character varying NOT NULL, - last_login timestamp without time zone DEFAULT now() NOT NULL, - timezone character varying DEFAULT 'UTC'::character varying NOT NULL -); - - --- --- Name: user_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres --- - -CREATE SEQUENCE public.user_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: user_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres --- - -ALTER SEQUENCE public.user_id_seq OWNED BY public."user".id; - - --- --- Name: client id; Type: DEFAULT; Schema: public; Owner: postgres --- - -ALTER TABLE ONLY public.client ALTER COLUMN id SET DEFAULT nextval('public.client_id_seq'::regclass); - - --- --- Name: node id; Type: DEFAULT; Schema: public; Owner: postgres --- - -ALTER TABLE ONLY public.node ALTER COLUMN id SET DEFAULT nextval('public.node_id_seq'::regclass); - - --- --- Name: node_history id; Type: DEFAULT; Schema: public; Owner: postgres --- - -ALTER TABLE ONLY public.node_history ALTER COLUMN id SET DEFAULT nextval('public.node_history_id_seq'::regclass); - - --- --- Name: user id; Type: DEFAULT; Schema: public; Owner: postgres --- - -ALTER TABLE ONLY public."user" ALTER COLUMN id SET DEFAULT nextval('public.user_id_seq'::regclass); - - --- --- Name: client client_pk; Type: CONSTRAINT; Schema: public; Owner: postgres --- - -ALTER TABLE ONLY public.client - ADD CONSTRAINT client_pk PRIMARY KEY (id); - - --- --- Name: node_history node_history_pk; Type: CONSTRAINT; Schema: public; Owner: postgres --- - -ALTER TABLE ONLY public.node_history - ADD CONSTRAINT node_history_pk PRIMARY KEY (id); - - --- --- Name: node node_pk; Type: CONSTRAINT; Schema: public; Owner: postgres --- - -ALTER TABLE ONLY public.node - ADD CONSTRAINT node_pk PRIMARY KEY (id); - - --- --- Name: user user_pk; Type: CONSTRAINT; Schema: public; Owner: postgres --- - -ALTER TABLE ONLY public."user" - ADD CONSTRAINT user_pk PRIMARY KEY (id); - - --- --- Name: client_uuid_idx; Type: INDEX; Schema: public; Owner: postgres --- - -CREATE UNIQUE INDEX client_uuid_idx ON public.client USING btree (client_uuid); - - --- --- Name: node_history_client_idx; Type: INDEX; Schema: public; Owner: postgres --- - -CREATE UNIQUE INDEX node_history_client_idx ON public.node_history USING btree (client, client_sequence); - - --- --- Name: node_history_idx; Type: INDEX; Schema: public; Owner: postgres --- - -CREATE INDEX node_history_idx ON public.node USING btree (history); - - --- --- Name: node_history_uuid_idx; Type: INDEX; Schema: public; Owner: postgres --- - -CREATE INDEX node_history_uuid_idx ON public.node USING btree (uuid); - - --- --- Name: node_search_index; Type: INDEX; Schema: public; Owner: postgres --- - -CREATE INDEX node_search_index ON public.node USING gin (name public.gin_trgm_ops, content public.gin_trgm_ops); - - --- --- Name: node_uuid_idx; Type: INDEX; Schema: public; Owner: postgres --- - -CREATE UNIQUE INDEX node_uuid_idx ON public.node USING btree (uuid); - - --- --- Name: node node_update; Type: TRIGGER; Schema: public; Owner: postgres --- - -CREATE TRIGGER node_update AFTER UPDATE ON public.node FOR EACH ROW EXECUTE FUNCTION public.node_update_timestamp(); - - --- --- Name: node_history node_history_user_fk; Type: FK CONSTRAINT; Schema: public; Owner: postgres --- - -ALTER TABLE ONLY public.node_history - ADD CONSTRAINT node_history_user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON UPDATE RESTRICT ON DELETE RESTRICT; - - --- --- Name: node node_node_fk; Type: FK CONSTRAINT; Schema: public; Owner: postgres --- - -ALTER TABLE ONLY public.node - ADD CONSTRAINT node_node_fk FOREIGN KEY (parent_uuid) REFERENCES public.node(uuid) ON UPDATE SET NULL ON DELETE SET NULL; - - --- --- Name: node user_fk; Type: FK CONSTRAINT; Schema: public; Owner: postgres --- - -ALTER TABLE ONLY public.node - ADD CONSTRAINT user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON UPDATE RESTRICT ON DELETE RESTRICT; +CREATE OR REPLACE TRIGGER node_update AFTER UPDATE ON node +FOR EACH ROW +EXECUTE PROCEDURE node_update_timestamp(); diff --git a/sql/00002.sql b/sql/00002.sql index 1775dc3..9d7bd8a 100644 --- a/sql/00002.sql +++ b/sql/00002.sql @@ -1,168 +1,19 @@ -CREATE OR REPLACE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid character varying, IN p_nodes jsonb) - LANGUAGE plpgsql -AS $procedure$ - -DECLARE - node_data jsonb; - node_updated timestamptz; - db_updated timestamptz; - db_uuid bpchar; - db_client bpchar; - db_client_seq int; - node_uuid bpchar; - parent_uuid bpchar; - +CREATE FUNCTION public.password_hash(salt_hex char(32), pass bytea) +RETURNS char(96) +LANGUAGE plpgsql +AS +$$ BEGIN - RAISE NOTICE '--------------------------'; - FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes) - LOOP - node_uuid = (node_data->>'UUID')::bpchar; - node_updated = (node_data->>'Updated')::timestamptz; - - IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' THEN - parent_uuid = NULL; - ELSE - parent_uuid = node_data->>'ParentUUID'; - END IF; - - /* Retrieve the current modified timestamp for this node from the database. */ + RETURN ( SELECT - uuid, updated, client, client_sequence - INTO - db_uuid, db_updated, db_client, db_client_seq - FROM public."node" - WHERE - user_id = p_user_id AND - uuid = node_uuid; - - /* Is the node not in database? It needs to be created. */ - IF db_uuid IS NULL THEN - RAISE NOTICE '01 New node %', node_uuid; - INSERT INTO public."node" ( - user_id, "uuid", parent_uuid, created, updated, - "name", "content", markdown, "content_encrypted", - client, client_sequence - ) - VALUES( - p_user_id, - node_uuid, - parent_uuid, - (node_data->>'Created')::timestamptz, - (node_data->>'Updated')::timestamptz, - (node_data->>'Name')::varchar, - (node_data->>'Content')::text, - (node_data->>'Markdown')::bool, - '', /* content_encrypted */ - p_client_uuid, - (node_data->>'ClientSequence')::int - ); - CONTINUE; - END IF; - - - /* The client could send a specific node again if it didn't receive the OK from this procedure before. */ - IF db_updated = node_updated AND db_client = p_client_uuid AND db_client_seq = (node_data->>'ClientSequence')::int THEN - RAISE NOTICE '04, already recorded, %, %', db_client, db_client_seq; - CONTINUE; - END IF; - - /* Determine if the incoming node data is to go into history or replace the current node. */ - IF db_updated > node_updated THEN - RAISE NOTICE '02 DB newer, % > % (%))', db_updated, node_updated, node_uuid; - /* Incoming node is going straight to history since it is older than the current node. */ - INSERT INTO node_history( - user_id, "uuid", parents, created, updated, - "name", "content", markdown, "content_encrypted", - client, client_sequence - ) - VALUES( - p_user_id, - node_uuid, - (jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors", - (node_data->>'Created')::timestamptz, - (node_data->>'Updated')::timestamptz, - (node_data->>'Name')::varchar, - (node_data->>'Content')::text, - (node_data->>'Markdown')::bool, - '', /* content_encrypted */ - p_client_uuid, - (node_data->>'ClientSequence')::int - ) - ON CONFLICT (client, client_sequence) - DO NOTHING; - ELSE - RAISE NOTICE '03 Client newer, % > % (%, %)', node_updated, db_updated, node_uuid, (node_data->>'ClientSequence'); - /* Incoming node is newer and will replace the current node. - * - * The current node is copied to the node_history table and then modified in place - * with the incoming data. */ - INSERT INTO node_history( - user_id, "uuid", parents, - created, updated, "name", "content", markdown, "content_encrypted", - client, client_sequence - ) - SELECT - user_id, - "uuid", - ( - WITH RECURSIVE nodes AS ( - SELECT - uuid, - COALESCE(node.parent_uuid, '') AS parent_uuid, - name, - 0 AS depth - FROM node - WHERE - uuid = node_uuid - - UNION - - SELECT - n.uuid, - COALESCE(n.parent_uuid, '') AS parent_uuid, - n.name, - nr.depth+1 AS depth - FROM node n - INNER JOIN nodes nr ON n.uuid = nr.parent_uuid - ) - SELECT ARRAY ( - SELECT name - FROM nodes - ORDER BY depth DESC - OFFSET 1 /* discard itself */ - ) + salt_hex || + encode( + sha256( + decode(salt_hex, 'hex') || /* salt in binary */ + pass /* password */ ), - created, - updated, - name, - content, - markdown, - content_encrypted, - client, - client_sequence - FROM public."node" - WHERE - user_id = p_user_id AND - uuid = node_uuid - ON CONFLICT (client, client_sequence) - DO NOTHING; - - /* Current node in database is updated with incoming data. */ - UPDATE public."node" - SET - updated = (node_data->>'Updated')::timestamptz, - updated_seq = nextval('node_updates'), - name = (node_data->>'Name')::varchar, - content = (node_data->>'Content')::text, - markdown = (node_data->>'Markdown')::bool, - client = p_client_uuid, - client_sequence = (node_data->>'ClientSequence')::int - WHERE - user_id = p_user_id AND - uuid = node_uuid; - END IF; - - END LOOP; -END -$procedure$ -; + 'hex' + ) + ); +END; +$$; diff --git a/sql/00003.sql b/sql/00003.sql index a0cd4b1..0fb2a51 100644 --- a/sql/00003.sql +++ b/sql/00003.sql @@ -1 +1 @@ -ALTER TABLE public.node_history ADD history_uuid uuid NULL; +ALTER TABLE public.node ADD CONSTRAINT node_node_fk FOREIGN KEY (parent_uuid) REFERENCES public.node("uuid") ON DELETE SET NULL ON UPDATE SET NULL; diff --git a/sql/00004.sql b/sql/00004.sql index eafbad2..7ac464d 100644 --- a/sql/00004.sql +++ b/sql/00004.sql @@ -1,135 +1,2 @@ -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$ -; +ALTER TABLE public.node ADD COLUMN history bool NOT NULL DEFAULT false; +CREATE INDEX node_history_idx ON public.node (history); diff --git a/sql/00005.sql b/sql/00005.sql index b272085..a366070 100644 --- a/sql/00005.sql +++ b/sql/00005.sql @@ -1,129 +1 @@ --- 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$ -; +ALTER TABLE public.node ADD COLUMN client bpchar(36) NOT NULL DEFAULT ''; diff --git a/sql/00006.sql b/sql/00006.sql index 56f2acb..453b260 100644 --- a/sql/00006.sql +++ b/sql/00006.sql @@ -1,119 +1 @@ -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$ -; +DROP INDEX public.node_uuid_idx; diff --git a/sql/00007.sql b/sql/00007.sql index 0b79d9c..40ce48e 100644 --- a/sql/00007.sql +++ b/sql/00007.sql @@ -1,119 +1,162 @@ -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$ +CREATE TYPE json_ancestor_array as ("Ancestors" varchar[]); + + +CREATE OR REPLACE PROCEDURE add_nodes(p_user_id int4, p_client_uuid varchar, p_nodes jsonb) +LANGUAGE PLPGSQL AS $$ DECLARE - node_data jsonb; - node_updated timestamptz; - db_updated timestamptz; - db_uuid uuid; - db_client uuid; - db_history_uuid uuid; - node_uuid uuid; - node_parent_uuid uuid; - node_history_uuid uuid; + node_data jsonb; + node_updated timestamptz; + db_updated timestamptz; + db_uuid bpchar; + db_client bpchar; + db_client_seq int; + node_uuid bpchar; BEGIN + RAISE NOTICE '--------------------------'; FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes) - LOOP - node_uuid = (node_data->>'UUID')::uuid; - node_history_uuid = (node_data->>'HistoryUUID')::uuid; - node_updated = (node_data->>'Updated')::timestamptz; + LOOP + node_uuid = (node_data->>'UUID')::bpchar; + 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. + /* Retrieve the current modified timestamp for this node from the database. */ SELECT - uuid, updated, client + uuid, updated, client, client_sequence INTO - db_uuid, db_updated, db_client + db_uuid, db_updated, db_client, db_client_seq FROM public."node" WHERE user_id = p_user_id AND - uuid::uuid = node_uuid::uuid; + uuid = node_uuid; - - - -- Is the node not in database? It needs to be created. + /* 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 + "name", "content", markdown, "content_encrypted", + client, client_sequence ) VALUES( p_user_id, node_uuid, - node_parent_uuid, - COALESCE((node_data->>'Created')::timestamptz, NOW()), - COALESCE((node_data->>'Updated')::timestamptz, NOW()), + (node_data->>'ParentUUID')::bpchar, + (node_data->>'Created')::timestamptz, + (node_data->>'Updated')::timestamptz, (node_data->>'Name')::varchar, (node_data->>'Content')::text, + (node_data->>'Markdown')::bool, '', /* content_encrypted */ - p_client_uuid + p_client_uuid, + (node_data->>'ClientSequence')::int ); - CONTINUE; - END IF; + + /* The client could send a specific node again if it didn't receive the OK from this procedure before. */ + IF db_updated = node_updated AND db_client = p_client_uuid AND db_client_seq = (node_data->>'ClientSequence')::int THEN + RAISE NOTICE '04, already recorded, %, %', db_client, db_client_seq; + CONTINUE; + END IF; + /* Determine if the incoming node data is to go into history or replace the current node. */ + IF db_updated > node_updated THEN + RAISE NOTICE '02 DB newer, % > % (%))', db_updated, node_updated, node_uuid; + /* Incoming node is going straight to history since it is older than the current node. */ + INSERT INTO node_history( + user_id, "uuid", parents, created, updated, + "name", "content", markdown, "content_encrypted", + client, client_sequence + ) + VALUES( + p_user_id, + node_uuid, + (jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors", + (node_data->>'Created')::timestamptz, + (node_data->>'Updated')::timestamptz, + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + (node_data->>'Markdown')::bool, + '', /* content_encrypted */ + p_client_uuid, + (node_data->>'ClientSequence')::int + ) + ON CONFLICT (client, client_sequence) + DO NOTHING; + ELSE + RAISE NOTICE '03 Client newer, % > % (%, %)', node_updated, db_updated, node_uuid, (node_data->>'ClientSequence'); + /* Incoming node is newer and will replace the current node. + * + * The current node is copied to the node_history table and then modified in place + * with the incoming data. */ + INSERT INTO node_history( + user_id, "uuid", parents, + created, updated, "name", "content", markdown, "content_encrypted", + client, client_sequence + ) + SELECT + user_id, + "uuid", + ( + WITH RECURSIVE nodes AS ( + SELECT + uuid, + COALESCE(parent_uuid, '') AS parent_uuid, + name, + 0 AS depth + FROM node + WHERE + uuid = node_uuid - -- Update the public node as well if it was older than incoming node. - IF node_updated > db_updated THEN + UNION + + SELECT + n.uuid, + COALESCE(n.parent_uuid, '') AS parent_uuid, + n.name, + nr.depth+1 AS depth + FROM node n + INNER JOIN nodes nr ON n.uuid = nr.parent_uuid + ) + SELECT ARRAY ( + SELECT name + FROM nodes + ORDER BY depth DESC + OFFSET 1 /* discard itself */ + ) + ), + created, + updated, + name, + content, + markdown, + content_encrypted, + client, + client_sequence + FROM public."node" + WHERE + user_id = p_user_id AND + uuid = node_uuid + ON CONFLICT (client, client_sequence) + DO NOTHING; + + /* Current node in database is updated with incoming data. */ UPDATE public."node" SET updated = (node_data->>'Updated')::timestamptz, updated_seq = nextval('node_updates'), - parent_uuid = node_parent_uuid, name = (node_data->>'Name')::varchar, content = (node_data->>'Content')::text, - client = p_client_uuid + 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; + uuid = node_uuid; END IF; - END LOOP; + END LOOP; END -$procedure$ -; +$$; diff --git a/sql/00008.sql b/sql/00008.sql index 2701ba5..a91d54c 100644 --- a/sql/00008.sql +++ b/sql/00008.sql @@ -1,123 +1,2 @@ -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$ -; +ALTER TABLE node ADD COLUMN Client_sequence int NULL; +ALTER TABLE node_history ADD COLUMN Client_sequence int NULL; diff --git a/sql/00009.sql b/sql/00009.sql index 50487f3..332af3a 100644 --- a/sql/00009.sql +++ b/sql/00009.sql @@ -1,35 +1 @@ --- 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(); +CREATE UNIQUE INDEX node_history_client_idx ON public.node_history (client,client_sequence); diff --git a/sql/00010.sql b/sql/00010.sql index ecd8ab4..c0f14ee 100644 --- a/sql/00010.sql +++ b/sql/00010.sql @@ -1 +1,10 @@ -ALTER TABLE public."user" ADD preferences jsonb DEFAULT '{}' NOT NULL; +CREATE TABLE public.client ( + id serial NOT NULL, + user_id int4 NOT NULL, + client_uuid bpchar(36) DEFAULT '' NOT NULL, + created timestamptz DEFAULT NOW() NOT NULL, + description varchar DEFAULT '' NOT NULL, + CONSTRAINT client_pk PRIMARY KEY (id) +); + +CREATE UNIQUE INDEX client_uuid_idx ON public.client (client_uuid); diff --git a/sql/00011.sql b/sql/00011.sql new file mode 100644 index 0000000..5b67839 --- /dev/null +++ b/sql/00011.sql @@ -0,0 +1,166 @@ +CREATE OR REPLACE PROCEDURE add_nodes(p_user_id int4, p_client_uuid varchar, p_nodes jsonb) +LANGUAGE PLPGSQL AS $$ + +DECLARE + node_data jsonb; + node_updated timestamptz; + db_updated timestamptz; + db_uuid bpchar; + db_client bpchar; + db_client_seq int; + node_uuid bpchar; + parent_uuid bpchar; + +BEGIN + RAISE NOTICE '--------------------------'; + FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes) + LOOP + node_uuid = (node_data->>'UUID')::bpchar; + node_updated = (node_data->>'Updated')::timestamptz; + + IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' THEN + parent_uuid = NULL; + ELSE + parent_uuid = node_data->>'ParentUUID'; + END IF; + + /* Retrieve the current modified timestamp for this node from the database. */ + SELECT + uuid, updated, client, client_sequence + INTO + db_uuid, db_updated, db_client, db_client_seq + FROM public."node" + WHERE + user_id = p_user_id AND + uuid = node_uuid; + + /* Is the node not in database? It needs to be created. */ + IF db_uuid IS NULL THEN + RAISE NOTICE '01 New node %', node_uuid; + INSERT INTO public."node" ( + user_id, "uuid", parent_uuid, created, updated, + "name", "content", markdown, "content_encrypted", + client, client_sequence + ) + VALUES( + p_user_id, + node_uuid, + parent_uuid, + (node_data->>'Created')::timestamptz, + (node_data->>'Updated')::timestamptz, + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + (node_data->>'Markdown')::bool, + '', /* content_encrypted */ + p_client_uuid, + (node_data->>'ClientSequence')::int + ); + CONTINUE; + END IF; + + + /* The client could send a specific node again if it didn't receive the OK from this procedure before. */ + IF db_updated = node_updated AND db_client = p_client_uuid AND db_client_seq = (node_data->>'ClientSequence')::int THEN + RAISE NOTICE '04, already recorded, %, %', db_client, db_client_seq; + CONTINUE; + END IF; + + /* Determine if the incoming node data is to go into history or replace the current node. */ + IF db_updated > node_updated THEN + RAISE NOTICE '02 DB newer, % > % (%))', db_updated, node_updated, node_uuid; + /* Incoming node is going straight to history since it is older than the current node. */ + INSERT INTO node_history( + user_id, "uuid", parents, created, updated, + "name", "content", markdown, "content_encrypted", + client, client_sequence + ) + VALUES( + p_user_id, + node_uuid, + (jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors", + (node_data->>'Created')::timestamptz, + (node_data->>'Updated')::timestamptz, + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + (node_data->>'Markdown')::bool, + '', /* content_encrypted */ + p_client_uuid, + (node_data->>'ClientSequence')::int + ) + ON CONFLICT (client, client_sequence) + DO NOTHING; + ELSE + RAISE NOTICE '03 Client newer, % > % (%, %)', node_updated, db_updated, node_uuid, (node_data->>'ClientSequence'); + /* Incoming node is newer and will replace the current node. + * + * The current node is copied to the node_history table and then modified in place + * with the incoming data. */ + INSERT INTO node_history( + user_id, "uuid", parents, + created, updated, "name", "content", markdown, "content_encrypted", + client, client_sequence + ) + SELECT + user_id, + "uuid", + ( + WITH RECURSIVE nodes AS ( + SELECT + uuid, + COALESCE(parent_uuid, '') AS parent_uuid, + name, + 0 AS depth + FROM node + WHERE + uuid = node_uuid + + UNION + + SELECT + n.uuid, + COALESCE(n.parent_uuid, '') AS parent_uuid, + n.name, + nr.depth+1 AS depth + FROM node n + INNER JOIN nodes nr ON n.uuid = nr.parent_uuid + ) + SELECT ARRAY ( + SELECT name + FROM nodes + ORDER BY depth DESC + OFFSET 1 /* discard itself */ + ) + ), + created, + updated, + name, + content, + markdown, + content_encrypted, + client, + client_sequence + FROM public."node" + WHERE + user_id = p_user_id AND + uuid = node_uuid + ON CONFLICT (client, client_sequence) + DO NOTHING; + + /* Current node in database is updated with incoming data. */ + UPDATE public."node" + SET + updated = (node_data->>'Updated')::timestamptz, + updated_seq = nextval('node_updates'), + name = (node_data->>'Name')::varchar, + content = (node_data->>'Content')::text, + markdown = (node_data->>'Markdown')::bool, + client = p_client_uuid, + client_sequence = (node_data->>'ClientSequence')::int + WHERE + user_id = p_user_id AND + uuid = node_uuid; + END IF; + + END LOOP; +END +$$; diff --git a/sql/00012.sql b/sql/00012.sql new file mode 100644 index 0000000..e62f011 --- /dev/null +++ b/sql/00012.sql @@ -0,0 +1,166 @@ +CREATE OR REPLACE PROCEDURE add_nodes(p_user_id int4, p_client_uuid varchar, p_nodes jsonb) +LANGUAGE PLPGSQL AS $$ + +DECLARE + node_data jsonb; + node_updated timestamptz; + db_updated timestamptz; + db_uuid bpchar; + db_client bpchar; + db_client_seq int; + node_uuid bpchar; + parent_uuid_nullable bpchar; + +BEGIN + RAISE NOTICE '--------------------------'; + FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes) + LOOP + node_uuid = (node_data->>'UUID')::bpchar; + node_updated = (node_data->>'Updated')::timestamptz; + + IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' THEN + parent_uuid_nullable = NULL; + ELSE + parent_uuid_nullable = node_data->>'ParentUUID'; + END IF; + + /* Retrieve the current modified timestamp for this node from the database. */ + SELECT + uuid, updated, client, client_sequence + INTO + db_uuid, db_updated, db_client, db_client_seq + FROM public."node" + WHERE + user_id = p_user_id AND + uuid = node_uuid; + + /* Is the node not in database? It needs to be created. */ + IF db_uuid IS NULL THEN + RAISE NOTICE '01 New node %', node_uuid; + INSERT INTO public."node" ( + user_id, "uuid", parent_uuid, created, updated, + "name", "content", markdown, "content_encrypted", + client, client_sequence + ) + VALUES( + p_user_id, + node_uuid, + parent_uuid_nullable, + (node_data->>'Created')::timestamptz, + (node_data->>'Updated')::timestamptz, + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + (node_data->>'Markdown')::bool, + '', /* content_encrypted */ + p_client_uuid, + (node_data->>'ClientSequence')::int + ); + CONTINUE; + END IF; + + + /* The client could send a specific node again if it didn't receive the OK from this procedure before. */ + IF db_updated = node_updated AND db_client = p_client_uuid AND db_client_seq = (node_data->>'ClientSequence')::int THEN + RAISE NOTICE '04, already recorded, %, %', db_client, db_client_seq; + CONTINUE; + END IF; + + /* Determine if the incoming node data is to go into history or replace the current node. */ + IF db_updated > node_updated THEN + RAISE NOTICE '02 DB newer, % > % (%))', db_updated, node_updated, node_uuid; + /* Incoming node is going straight to history since it is older than the current node. */ + INSERT INTO node_history( + user_id, "uuid", parents, created, updated, + "name", "content", markdown, "content_encrypted", + client, client_sequence + ) + VALUES( + p_user_id, + node_uuid, + (jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors", + (node_data->>'Created')::timestamptz, + (node_data->>'Updated')::timestamptz, + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + (node_data->>'Markdown')::bool, + '', /* content_encrypted */ + p_client_uuid, + (node_data->>'ClientSequence')::int + ) + ON CONFLICT (client, client_sequence) + DO NOTHING; + ELSE + RAISE NOTICE '03 Client newer, % > % (%, %)', node_updated, db_updated, node_uuid, (node_data->>'ClientSequence'); + /* Incoming node is newer and will replace the current node. + * + * The current node is copied to the node_history table and then modified in place + * with the incoming data. */ + INSERT INTO node_history( + user_id, "uuid", parents, + created, updated, "name", "content", markdown, "content_encrypted", + client, client_sequence + ) + SELECT + user_id, + "uuid", + ( + WITH RECURSIVE nodes AS ( + SELECT + uuid, + COALESCE(parent_uuid, '') AS parent_uuid, + name, + 0 AS depth + FROM node + WHERE + uuid = node_uuid + + UNION + + SELECT + n.uuid, + COALESCE(n.parent_uuid, '') AS parent_uuid, + n.name, + nr.depth+1 AS depth + FROM node n + INNER JOIN nodes nr ON n.uuid = nr.parent_uuid + ) + SELECT ARRAY ( + SELECT name + FROM nodes + ORDER BY depth DESC + OFFSET 1 /* discard itself */ + ) + ), + created, + updated, + name, + content, + markdown, + content_encrypted, + client, + client_sequence + FROM public."node" + WHERE + user_id = p_user_id AND + uuid = node_uuid + ON CONFLICT (client, client_sequence) + DO NOTHING; + + /* Current node in database is updated with incoming data. */ + UPDATE public."node" + SET + updated = (node_data->>'Updated')::timestamptz, + updated_seq = nextval('node_updates'), + name = (node_data->>'Name')::varchar, + content = (node_data->>'Content')::text, + markdown = (node_data->>'Markdown')::bool, + client = p_client_uuid, + client_sequence = (node_data->>'ClientSequence')::int + WHERE + user_id = p_user_id AND + uuid = node_uuid; + END IF; + + END LOOP; +END +$$; diff --git a/static/css/markdown.css b/static/css/markdown.css index 832d4a2..84eb0b2 100644 --- a/static/css/markdown.css +++ b/static/css/markdown.css @@ -1,73 +1,32 @@ .el-node-markdown { - padding-top: 16px; + h1 { + border-bottom: 1px solid #ccc; + margin-top: 32px; + margin-bottom: 8px; - .heading-container { - display: grid; - grid-template-columns: min-content 1fr; - grid-gap: 12px; - white-space: nowrap; - align-items: center; - margin-bottom: 16px; + 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 +42,6 @@ table { border: 1px solid #ccc; border-collapse: collapse; - margin-top: 16px; th { text-align: left; @@ -102,11 +60,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 +67,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..71737dd 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -4,183 +4,97 @@ --content-width: 900px; --thumbnail-width: 300px; --thumbnail-height: 100px; - - --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 { background-color: #fff; } -.colorize { - filter: var(--colorize); -} - -textarea { - font-family: var(--font-monospace); -} - -button { - font-size: 1em; - padding: 4px 8px; -} - -/* ------------------------------------- * -* Default application grid in wide mode * -* ------------------------------------- */ #notes2 { min-height: 100vh; display: grid; + grid-template-areas: + "tree hum crumbs crumbs ding" + "tree hum name name ding" + "tree hum sync functions ding" + "tree hum content content ding" + "tree hum blank blank ding" + ; + grid-template-columns: min-content minmax(16px, 1fr) minmax(min-content, 820px) 80px minmax(16px, 1fr); + grid-template-rows: + min-content min-content 48px 1fr; - &.page-node { + + @media only screen and (max-width: 600px) { grid-template-areas: - "tree-expander tree pad1 crumbs crumbs pad2" - "tree-expander tree pad1 name functions pad2" - "tree-expander tree pad1 content content pad2" + "crumbs" + "sync" + "name" + "content" + "blank" ; - - 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; - } - - - /* Tree expander is collapsed as default */ - --tree-expander: 0px; - - &.hide-tree { - --tree-expander: 32px; - - #tree { - border-right: none; - } - - n2-sidebar { - display: none; - - } - } -} - -/* ------------------------------- * - * Application grid in narrow mode * - * ------------------------------- */ -@media only screen and (max-width: 800px) { - #notes2 { - grid-template-areas: - "tree-expander pad1 crumbs crumbs pad2" - "tree-expander pad1 name functions pad2" - "tree-expander pad1 content content pad2" - ; - grid-template-columns: 32px 16px 1fr var(--functions-width) 16px; - - &.show-tree { - grid-template-areas: "tree"; - grid-template-columns: 100%; - grid-template-rows: 1fr; - - #tree { - display: grid; - width: 100%; - } - - #main-page, - #show-tree { - display: none; - } - } + grid-template-columns: 1fr; #tree { display: none; } + + n2-syncprogress { + .el-count { + top: 4px; + } + } } + } -#tree-expander { - grid-area: tree-expander; - color: #333; - background-color: #eee; - font-weight: bold; - border-right: 1px solid var(--line-color); - - display: grid; - justify-items: center; - align-items: start; - - padding-top: 8px; - font-size: 1.25em; - - div div { - display: inline-block; - writing-mode: vertical-rl; - transform: rotate(180deg); - } -} - - #tree { grid-area: tree; display: grid; - background-color: #ffffff; + background-color: #fafafa; color: #444; z-index: 100; - border-right: 1px solid var(--line-color); + border-right: 1px solid #ddd; - n2-sidebar { - .el-treenodes { - margin: 24px 32px 32px 32px; + n2-tree { + /*border: 2px solid #f8f8f8;*/ + padding: 16px 48px 16px 24px; + } + + &:focus-within { + n2-tree { + /* + border: 2px solid #fe5f55; + */ } + + } + + + #logo { + display: grid; + position: relative; + justify-items: center; + margin-top: 8px; + margin-bottom: 8px; + margin-left: 24px; + margin-right: 24px; + cursor: pointer; + + img { + width: 128px; + left: -20px; + + } + } + + .icons { + display: flex; + justify-content: center; + margin-bottom: 32px; + gap: 8px; } .node { @@ -199,11 +113,6 @@ button { img { width: auto; height: 18px; - - &.deleted { - height: 24px; - transform: translateX(3px) translateY(3px); - } } } @@ -236,87 +145,43 @@ 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; - } - +#tree-nodes { + padding: 16px 32px; + /* + border-radius: 8px; +*/ + /* + box-shadow: 5px 5px 10px -5px rgba(0, 0, 0, 0.75); + */ } #crumbs { grid-area: crumbs; display: grid; align-items: start; - justify-items: start; + justify-items: center; height: min-content; - margin: 0 16px 16px 0px; + margin: 0 16px 16px 16px; n2-crumbs { + background: #e4e4e4; display: flex; flex-wrap: wrap; - align-items: center; - padding: 16px 0px; + padding: 8px 16px; + background: #e4e4e4; color: #333; border-bottom-left-radius: 5px; border-bottom-right-radius: 5px; + &.node-modified { + background-color: var(--color1); + color: var(--color2); + + .crumb:after { + color: var(--color2); + } + } + n2-crumb { margin-right: 8px; cursor: pointer; @@ -329,89 +194,85 @@ button { } } - - n2-crumb:before { + n2-crumb:after { content: ">"; font-weight: bold; color: var(--color1) } - - n2-crumb.home { - &:before { - content: ''; - margin-left: 0px; - } - - img { - height: 24px; - } + n2-crumb:last-child { + margin-right: 0; } + + n2-crumb:last-child:after { + content: ''; + margin-left: 0px; + } + } } 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); + } + } /* ============================================================= */ @@ -419,42 +280,25 @@ n2-syncprogress { n2-nodeui { margin-bottom: 32px; - &.node-modified:before { - content: 'h'; - z-index: 8192; - position: fixed; - top: 0px; - left: 0px; - right: 0px; - height: 4px; - - background-color: var(--color1); - color: var(--color2); - } - .el-name { grid-area: name; color: #333; font-weight: bold; - font-size: 1.75em; + text-align: center; + font-size: 1.15em; margin-top: 8px; margin-bottom: 0px; - white-space: nowrap; - width: min-content; } .el-functions { grid-area: functions; - justify-self: end; - align-self: end; - margin-bottom: 6px; } .el-node-content { grid-area: content; justify-self: center; word-wrap: break-word; - font-size: 1em; + font-family: monospace; color: #333; width: 100%; @@ -468,24 +312,22 @@ n2-nodeui { border-left: none; border-right: none; border-top: 1px solid #e0e0e0; - border-bottom: none; - margin-top: 8px; + border-bottom: 1px solid #e0e0e0; margin-bottom: 32px; + + &:invalid { + background: #f5f5f5; + padding-top: 16px; + } } .el-node-markdown { grid-area: content; display: none; - font-family: var(--font-monospace); - font-size: 1em; - font-weight: 400; - border-top: 1px solid #e0e0e0; - margin-top: 8px; + border-bottom: 1px solid #e0e0e0; margin-bottom: 32px; - - overflow-wrap: anywhere; } &.show-markdown { @@ -546,46 +388,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/favicon.ico b/static/favicon.ico deleted file mode 100644 index 299310f..0000000 Binary files a/static/favicon.ico and /dev/null differ diff --git a/static/images/collapsed.svg b/static/images/collapsed.svg index db06415..d93f4ca 100644 --- a/static/images/collapsed.svg +++ b/static/images/collapsed.svg @@ -8,7 +8,7 @@ version="1.1" id="svg1" sodipodi:docname="collapsed.svg" - inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)" + inkscape:version="1.4.2 (ebf0e94, 2025-05-08)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" @@ -23,13 +23,13 @@ inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:document-units="px" - inkscape:zoom="19.349237" - inkscape:cx="11.809251" - inkscape:cy="6.3051583" - inkscape:window-width="1093" - inkscape:window-height="1401" - inkscape:window-x="2560" - inkscape:window-y="0" + inkscape:zoom="4.8373092" + inkscape:cx="6.201795" + inkscape:cy="-12.40359" + inkscape:window-width="1916" + inkscape:window-height="1161" + inkscape:window-x="0" + inkscape:window-y="18" inkscape:window-maximized="1" inkscape:current-layer="layer1" showguides="false" /> @@ -42,13 +42,9 @@ transform="translate(-102.39375,-146.31458)">
${content}
\n` + return `${content}
\n` }, list(token) { @@ -181,7 +134,7 @@ export class MarkedPosition { }, listitem(token) { - return ``
+ return ``
+ (token.escaped ? code : escapeHtmlEntities(code, true))
+ '
\n'
}
- return `'
+ (token.escaped ? code : escapeHtmlEntities(code, true))
@@ -204,7 +157,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 +169,13 @@ export class MarkedPosition {
},
hr(token) {
- return `
\n`
+ return `
\n`
},
checkbox(token) {
- return ` '
+ + 'disabled="" type="checkbox"> '
},
table(token) {
@@ -265,7 +218,7 @@ export class MarkedPosition {
if (token.tokens.length > 0) {
const start = token.tokens[0].position.start.offset
const end = token.tokens[0].position.end.offset
- ofs = `ondblclick="marked_setpos(event)" data-offset-start="${start}" data-offset-end="${end}"`
+ ofs = `onclick="setpos(event)" data-offset-start="${start}" data-offset-end="${end}"`
}
const content = this.parser.parseInline(token.tokens);
@@ -277,23 +230,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 +256,7 @@ export class MarkedPosition {
return text
}
token.href = cleanHref
- let out = ''
+ out += '>'
return out
},
@@ -336,34 +291,8 @@ export class MarkedPosition {
}
})
- }// }}}}}}
+ }// }}}
parse(text) {// {{{
return this.marked.parse(text)
}// }}}
- async whenElementExist(id) {// {{{
- // The element could have already been created.
- const element = document.getElementById(id)
- if (element) {
- return element
- }
-
- const observer = new MutationObserver((_mutations, observer) => {
- const target = document.getElementById(id)
- if (target) {
- observer.disconnect()
- return target
- }
- })
-
- observer.observe(document.documentElement, {
- childList: true,
- subtree: true
- })
- }// }}}
- async populateImg(fileID, elementID) {// {{{
- let img = await globalThis.nodeStore.files.get(fileID)
- const el = await this.whenElementExist(elementID)
-
- el.src = URL.createObjectURL(img.file)
- }// }}}
}
diff --git a/static/js/node.mjs b/static/js/node.mjs
new file mode 100644
index 0000000..acebbb7
--- /dev/null
+++ b/static/js/node.mjs
@@ -0,0 +1,286 @@
+import { ROOT_NODE } from 'node_store'
+import { CustomHTMLElement } from './lib/custom_html_element.mjs'
+import { MarkedPosition } from './marked_position.mjs'
+
+export class N2NodeUI extends CustomHTMLElement {
+ static {// {{{
+ this.tmpl = document.createElement('template')
+ this.tmpl.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+ `
+ }// }}}
+
+ constructor() {// {{{
+ super()
+ 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)
+ this.render()
+ })
+
+ _mbus.subscribe('NODE_MODIFIED', () => {
+ document.querySelector('#crumbs .crumbs')?.classList.add('node-modified')
+ this.elIconSave.src = `/images/${_VERSION}/icon_save.svg`
+ this.render()
+ })
+
+ _mbus.subscribe('NODE_UNMODIFIED', () => {
+ document.querySelector('#crumbs .crumbs')?.classList.remove('node-modified')
+ this.elIconSave.src = `/images/${_VERSION}/icon_save_disabled.svg`
+ })
+
+ _mbus.subscribe('MARKDOWN_TOGGLE', () => this.showMarkdown(!this.showMarkdown()))
+ _mbus.subscribe('MARKDOWN_EDIT', ({ detail }) => this.editMarkdown(detail.data))
+
+ this.elName.addEventListener('click', () => {
+ const name = prompt('Change title', this.node.data.Name)
+ if (name === null)
+ return
+
+ try {
+ this.node.setName(name)
+ } catch (err) {
+ console.error(err)
+ alert(err)
+ }
+ })
+ this.elNodeContent.addEventListener('input', event => this.contentChanged(event))
+ this.elIconMarkdown.addEventListener('click', () => this.showMarkdown(!this.showMarkdown()))
+
+ this.showMarkdown(true)
+ }// }}}
+ render() {// {{{
+ this.elName.innerText = this.node?.get('Name') ?? ''
+ this.elNodeContent.value = this.node?.get('Content') ?? ''
+ this.elNodeMarkdown.innerHTML = this.marked.parse(this.elNodeContent.value)
+ }// }}}
+ takeFocus() {// {{{
+ if (this.showMarkdown()) {
+ this.elNodeMarkdown.focus()
+ } else
+ this.elNodeContent.focus()
+ }// }}}
+
+ contentChanged(event) {//{{{
+ this.node.setContent(event.target.value)
+ }//}}}
+ isModified() {// {{{
+ return this.node?.isModified()
+ }// }}}
+ showMarkdown(state) {// {{{
+ // No point in showing markdown if there is no data.
+ // If there is no data, it will show a blank page regardless, and the user will most
+ // likely want to edit content, which can't be done in markdown.
+ const show = this.node?.content().trim() !== '' && state
+
+ switch (show) {
+ case true:
+ this.elNodeMarkdown.innerHTML = this.marked.parse(this.elNodeContent.value)
+ this.elIconMarkdown.src = `/images/${_VERSION}/icon_markdown.svg`
+ this.classList.add('show-markdown')
+ break
+ case false:
+ this.elIconMarkdown.src = `/images/${_VERSION}/icon_markdown_hollow.svg`
+ this.classList.remove('show-markdown')
+ break
+ case null:
+ case undefined:
+ return this.classList.contains('show-markdown')
+ }
+ }// }}}
+ editMarkdown(data) {// {{{
+ this.showMarkdown(false)
+ this.elNodeContent.selectionStart = data.position.start
+ this.elNodeContent.selectionEnd = data.position.end
+ this.elNodeContent.focus()
+ }// }}}
+}
+customElements.define('n2-nodeui', N2NodeUI)
+
+export class Node {
+ static sort(a, b) {//{{{
+ if (a.data.Name < b.data.Name) return -1
+ if (a.data.Name > b.data.Name) return 0
+ return 0
+ }//}}}
+ static create(name, parentUUID) {// {{{
+ return new Node({
+ UUID: uuidv7(),
+ Created: (new Date()).toISOString(),
+ Content: '',
+ Name: name,
+ ParentUUID: parentUUID,
+ Markdown: false,
+ History: false,
+ })
+ }// }}}
+
+ constructor(nodeData, level) {//{{{
+ this.Level = level
+ this.data = nodeData
+ this.UUID = nodeData.UUID
+
+ // Toplevel nodes are normalized to have the ROOT_NODE as parent.
+ if (nodeData.UUID !== ROOT_NODE && nodeData.ParentUUID === '') {
+ this.ParentUUID = ROOT_NODE
+ this.data.ParentUUID = ROOT_NODE
+ } else
+ this.ParentUUID = nodeData.ParentUUID
+
+ this._children_fetched = false
+ this.Children = []
+ this.Ancestors = []
+
+ this._sibling_before = null
+ this._sibling_after = null
+ this._parent = null
+
+ this.reset()
+ }//}}}
+
+ reset() {// {{{
+ this._content = this.data.Content
+ this._modified = false
+ }// }}}
+ get(prop) {//{{{
+ return this.data[prop]
+ }//}}}
+ updated() {//{{{
+ // '2024-12-17T17:33:48.85939Z
+ return new Date(Date.parse(this.data.Updated))
+ }//}}}
+ isModified() {// {{{
+ return this._modified
+ }// }}}
+ hasFetchedChildren() {//{{{
+ return this._children_fetched
+ }//}}}
+ async fetchChildren() {//{{{
+ this.Children = await nodeStore.getTreeNodes(this.UUID, this.Level + 1)
+ this._children_fetched = true
+
+ // Children are sorted to allow for storing siblings befare and after.
+ // These are used with keyboard navigation in the tree.
+ this.Children.sort(Node.sort)
+
+ const numChildren = this.Children.length
+ for (let i = 0; i < numChildren; i++) {
+ if (i > 0)
+ this.Children[i]._sibling_before = this.Children[i - 1]
+ if (i < numChildren - 1)
+ this.Children[i]._sibling_after = this.Children[i + 1]
+ 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
+ }//}}}
+ hasChildren() {//{{{
+ return this.Children.length > 0
+ }//}}}
+ getSiblingBefore() {// {{{
+ return this._sibling_before
+ }// }}}
+ getSiblingAfter() {// {{{
+ return this._sibling_after
+ }// }}}
+ getParent() {//{{{
+ return this._parent
+ }//}}}
+ isLastSibling() {//{{{
+ return this._sibling_after === null
+ }//}}}
+ isFirstSibling() {//{{{
+ return this._sibling_before === null
+ }//}}}
+ content() {//{{{
+ /* TODO - implement crypto
+ if (this.CryptoKeyID != 0 && !this._decrypted)
+ this.#decrypt()
+ */
+ return this._content
+ }//}}}
+ setContent(new_content) {//{{{
+ this._content = new_content
+ this._modified = true
+ _mbus.dispatch('NODE_MODIFIED', { node: this })
+ }//}}}
+ setName(new_name) {// {{{
+ if (new_name.trim() === '')
+ throw new Error(`The name can't be empty`)
+
+ this.data.Name = new_name
+ this._modified = true
+ _mbus.dispatch('NODE_MODIFIED', { node: this })
+ }// }}}
+ async save() {//{{{
+ this.data.Content = this._content
+ this.data.Updated = new Date().toISOString()
+ 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()
+ }//}}}
+}
+
+function uuidv7() {
+ // random bytes
+ const value = new Uint8Array(16)
+ crypto.getRandomValues(value)
+
+ // current timestamp in ms
+ const timestamp = BigInt(Date.now())
+
+ // timestamp
+ value[0] = Number((timestamp >> 40n) & 0xffn)
+ value[1] = Number((timestamp >> 32n) & 0xffn)
+ value[2] = Number((timestamp >> 24n) & 0xffn)
+ value[3] = Number((timestamp >> 16n) & 0xffn)
+ value[4] = Number((timestamp >> 8n) & 0xffn)
+ value[5] = Number(timestamp & 0xffn)
+
+ // version and variant
+ value[6] = (value[6] & 0x0f) | 0x70
+ value[8] = (value[8] & 0x3f) | 0x80
+
+ const str = Array.from(value)
+ .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/node_store.mjs b/static/js/node_store.mjs
index 6be8f82..e849e29 100644
--- a/static/js/node_store.mjs
+++ b/static/js/node_store.mjs
@@ -1,8 +1,6 @@
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() {//{{{
@@ -14,13 +12,10 @@ export class NodeStore {
this.nodes = {}
this.sendQueue = null
this.nodesHistory = null
- this.files = null
-
- this.initializeSpecialNodes()
}//}}}
initializeDB() {//{{{
return new Promise((resolve, reject) => {
- const req = indexedDB.open('notes', 8)
+ const req = indexedDB.open('notes', 7)
// Schema upgrades for IndexedDB.
// These can start from different points depending on updates to Notes2 since a device was online.
@@ -29,7 +24,6 @@ export class NodeStore {
let appState
let sendQueue
let nodesHistory
- let files
const db = event.target.result
const trx = event.target.transaction
@@ -61,16 +55,12 @@ 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:
trx.objectStore('nodes_history').createIndex('byUUID', 'UUID', { unique: false })
break
-
- case 8:
- files = db.createObjectStore('files', { keyPath: 'UUID' })
- break
}
}
}
@@ -78,9 +68,9 @@ 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.files = new SimpleNodeStore(this.db, 'files')
- resolve()
+ this.nodesHistory = new SimpleNodeStore(this.db, 'nodes_history')
+ this.initializeRootNode()
+ .then(() => resolve())
}
req.onerror = (event) => {
@@ -88,11 +78,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]
@@ -140,6 +159,39 @@ export class NodeStore {
})
}//}}}
+ /*
+ upsertNodeRecords(records) {//{{{
+ return new Promise((resolve, reject) => {
+ const t = this.db.transaction('nodes', 'readwrite')
+ const nodeStore = t.objectStore('nodes')
+ t.onerror = (event) => {
+ console.log('transaction error', event.target.error)
+ reject(event.target.error)
+ }
+ t.oncomplete = () => {
+ resolve()
+ }
+
+ // records is an object, not an array.
+ for (const i in records) {
+ const record = records[i]
+
+ let addReq
+ let op
+ if (record.Deleted) {
+ op = 'deleting'
+ addReq = nodeStore.delete(record.UUID)
+ } else {
+ op = 'upserting'
+ // 'modified' is a local property for tracking
+ // nodes needing to be synced to backend.
+ record.modified = 0
+ addReq = nodeStore.put(record)
+ }
+ }
+ })
+ }//}}}
+ */
getTreeNodes(parent, newLevel) {//{{{
return new Promise((resolve, reject) => {
// Parent of toplevel nodes is ROOT_NODE in indexedDB.
@@ -150,30 +202,14 @@ export class NodeStore {
const nodeStore = trx.objectStore('nodes')
const index = nodeStore.index('byParent')
const req = index.getAll(storeParent)
-
- const hasChildrenPromises = []
req.onsuccess = (event) => {
const nodes = []
for (const i in event.target.result) {
const nodeData = event.target.result[i]
const node = this.node(nodeData.UUID, nodeData, newLevel)
-
- // Look for the key of any children, a hopefully fast way
- // to tell if any children exists at all and this node is a
- // "folder". Needed quite early on for sorting.
- const promise = new Promise((resolve, reject) => {
- const countReq = index.getKey(nodeData.UUID)
- countReq.onsuccess = event => {
- node.setHasChildren(event.target.result !== undefined)
- resolve()
- }
- })
- hasChildrenPromises.push(promise)
nodes.push(node)
}
-
- Promise.all(hasChildrenPromises)
- .then(() => resolve(nodes))
+ resolve(nodes)
}
req.onerror = (event) => reject(event.target.error)
})
@@ -221,7 +257,6 @@ export class NodeStore {
nodeStore = t.objectStore('nodes')
t.oncomplete = (_event) => {
- console.log('complete')
resolve()
}
@@ -246,14 +281,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 +318,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 +368,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) => {
@@ -360,20 +376,8 @@ class SimpleNodeStore {
}
})
}//}}}
- get(key) {//{{{
- return new Promise((resolve, _reject) => {
- const req = this.db
- .transaction(['nodes', this.storeName], 'readonly')
- .objectStore(this.storeName)
- .get(key)
-
- req.onsuccess = (event) => {
- resolve(event.target.result)
- }
- })
- }//}}}
retrieve(limit) {//{{{
- return new Promise((resolve, _reject) => {
+ return new Promise((resolve, reject) => {
const cursorReq = this.db
.transaction(['nodes', this.storeName], 'readonly')
.objectStore(this.storeName)
@@ -429,114 +433,4 @@ class SimpleNodeStore {
}//}}}
}
-class NodeHistoryStore extends SimpleNodeStore {
- constructor(db, storeName) {//{{{
- super(db, storeName)
- }//}}}
- count(uuid) {//{{{
- if (uuid === undefined)
- return super.count()
-
- 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
- }
- }
- })
- }// }}}
-}
-
-export function uuidv7() {// {{{
- // random bytes
- const value = new Uint8Array(16)
- crypto.getRandomValues(value)
-
- // current timestamp in ms
- const timestamp = BigInt(Date.now())
-
- // timestamp
- value[0] = Number((timestamp >> 40n) & 0xffn)
- value[1] = Number((timestamp >> 32n) & 0xffn)
- value[2] = Number((timestamp >> 24n) & 0xffn)
- value[3] = Number((timestamp >> 16n) & 0xffn)
- value[4] = Number((timestamp >> 8n) & 0xffn)
- value[5] = Number(timestamp & 0xffn)
-
- // version and variant
- value[6] = (value[6] & 0x0f) | 0x70
- value[8] = (value[8] & 0x3f) | 0x80
-
- const str = Array.from(value)
- .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/notes2.mjs b/static/js/notes2.mjs
index ddaf891..7bef2ad 100644
--- a/static/js/notes2.mjs
+++ b/static/js/notes2.mjs
@@ -69,7 +69,7 @@ export class Notes2 extends Component {
}
if (!dontPush)
- history.pushState({ nodeUUID }, '', `/#${nodeUUID}`)
+ history.pushState({ nodeUUID }, '', `/notes2#${nodeUUID}`)
// New node is fetched in order to retrieve content and files.
// Such data is unnecessary to transfer for tree/navigational purposes.
@@ -86,6 +86,404 @@ export class Notes2 extends Component {
}//}}}
}
+class Tree extends Component {
+ constructor(props) {//{{{
+ super(props)
+ console.log('new tree')
+ this.treeNodeComponents = {}
+ this.treeTrunk = []
+ this.selectedNode = null
+ this.expandedNodes = {} // keyed on UUID
+ this.treeDiv = createRef()
+
+ // childrenFetchedCallbacks is keyed on a UUID and each
+ // item is an array with callbacks called when a UUID has
+ // had all children fetched.
+ this.childrenFetchedCallbacks = {}
+
+ this.props.app.tree = this
+
+ this.populateFirstLevel()
+ }//}}}
+ render({ app }) {//{{{
+ const renderedTreeTrunk = this.treeTrunk.map(node => {
+ this.treeNodeComponents[node.UUID] = createRef()
+ return html`<${TreeNode} key=${`treenode_${node.UUID}`} tree=${this} node=${node} ref=${this.treeNodeComponents[node.UUID]} selected=${node.UUID === app.state.startNode?.UUID} />`
+ })
+
+ return html`
+
+ _notes2.current.goToNode(ROOT_NODE)}>
+
+
_mbus.dispatch('op-search')} />
+
_sync.run()} />
+
+ ${renderedTreeTrunk}
+ `
+ }//}}}
+ componentDidMount() {//{{{
+ //this.treeDiv.current.addEventListener('keydown', event => this.keyHandler(event))
+
+ // This will show and select the treenode that is selected in the node UI.
+ const node = _notes2.current?.nodeUI.current?.node.value
+ if (node === null)
+ return
+ _notes2.current.tree.expandToTrunk(node)
+ this.setSelected(node)
+ }//}}}
+
+ fetchChildrenNotify(uuid, fn) {//{{{
+ if (this.childrenFetchedCallbacks[uuid] === undefined)
+ this.childrenFetchedCallbacks[uuid] = [fn]
+ else
+ this.childrenFetchedCallbacks[uuid].push(fn)
+ }//}}}
+ fetchChildrenOn(uuid) {//{{{
+ if (this.childrenFetchedCallbacks[uuid] === undefined)
+ return
+ for (const fn of this.childrenFetchedCallbacks[uuid])
+ fn(uuid)
+ delete this.childrenFetchedCallbacks[uuid]
+ }//}}}
+
+ populateFirstLevel(callback = null) {//{{{
+ nodeStore.get(ROOT_NODE)
+ .then(node => node.fetchChildren())
+ .then(children => {
+ this.treeNodeComponents = {}
+ this.treeTrunk = []
+ for (const node of children) {
+ // The root node isn't supposed to be shown in the tree.
+ if (node.UUID === ROOT_NODE)
+ continue
+ if (node.ParentUUID === ROOT_NODE)
+ this.treeTrunk.push(node)
+ }
+ this.forceUpdate()
+ if (callback)
+ callback()
+
+ })
+ .catch(e => { console.log(e); console.log(e.type, e.error); alert(e.error) })
+ }//}}}
+ setSelected(node, dontExpand) {//{{{
+ // The previously selected node, if any, needs to be rerendered
+ // to not retain its 'selected' class.
+ const prevUUID = this.selectedNode?.UUID
+ this.selectedNode = node
+ if (prevUUID)
+ this.treeNodeComponents[prevUUID]?.current.forceUpdate()
+
+ // And now the newly selected node is rerendered.
+ this.treeNodeComponents[node.UUID]?.current.forceUpdate()
+
+ if (!dontExpand)
+ this.setNodeExpanded(node, true)
+ }//}}}
+ isSelected(node) {//{{{
+ return this.selectedNode?.UUID === node.UUID
+ }//}}}
+ async expandToTrunk(node) {//{{{
+ // Get all ancestors from a certain node up to the highest grandparent.
+ const ancestry = await nodeStore.getNodeAncestry(node, [])
+ for (const i in ancestry) {
+ await nodeStore.node(ancestry[i].UUID).fetchChildren()
+ this.setNodeExpanded(ancestry[i], true)
+ }
+
+ // Already a top node, no need to expand anything.
+ if (ancestry.length === 0)
+ return
+
+ // Start the chain of by expanding the top node.
+ this.setNodeExpanded(ancestry[ancestry.length - 1], true)
+ }//}}}
+ getNodeExpanded(UUID) {//{{{
+ if (this.expandedNodes[UUID] === undefined)
+ this.expandedNodes[UUID] = signal(false)
+ return this.expandedNodes[UUID].value
+ }//}}}
+ async setNodeExpanded(node, value) {//{{{
+ return new Promise((resolve, reject) => {
+ const work = uuid => {
+ // Creating a default value if it doesn't exist already.
+ this.getNodeExpanded(uuid)
+ this.expandedNodes[uuid].value = value
+ resolve()
+ }
+
+ if (node.hasFetchedChildren()) {
+ work(node.UUID)
+ return
+ } else {
+ this.fetchChildrenNotify(node.UUID, uuid => work(uuid))
+ }
+ })
+ }//}}}
+ getParentWithNextSibling(node) {//{{{
+ let currNode = node
+ while (currNode !== null && currNode.UUID !== ROOT_NODE && currNode.getSiblingAfter() === null) {
+ currNode = currNode.getParent()
+ }
+ return currNode?.getSiblingAfter()
+ }//}}}
+ getLastExpandedNode(node) {//{{{
+ let currNode = node
+ while (this.getNodeExpanded(currNode.UUID) && currNode.hasChildren()) {
+ currNode = currNode.Children[currNode.Children.length - 1]
+ }
+ return currNode
+ }//}}}
+
+ async recursiveExpand(node, state) {//{{{
+ if (state)
+ await this.setNodeExpanded(node, true)
+
+ for (const child of node.Children)
+ await this.recursiveExpand(child, state)
+
+ if (!state)
+ await this.setNodeExpanded(node, false)
+ }//}}}
+
+ async keyHandler(event) {//{{{
+ let handled = true
+ const n = this.selectedNode
+ const Space = ' '
+
+ // This handler would otherwise react to stuff like Ctrl+L.
+ if (event.ctrlKey || event.altKey)
+ return
+
+ switch (event.key) {
+ // Space and enter is toggling expansion.
+ // Holding shift down does it recursively.
+ case Space:
+ case 'Enter':
+ const expanded = this.getNodeExpanded(n.UUID)
+ if (event.shiftKey) {
+ this.recursiveExpand(n, !expanded)
+ } else {
+ this.setNodeExpanded(n, !expanded)
+ }
+ break
+
+ case 'g':
+ case 'Home':
+ this.navigateTop()
+ break
+
+ case 'G':
+ case 'End':
+ this.navigateBottom()
+ break
+
+ case 'j':
+ case 'ArrowDown':
+ await this.navigateDown(this.selectedNode)
+ break
+
+ case 'k':
+ case 'ArrowUp':
+ await this.navigateUp(this.selectedNode)
+ break
+
+ case 'h':
+ case 'ArrowLeft':
+ await this.navigateLeft(this.selectedNode)
+ break
+
+ case 'l':
+ case 'ArrowRight':
+ await this.navigateRight(this.selectedNode)
+ break
+
+ default:
+ // nonsole.log(event.key)
+ handled = false
+ }
+
+ if (handled) {
+ event.preventDefault()
+ event.stopPropagation()
+ }
+ }//}}}
+ async navigateLeft(n) {//{{{
+ if (n === null)
+ return
+
+ const expanded = this.getNodeExpanded(n.UUID)
+ if (expanded && n.hasChildren()) {
+ this.setNodeExpanded(n, false)
+ return
+ }
+
+ if (n.isFirstSibling() && n.getParent().UUID !== ROOT_NODE) {
+ await _notes2.current.goToNode(n.getParent()?.UUID, true, true)
+ return
+ }
+
+ const siblingBefore = n.getSiblingBefore()
+ const siblingExpanded = this.getNodeExpanded(siblingBefore?.UUID)
+ if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) {
+ const siblingAbove = this.getLastExpandedNode(siblingBefore)
+ await _notes2.current.goToNode(siblingAbove?.UUID, true, true)
+ return
+ }
+
+ await _notes2.current.goToNode(n.getSiblingBefore()?.UUID, true, true)
+ }//}}}
+ async navigateRight(n) {//{{{
+ if (n === null)
+ return
+
+ const siblingAfter = n.getSiblingAfter()
+ const expanded = this.getNodeExpanded(n.UUID)
+
+ if (!expanded && n.hasChildren()) {
+ this.setNodeExpanded(n, true)
+ return
+ }
+
+ if (expanded && n.hasChildren()) {
+ await _notes2.current.goToNode(n.Children[0]?.UUID, true, true)
+ return
+ }
+
+ if (n.isLastSibling()) {
+ const nextNode = this.getParentWithNextSibling(n)
+ await _notes2.current.goToNode(nextNode?.UUID, true, true)
+ return
+ }
+
+ await _notes2.current.goToNode(n.getSiblingAfter()?.UUID, true, true)
+ }//}}}
+ async navigateUp(n) {//{{{
+ if (n === null)
+ return
+
+ let parent = null
+ const siblingBefore = n.getSiblingBefore()
+ let siblingExpanded = false
+ if (siblingBefore !== null)
+ siblingExpanded = this.getNodeExpanded(siblingBefore.UUID)
+
+ if (n.isFirstSibling()) {
+ parent = n.getParent()
+ if (parent?.UUID === ROOT_NODE)
+ return
+ await _notes2.current.goToNode(parent?.UUID, true, true)
+ return
+ }
+
+ if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) {
+ await _notes2.current.goToNode(siblingBefore.Children[siblingBefore.Children.length - 1]?.UUID, true, true)
+ return
+ }
+
+ if (siblingBefore) {
+ await _notes2.current.goToNode(siblingBefore.UUID, true, true)
+ return
+ }
+ }//}}}
+ async navigateDown(n) {//{{{
+ if (n === null)
+ return
+
+ const nodeExpanded = this.getNodeExpanded(n.UUID)
+
+ // Last node, not expanded, so it matters not whether it has children or not.
+ // Traverse upward to nearest parent with next sibling.
+ if (!nodeExpanded && n.isLastSibling()) {
+ const wantedNode = this.getParentWithNextSibling(n)
+ await _notes2.current.goToNode(wantedNode?.UUID, true, true)
+ return
+ }
+
+ if (nodeExpanded && n.isLastSibling() && !n.hasChildren()) {
+ const wantedNode = this.getParentWithNextSibling(n)
+ await _notes2.current.goToNode(wantedNode?.UUID, true, true)
+ return
+ }
+
+ // Node not expanded. Go to this node's next sibling.
+ // GoToNode will abort if given null.
+ if (!nodeExpanded || !n.hasChildren()) {
+ await _notes2.current.goToNode(n.getSiblingAfter()?.UUID, true, true)
+ return
+ }
+
+ // Node is expanded.
+ // Children will be visually beneath this node, if any.
+ if (nodeExpanded && n.hasChildren()) {
+ await _notes2.current.goToNode(n.Children[0].UUID, true, true)
+ return
+ }
+ }//}}}
+ async navigateTop() {//{{{
+ const root = await nodeStore.get(ROOT_NODE)
+ if (root.Children.length === 0)
+ return
+ await _notes2.current.goToNode(root.Children[0]?.UUID, true, true)
+ }//}}}
+ async navigateBottom() {//{{{
+ const root = await nodeStore.get(ROOT_NODE)
+ if (root.Children.length === 0)
+ return
+
+ const toplevel = root.Children[root.Children.length - 1]
+ const toplevelExpanded = this.getNodeExpanded(toplevel?.UUID)
+
+ if (toplevelExpanded) {
+ const lastnode = this.getLastExpandedNode(toplevel)
+ await _notes2.current.goToNode(lastnode?.UUID, true, true)
+ } else
+ await _notes2.current.goToNode(root.Children[root.Children.length - 1]?.UUID, true, true)
+ }//}}}
+}
+
+class TreeNode extends Component {
+ constructor(props) {//{{{
+ super(props)
+ this.children_populated = signal(false)
+ if (this.props.node.Level === 0 || this.props.tree.getNodeExpanded(this.props.node.UUID))
+ this.fetchChildren()
+ }//}}}
+ render({ tree, node, parent }) {//{{{
+ // Fetch the next level of children if the parent tree node is expanded and our children thus will be visible.
+ const selected = tree.isSelected(node) ? 'selected' : ''
+
+ if (!this.children_populated.value && tree.getNodeExpanded(parent?.props.node.UUID))
+ this.fetchChildren()
+
+ const children = node.Children.map(node => {
+ tree.treeNodeComponents[node.UUID] = createRef()
+ return html`<${TreeNode} key=${`treenode_${node.UUID}`} tree=${tree} node=${node} parent=${this} ref=${tree.treeNodeComponents[node.UUID]} selected=${node.UUID === tree.props.app.startNode?.UUID} />`
+ })
+
+ let expandImg = ''
+ if (node.Children.length === 0)
+ expandImg = html`
`
+ else {
+ if (tree.getNodeExpanded(node.UUID))
+ expandImg = html`
`
+ else
+ expandImg = html`
`
+ }
+
+ return html`
+
+
+ window._notes2.current.goToNode(node.UUID)}>${node.get('Name')}
+
+ `
+ }//}}}
+ async fetchChildren() {//{{{
+ await this.props.node.fetchChildren()
+ this.children_populated.value = true
+ }//}}}
+}
+
class Op {
constructor(id) {
this.id = id
diff --git a/static/js/page_history.mjs b/static/js/page_history.mjs
deleted file mode 100644
index 44061f6..0000000
--- a/static/js/page_history.mjs
+++ /dev/null
@@ -1,319 +0,0 @@
-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 {// {{{
- this.tmpl = document.createElement('template')
- this.tmpl.innerHTML = `
-
-
-
-
- Back to node
-
-
-
-
-
-
-
-
- Actions
-
-
-
-
-
-
- History
-
-
- History on server:
-
-
- History on client:
-
-
-
-
-
-
- <
-
- >
-
-
-
-
-
- Document
-
-
-
-
- `
- }// }}}
-
- 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
deleted file mode 100644
index 2106ada..0000000
--- a/static/js/page_node.mjs
+++ /dev/null
@@ -1,593 +0,0 @@
-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 = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- `
- }// }}}
-
- constructor() {// {{{
- super()
- this.node = null
-
- this.style.display = 'contents'
- this.marked = new MarkedPosition()
-
- _mbus.subscribe('NODE_UI_OPEN', event => {
- this.node = event.detail.data
-
-
- if (!this.node.isSpecial())
- this.showMarkdown(true)
- this.render()
- })
-
- _mbus.subscribe('NODE_MODIFIED', () => {
- this.classList.add('node-modified')
- this.elIconSave.src = `/images/${_VERSION}/icon_save.svg`
- this.elIconSave.classList.add('colorize')
- this.renderName()
- })
-
- _mbus.subscribe('NODE_UNMODIFIED', () => {
- this.classList.remove('node-modified')
- this.elIconSave.src = `/images/${_VERSION}/icon_save_disabled.svg`
- this.elIconSave.classList.remove('colorize')
- })
-
- _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())
-
- // 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.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 {
- const from = this.elNodeContent.selectionStart
- const to = this.elNodeContent.selectionEnd
- const text = this.elNodeContent.value.slice(from, to)
-
- const formatted = this.formatAllTables(text)
- this.elNodeContent.setRangeText(formatted, from, to, 'select');
- }
-
- this.node.setContent(this.elNodeContent.value)
- })
- 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() {// {{{
- this.elName.innerText = this.node?.get('Name') ?? ''
- }// }}}
- render() {// {{{
- this.elName.innerText = this.node?.get('Name') ?? ''
- this.elNodeContent.value = this.node?.get('Content') ?? ''
- this.elNodeMarkdown.innerHTML = this.marked.parse(this.elNodeContent.value)
- }// }}}
- takeFocus() {// {{{
- if (this.showMarkdown()) {
- this.elNodeMarkdown.focus({ preventScroll: true })
- } 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()
- }// }}}
-
- contentChanged(event) {//{{{
- this.node.setContent(event.target.value)
- }//}}}
- isModified() {// {{{
- return this.node?.isModified()
- }// }}}
- showMarkdown(state) {// {{{
- // No point in showing markdown if there is no data.
- // If there is no data, it will show a blank page regardless, and the user will most
- // likely want to edit content, which can't be done in markdown.
- const show = this.node?.content().trim() !== '' && state
-
- switch (show) {
- case true:
- this.elNodeMarkdown.innerHTML = this.marked.parse(this.elNodeContent.value)
- this.elIconMarkdown.src = `/images/${_VERSION}/icon_markdown.svg`
- this.elIconMarkdown.classList.add('colorize')
- this.classList.add('show-markdown')
- break
- case false:
- this.elIconMarkdown.src = `/images/${_VERSION}/icon_markdown_hollow.svg`
- this.elIconMarkdown.classList.remove('colorize')
- this.classList.remove('show-markdown')
- break
- case null:
- case undefined:
- return this.classList.contains('show-markdown')
- }
- }// }}}
- async pasteHandler(event) {// {{{
- const clipboardItems = event.clipboardData?.items
- if (!clipboardItems)
- return
-
- for (const item of clipboardItems) {
- switch (item.kind) {
- case 'string':
- continue
-
- case 'file':
- const file = item.getAsFile()
- if (!file)
- throw new Error("Couldn't convert image to file object.")
- const uuid = uuidv7()
- await globalThis.nodeStore.files.add({ data: { UUID: uuid, file: file } })
-
- const [start, end] = [this.elNodeContent.selectionStart, this.elNodeContent.selectionEnd]
- this.elNodeContent.setRangeText(``, start, end, 'select');
-
- // Editing the textarea programatically doesn't generate the events it usually gets when edited interactively.
- this.node.setContent(this.elNodeContent.value)
-
- break
-
- default:
- alert(`Unknown paste type of '${item.kind}'`)
- }
- }
- }// }}}
- editMarkdown(data) {// {{{
- this.showMarkdown(false)
- this.elNodeContent.selectionStart = data.position.start
- this.elNodeContent.selectionEnd = data.position.end
- this.elNodeContent.focus()
- }// }}}
-
- findTables(lines) {// {{{
- let tables = []
- let curr = { from: -1, to: -1 }
- for (let i = 0; i < lines.length; i++) {
- const linecols = lines[i].split('|').length - 2 // Gives empty value in front of first pipe and after last one.
-
- if (linecols >= 1) {
- if (curr.from == -1)
- curr.from = i
- curr.to = i
- } else if (linecols < 1 && curr.to > -1) {
- tables.push(curr)
- curr = { from: -1, to: -1 }
- }
- }
-
- if (curr.from > -1)
- tables.push(curr)
-
- return tables
- }// }}}
- formatAllTables(text) {// {{{
- const lines = text.split(/\r?\n/)
- const tables = this.findTables(lines)
- for (const table of tables) {
- const formattedLines = this.formatTable(lines.slice(table.from, table.to + 1))
- lines.splice(table.from, formattedLines.length, ...formattedLines)
- }
-
- return lines.join("\n")
- }// }}}
- formatTable(lines) {// {{{
- let numColumns = 0
- let colwidth = []
-
- for (let i = 0; i < lines.length; i++) {
- // -1 for split, -1 because number of columns are one less than number of pipes.
- const columns = lines[i].split('|').slice(1)
- const linecols = columns.length - 2
- numColumns = Math.max(numColumns, linecols)
-
- // Keep count of column width.
- for (let j = 0; j < columns.length - 1; j++) {
- colwidth[j] = Math.max(colwidth[j] || 0, columns[j].trim().length)
- }
- }
-
- // Build up each line correct.
- let extendHeader
- for (let i = 0; i < lines.length; i++) {
- // Build lines with columns.
- const cols = lines[i].split('|').slice(1, -1)
-
- // Second line should be headers.
- if (i === 1) {
- extendHeader = true
- for (let j = 0; j < colwidth.length; j++) {
- extendHeader &= ((cols[j] || '').match(/^\s*[-]*\s*$/) !== null)
- }
-
-
- }
-
- if (i === 1 && extendHeader) {
- for (let j = 0; j < colwidth.length; j++)
- cols[j] = '-'.repeat(colwidth[j])
-
- } else {
- for (let j = 0; j < colwidth.length; j++) {
- cols[j] = (cols[j] || '').trim()
- const cw = colwidth[j]
- const padWidth = cw - (cols[j]?.length || 0) // may be a column that doesn't exist on this line.
- cols[j] = cols[j] + ' '.repeat(padWidth > 0 ? padWidth : 0)
- }
- }
-
- lines[i] = '| ' + cols.join(' | ') + ' |'
- }
-
- 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)
-
-export class Node {
- static sort(a, b) {//{{{
- // Nodes with children ("folders") are sorted first.
- if (a._has_children && !b._has_children) return -1
- if (!a._has_children && b._has_children) return 1
-
- // Otherwise sort by lowercased name.
- const an = a.data.Name.toLowerCase()
- const bn = b.data.Name.toLowerCase()
- if (an < bn) return -1
- if (an > bn) return 1
- return 0
- }//}}}
- static create(name, parentUUID) {// {{{
- const node = new Node({
- UUID: uuidv7(),
- Created: (new Date()).toISOString(),
- Content: '',
- Name: name,
- ParentUUID: parentUUID,
- Markdown: 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) {//{{{
- this.Level = level
- this.data = nodeData
- this.UUID = nodeData.UUID
-
- // Toplevel nodes are normalized to have the ROOT_NODE as parent.
- if (nodeData.UUID !== ROOT_NODE && nodeData.ParentUUID === '') {
- this.ParentUUID = ROOT_NODE
- this.data.ParentUUID = ROOT_NODE
- } else
- this.ParentUUID = nodeData.ParentUUID
-
- this._children_fetched = false
- this._has_children = null // this will be set by nodeStore.getTreeNodes
- this.Children = []
- this.Ancestors = []
-
- this._sibling_before = null
- this._sibling_after = null
- this._parent = null
-
- this.reset()
- }//}}}
-
- reset() {// {{{
- this._content = this.data.Content
- this._modified = false
- }// }}}
- get(prop) {//{{{
- return this.data[prop]
- }//}}}
- updated() {//{{{
- // '2024-12-17T17:33:48.85939Z
- return new Date(Date.parse(this.data.Updated))
- }//}}}
- isModified() {// {{{
- return this._modified
- }// }}}
- hasFetchedChildren() {//{{{
- return this._children_fetched
- }//}}}
- async fetchChildren() {//{{{
- this.Children = await nodeStore.getTreeNodes(this.UUID, this.Level + 1)
- this._children_fetched = true
-
- // Children are sorted to allow for storing siblings befare and after.
- // These are used with keyboard navigation in the tree.
- this.Children.sort(Node.sort)
-
- const numChildren = this.Children.length
- this.setHasChildren(numChildren > 0)
- for (let i = 0; i < numChildren; i++) {
- if (i > 0)
- this.Children[i]._sibling_before = this.Children[i - 1]
- if (i < numChildren - 1)
- this.Children[i]._sibling_after = this.Children[i + 1]
- this.Children[i]._parent = this
- }
-
- return this.Children
- }//}}}
- setHasChildren(v) {// {{{
- this._has_children = v
- }// }}}
- hasChildren() {//{{{
- return this._has_children
- }//}}}
- getSiblingBefore() {// {{{
- return this._sibling_before
- }// }}}
- getSiblingAfter() {// {{{
- return this._sibling_after
- }// }}}
- 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)
- this.#decrypt()
- */
- return this._content
- }//}}}
- setContent(new_content) {//{{{
- this._content = new_content
- this._modified = true
- _mbus.dispatch('NODE_MODIFIED', { node: this })
- }//}}}
- setName(new_name) {// {{{
- if (new_name.trim() === '')
- throw new Error(`The name can't be empty`)
-
- this.data.Name = new_name
- this._modified = true
- _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
deleted file mode 100644
index a007130..0000000
--- a/static/js/page_storage.mjs
+++ /dev/null
@@ -1,31 +0,0 @@
-import { CustomHTMLElement } from "./lib/custom_html_element.mjs"
-
-export class N2PageStorage extends CustomHTMLElement {
- static {
- this.tmpl = document.createElement('template')
- this.tmpl.innerHTML = `
- Local storage
-
-
-
- `
- }
- constructor() {
- super()
-
- window._mbus.subscribe('SHOW_PAGE', event => {
- if (event.detail.data?.page == 'storage')
- this.render()
- })
- }
- async render() {
- const countNodes = await globalThis.nodeStore.nodeCount()
- const countQueuedNodes = await globalThis.nodeStore.sendQueue.count()
- const countHistoryNodes = await globalThis.nodeStore.nodesHistory.count()
-
- this.elCountNodes.innerText = countNodes
- this.elCountQueuedNodes.innerText = countQueuedNodes
- this.elCountHistoryNodes.innerText = countHistoryNodes
- }
-}
-customElements.define('n2-pagestorage', N2PageStorage)
diff --git a/static/js/sidebar.mjs b/static/js/sidebar.mjs
deleted file mode 100644
index 6cd5814..0000000
--- a/static/js/sidebar.mjs
+++ /dev/null
@@ -1,771 +0,0 @@
-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'
-
-// TreeExpandedHandler is responsible for collapsing or expanding
-// the node tree, wide view or narrow "mobile" view.
-class TreeExpansionHandler {// {{{
- constructor() {
- this.isNarrow = false
- this.initializeMediaHandler()
- this.initializeBusEvents()
- }
-
- initializeBusEvents() {
- _mbus.subscribe('TREE_EXPANSION', ({ detail }) => {
- // When a node is selected on the screen and the screen
- // is narrow the tree is automatically hidden.
- //
- // Can't always hide the tree automatically when a node
- // is selected since the wide mode shows the tree as standard.
- if (detail.data?.when == 'narrow' && !this.isNarrow)
- return
-
- this.treeExpansion(detail.data?.expand)
- })
- }
-
- initializeMediaHandler() {
- const query = window.matchMedia('(max-width: 800px)')
- query.addEventListener('change', event => this.screenNarrowHandler(event))
-
- // Run once to set initial state, instead of needing to toggle state.
- this.screenNarrowHandler(query)
- }
-
- // When screen becomes narrow, the tree is automatically hidden.
- // Primary purpose is to read content, not browse, which is why
- // the tree is hidden as standard.
- screenNarrowHandler(event) {
- this.isNarrow = event.matches
-
- if (this.isNarrow)
- this.treeExpansion(false)
- else
- this.treeExpansion(true)
- }
-
- treeExpansion(expanded) {
- const notes2 = document.getElementById('notes2')
-
- if (expanded) {
- notes2.classList.remove('hide-tree')
- notes2.classList.add('show-tree')
- } else {
- notes2.classList.add('hide-tree')
- notes2.classList.remove('show-tree')
- }
- }
-}// }}}
-
-export class N2Sidebar extends CustomHTMLElement {
- static {// {{{
- this.tmpl = document.createElement('template')
- this.tmpl.innerHTML = `
-
-
-
- <
-
-
-
-
-
-
-
- `
- }// }}}
-
- constructor() {// {{{
- super()
-
- this.id = 'tree-nodes'
- this.tabIndex = 0
-
- this.treeNodeComponents = {}
- this.expandedNodes = {} // keyed on UUID
- this.selectedNode = null
- this.rendered = false
-
- new TreeExpansionHandler()
-
- this.addEventListener('keydown', event => this.keyHandler(event))
- 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 })
- })
-
- _mbus.subscribe('NODE_MODIFIED', ({ detail }) => {
- const node = detail.data.node
- const treenode = this.treeNodeComponents[node.get('UUID')]
-
- if (!treenode)
- return
-
- treenode.node = node
- treenode.render(true)
- })
-
- /* XXX - set color */
- let color = new Color(0x80, 0x00, 0x33)
- let solver = new Solver(color)
- let result = solver.solve()
- console.log(result.filter)
- }// }}}
- async 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')
-
- this.rendered = true
- return this
- }// }}}
- reset() {// {{{
- this.treeNodeComponents = {}
- this.rendered = false
- this.elTreenodes.replaceChildren()
- this.render()
- }// }}}
- getNodeExpanded(UUID) {//{{{
- if (this.expandedNodes[UUID] === undefined)
- this.expandedNodes[UUID] = false
- return this.expandedNodes[UUID]
- }//}}}
- async setNodeExpanded(node, value) {//{{{
- let expanded = this.expandedNodes[node.UUID]
- if (expanded === undefined) {
- this.expandedNodes[node.UUID] = false
- expanded = false
- }
-
- if (expanded === value)
- return
-
- this.expandedNodes[node.UUID] = value
- _mbus.dispatch(`NODE_EXPAND_${node.UUID}`, value)
- }//}}}
- setSelected(node, dontExpand) {//{{{
- if (node === undefined)
- return
-
- // The previously selected node, if any, needs to be rerendered
- // to not retain its 'selected' class.
- const prevUUID = this.selectedNode?.UUID
- this.selectedNode = node
- if (prevUUID)
- this.treeNodeComponents[prevUUID]?.render(true)
-
- // And now the newly selected node is rerendered.
- this.treeNodeComponents[node.UUID]?.render(true)
-
- if (!dontExpand)
- this.setNodeExpanded(node, true)
- }//}}}
- isSelected(node) {//{{{
- return this.selectedNode?.UUID === node.UUID
- }//}}}
- getTreeNode(uuid) {// {{{
- return this.treeNodeComponents[uuid]
- }// }}}
-
- async keyHandler(event) {//{{{
- let handled = true
- const n = this.selectedNode
- const Space = ' '
-
- // This handler would otherwise react to stuff like Ctrl+L.
- if (event.ctrlKey || event.altKey)
- return
-
- switch (event.key) {
- // Space and enter is toggling expansion.
- // Holding shift down does it recursively.
- case Space:
- case 'Enter':
- const expanded = this.getNodeExpanded(n.UUID)
- if (event.shiftKey) {
- this.recursiveExpand(n, !expanded)
- } else {
- this.setNodeExpanded(n, !expanded)
- }
- break
-
- case 'Home':
- this.navigateTop()
- break
-
- case 'End':
- this.navigateBottom()
- break
-
- case 'ArrowDown':
- await this.navigateDown(this.selectedNode)
- break
-
- case 'ArrowUp':
- await this.navigateUp(this.selectedNode)
- break
-
- case 'ArrowLeft':
- await this.navigateLeft(this.selectedNode)
- break
-
- case 'ArrowRight':
- await this.navigateRight(this.selectedNode)
- break
-
- default:
- handled = false
- }
-
- if (handled) {
- event.preventDefault()
- event.stopPropagation()
- }
- }//}}}
- async navigateLeft(n) {//{{{
- if (n === null || n === undefined || n.UUID == ROOT_NODE)
- return
-
- const expanded = this.getNodeExpanded(n.UUID)
- if (expanded && n.hasChildren() && n.UUID !== ROOT_NODE) {
- this.setNodeExpanded(n, false)
- return
- }
-
- if (n.isFirstSibling()) {
- _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getParent()?.UUID, dontPush: false, dontExpand: true })
- return
- }
-
- const siblingBefore = n.getSiblingBefore()
- const siblingExpanded = this.getNodeExpanded(siblingBefore?.UUID)
- if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) {
- const siblingAbove = this.getLastExpandedNode(siblingBefore)
- _mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingAbove?.UUID, dontPush: false, dontExpand: true })
- return
- }
-
- _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingBefore()?.UUID, dontPush: false, dontExpand: true })
- }//}}}
- async navigateRight(n) {//{{{
- if (n === null || n === undefined)
- return
-
- const siblingAfter = n.getSiblingAfter()
- const expanded = this.getNodeExpanded(n.UUID)
-
- if (!expanded && n.hasChildren()) {
- this.setNodeExpanded(n, true)
- return
- }
-
- if (expanded && n.hasChildren()) {
- _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.Children[0]?.UUID, dontPush: false, dontExpand: true })
- return
- }
-
- if (n.isLastSibling()) {
- const nextNode = this.getParentWithNextSibling(n)
- _mbus.dispatch("GO_TO_NODE", { nodeUUID: nextNode?.UUID, dontPush: false, dontExpand: true })
- return
- }
-
- _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)
- return
-
- let parent = null
- const siblingBefore = n.getSiblingBefore()
- let siblingExpanded = false
- if (siblingBefore !== null)
- siblingExpanded = this.getNodeExpanded(siblingBefore.UUID)
-
- if (n.isFirstSibling()) {
- parent = n.getParent()
- _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 })
- return
- }
-
- if (siblingBefore) {
- _mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingBefore.UUID, dontPush: false, dontExpand: true })
- return
- }
- }//}}}
- async navigateDown(n) {//{{{
- if (n === null || n === undefined)
- return
-
- const nodeExpanded = this.getNodeExpanded(n.UUID)
-
- // Last node, not expanded, so it matters not whether it has children or not.
- // Traverse upward to nearest parent with next sibling.
- if (!nodeExpanded && n.isLastSibling()) {
- const wantedNode = this.getParentWithNextSibling(n)
- _mbus.dispatch("GO_TO_NODE", { nodeUUID: wantedNode?.UUID, dontPush: false, dontExpand: true })
- return
- }
-
- if (nodeExpanded && n.isLastSibling() && !n.hasChildren()) {
- const wantedNode = this.getParentWithNextSibling(n)
- _mbus.dispatch("GO_TO_NODE", { nodeUUID: wantedNode?.UUID, dontPush: false, dontExpand: true })
- return
- }
-
- // Node not expanded. Go to this node's next sibling.
- // GoToNode will abort if given null.
- if (!nodeExpanded || !n.hasChildren()) {
- _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingAfter()?.UUID, dontPush: false, dontExpand: true })
- return
- }
-
- // Node is expanded.
- // Children will be visually beneath this node, if any.
- if (nodeExpanded && n.hasChildren()) {
- _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.Children[0].UUID, dontPush: false, dontExpand: true })
- return
- }
- }//}}}
- async navigateTop() {//{{{
- const root = await nodeStore.get(ROOT_NODE)
- _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.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 })
- 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: orphaned.Children[orphaned.Children.length - 1]?.UUID, dontPush: false, dontExpand: true })
- */
- }//}}}
-
- getParentWithNextSibling(node) {//{{{
- let currNode = node
- while (currNode !== null && currNode.UUID !== ROOT_NODE && currNode.getSiblingAfter() === null) {
- currNode = currNode.getParent()
- }
- return currNode?.getSiblingAfter()
- }//}}}
- getLastExpandedNode(node) {//{{{
- let currNode = node
- while (this.getNodeExpanded(currNode.UUID) && currNode.hasChildren()) {
- currNode = currNode.Children[currNode.Children.length - 1]
- }
- return currNode
- }//}}}
- async recursiveExpand(node, state) {//{{{
- if (state)
- await this.setNodeExpanded(node, true)
-
- // 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) {// {{{
- 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)
- }
- }
-
- treenode?.scrollIntoView({ block: 'nearest' })
- }// }}}
-}
-
-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) {//{{{
- super()
- this.setAttribute('draggable', 'true')
- this.classList.add('node')
-
- this.sidebar = sidebar
- 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.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)
- return
-
- await this.node.fetchChildren()
- this.children_populated = true
- }//}}}
- async render(force_update, force_refetch_children) {//{{{
- if (this.rendered && force_update !== true)
- return this
-
- if (this.sidebar.getNodeExpanded(this.node.UUID) || force_refetch_children)
- await this.fetchChildren(force_refetch_children)
-
- // Update the name and selected status.
- this.elName.querySelector('span').innerText = this.node.get('Name')
-
- if (this.sidebar.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')
- } else {
- this.elChildren.classList.remove('expanded')
- this.elChildren.classList.add('collapsed')
- }
-
- // 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))
- this.setImgSrc(this.elExpand, `/images/${window._VERSION}/expanded.svg`)
- else
- this.setImgSrc(this.elExpand, `/images/${window._VERSION}/collapsed.svg`)
-
- // Should children be rendered?
- let children = []
- if (expanded)
- children = this.node.Children.map(node => {
- let treenode = this.sidebar.treeNodeComponents[node.UUID]
- if (treenode === undefined) {
- treenode = new N2TreeNode(this.sidebar, node, this)
- this.sidebar.treeNodeComponents[node.UUID] = treenode
- }
- return treenode
- })
-
- const renderedChildren = []
- for (const c of children)
- renderedChildren.push(await c.render())
- this.elChildren.replaceChildren(...renderedChildren)
-
- this.rendered = true
- return this
- }//}}}
-
- setImgSrc(img, newSrc) {// {{{
- if (img.getAttribute('src') === newSrc)
- return
- 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 daa603f..9b58cf7 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
}
}
@@ -167,83 +163,62 @@ export class Sync {
}
export class N2SyncProgress extends CustomHTMLElement {
- static {// {{{
+ 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/tree.mjs b/static/js/tree.mjs
new file mode 100644
index 0000000..3732fc5
--- /dev/null
+++ b/static/js/tree.mjs
@@ -0,0 +1,462 @@
+import { ROOT_NODE } from 'node_store'
+import { CustomHTMLElement } from './lib/custom_html_element.mjs'
+
+export class N2Tree extends CustomHTMLElement {
+ static {// {{{
+ this.tmpl = document.createElement('template')
+ this.tmpl.innerHTML = `
+
+
+
+
+
+
+ `
+ }// }}}
+
+ constructor() {// {{{
+ super()
+
+ this.id = 'tree-nodes'
+ this.tabIndex = 0
+
+ this.treeNodeComponents = {}
+ this.treeTrunk = []
+ this.expandedNodes = {} // keyed on UUID
+ this.selectedNode = null
+ this.rendered = false
+
+ this.addEventListener('keydown', event => this.keyHandler(event))
+ this.elSearch.addEventListener('click', () => _mbus.dispatch('op-search'))
+ this.elSync.addEventListener('click', () => _sync.run())
+ this.elLogo.addEventListener('click', () => _app.goToNode(ROOT_NODE, false, false))
+
+ _mbus.subscribe('NODE_MODIFIED', ({ detail })=>{
+ const node = detail.data.node
+ const treenode = this.treeNodeComponents[node.get('UUID')]
+
+ if (!treenode)
+ return
+
+ treenode.node = node
+ treenode.render(true)
+ })
+
+ this.populateFirstLevel()
+ }// }}}
+ render() {// {{{
+ if (this.rendered)
+ alert('Tree should only be rendered once.')
+
+ 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.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.log(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]
+ }//}}}
+ setNodeExpanded(node, value) {//{{{
+ let expanded = this.expandedNodes[node.UUID]
+
+ if (expanded === undefined) {
+ this.expandedNodes[node.UUID] = false
+ expanded = false
+ }
+
+ if (expanded === value)
+ return
+
+ this.expandedNodes[node.UUID] = value
+ _mbus.dispatch(`NODE_EXPAND_${node.UUID}`, value)
+ }//}}}
+ setSelected(node, dontExpand) {//{{{
+ if (node === undefined)
+ return
+
+ // The previously selected node, if any, needs to be rerendered
+ // to not retain its 'selected' class.
+ const prevUUID = this.selectedNode?.UUID
+ this.selectedNode = node
+ if (prevUUID)
+ this.treeNodeComponents[prevUUID]?.render(true)
+
+ // And now the newly selected node is rerendered.
+ this.treeNodeComponents[node.UUID]?.render(true)
+
+ if (!dontExpand)
+ this.setNodeExpanded(node, true)
+ }//}}}
+ isSelected(node) {//{{{
+ return this.selectedNode?.UUID === node.UUID
+ }//}}}
+
+ async keyHandler(event) {//{{{
+ let handled = true
+ const n = this.selectedNode
+ const Space = ' '
+
+ // This handler would otherwise react to stuff like Ctrl+L.
+ if (event.ctrlKey || event.altKey)
+ return
+
+ switch (event.key) {
+ // Space and enter is toggling expansion.
+ // Holding shift down does it recursively.
+ case Space:
+ case 'Enter':
+ const expanded = this.getNodeExpanded(n.UUID)
+ if (event.shiftKey) {
+ this.recursiveExpand(n, !expanded)
+ } else {
+ this.setNodeExpanded(n, !expanded)
+ }
+ break
+
+ case 'g':
+ case 'Home':
+ this.navigateTop()
+ break
+
+ case 'G':
+ case 'End':
+ this.navigateBottom()
+ break
+
+ case 'j':
+ case 'ArrowDown':
+ await this.navigateDown(this.selectedNode)
+ break
+
+ case 'k':
+ case 'ArrowUp':
+ await this.navigateUp(this.selectedNode)
+ break
+
+ case 'h':
+ case 'ArrowLeft':
+ await this.navigateLeft(this.selectedNode)
+ break
+
+ case 'l':
+ case 'ArrowRight':
+ await this.navigateRight(this.selectedNode)
+ break
+
+ default:
+ // nonsole.log(event.key)
+ handled = false
+ }
+
+ if (handled) {
+ event.preventDefault()
+ event.stopPropagation()
+ }
+ }//}}}
+ async navigateLeft(n) {//{{{
+ if (n === null || n === undefined)
+ return
+
+ const expanded = this.getNodeExpanded(n.UUID)
+ if (expanded && n.hasChildren()) {
+ this.setNodeExpanded(n, false)
+ return
+ }
+
+ if (n.isFirstSibling() && n.getParent().UUID !== ROOT_NODE) {
+ _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getParent()?.UUID, dontPush: false, dontExpand: true })
+ return
+ }
+
+ const siblingBefore = n.getSiblingBefore()
+ const siblingExpanded = this.getNodeExpanded(siblingBefore?.UUID)
+ if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) {
+ const siblingAbove = this.getLastExpandedNode(siblingBefore)
+ _mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingAbove?.UUID, dontPush: false, dontExpand: true })
+ return
+ }
+
+ _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingBefore()?.UUID, dontPush: false, dontExpand: true })
+ }//}}}
+ async navigateRight(n) {//{{{
+ if (n === null || n === undefined)
+ return
+
+ const siblingAfter = n.getSiblingAfter()
+ const expanded = this.getNodeExpanded(n.UUID)
+
+ if (!expanded && n.hasChildren()) {
+ this.setNodeExpanded(n, true)
+ return
+ }
+
+ if (expanded && n.hasChildren()) {
+ _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.Children[0]?.UUID, dontPush: false, dontExpand: true })
+ return
+ }
+
+ if (n.isLastSibling()) {
+ const nextNode = this.getParentWithNextSibling(n)
+ _mbus.dispatch("GO_TO_NODE", { nodeUUID: nextNode?.UUID, dontPush: false, dontExpand: true })
+ return
+ }
+
+ _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingAfter()?.UUID, dontPush: false, dontExpand: true })
+ }//}}}
+ async navigateUp(n) {//{{{
+ if (n === null || n === undefined)
+ return
+
+ let parent = null
+ const siblingBefore = n.getSiblingBefore()
+ let siblingExpanded = false
+ if (siblingBefore !== null)
+ siblingExpanded = this.getNodeExpanded(siblingBefore.UUID)
+
+ if (n.isFirstSibling()) {
+ parent = n.getParent()
+ if (parent?.UUID === ROOT_NODE)
+ return
+ _mbus.dispatch("GO_TO_NODE", { nodeUUID: parent?.UUID, dontPush: false, dontExpand: true })
+ return
+ }
+
+ if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) {
+ _mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingBefore.Children[siblingBefore.Children.length - 1]?.UUID, dontPush: false, dontExpand: true })
+ return
+ }
+
+ if (siblingBefore) {
+ _mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingBefore.UUID, dontPush: false, dontExpand: true })
+ return
+ }
+ }//}}}
+ async navigateDown(n) {//{{{
+ if (n === null || n === undefined)
+ return
+
+ const nodeExpanded = this.getNodeExpanded(n.UUID)
+
+ // Last node, not expanded, so it matters not whether it has children or not.
+ // Traverse upward to nearest parent with next sibling.
+ if (!nodeExpanded && n.isLastSibling()) {
+ const wantedNode = this.getParentWithNextSibling(n)
+ _mbus.dispatch("GO_TO_NODE", { nodeUUID: wantedNode?.UUID, dontPush: false, dontExpand: true })
+ return
+ }
+
+ if (nodeExpanded && n.isLastSibling() && !n.hasChildren()) {
+ const wantedNode = this.getParentWithNextSibling(n)
+ _mbus.dispatch("GO_TO_NODE", { nodeUUID: wantedNode?.UUID, dontPush: false, dontExpand: true })
+ return
+ }
+
+ // Node not expanded. Go to this node's next sibling.
+ // GoToNode will abort if given null.
+ if (!nodeExpanded || !n.hasChildren()) {
+ _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingAfter()?.UUID, dontPush: false, dontExpand: true })
+ return
+ }
+
+ // Node is expanded.
+ // Children will be visually beneath this node, if any.
+ if (nodeExpanded && n.hasChildren()) {
+ _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.Children[0].UUID, dontPush: false, dontExpand: true })
+ return
+ }
+ }//}}}
+ 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 })
+ }//}}}
+ async navigateBottom() {//{{{
+ const root = await nodeStore.get(ROOT_NODE)
+ if (root.Children.length === 0)
+ return
+
+ const toplevel = root.Children[root.Children.length - 1]
+ const toplevelExpanded = this.getNodeExpanded(toplevel?.UUID)
+
+ if (toplevelExpanded) {
+ const lastnode = this.getLastExpandedNode(toplevel)
+ _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 })
+ }//}}}
+
+ getParentWithNextSibling(node) {//{{{
+ let currNode = node
+ while (currNode !== null && currNode.UUID !== ROOT_NODE && currNode.getSiblingAfter() === null) {
+ currNode = currNode.getParent()
+ }
+ return currNode?.getSiblingAfter()
+ }//}}}
+ getLastExpandedNode(node) {//{{{
+ let currNode = node
+ while (this.getNodeExpanded(currNode.UUID) && currNode.hasChildren()) {
+ currNode = currNode.Children[currNode.Children.length - 1]
+ }
+ return currNode
+ }//}}}
+ async recursiveExpand(node, state) {//{{{
+ if (state)
+ await this.setNodeExpanded(node, true)
+
+ for (const child of node.Children)
+ await this.recursiveExpand(child, state)
+
+ if (!state)
+ await this.setNodeExpanded(node, false)
+ }//}}}
+ async makeVisible(node) {// {{{
+ const treenode = this.treeNodeComponents[node.UUID]
+
+ 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 {// {{{
+ this.tmpl = document.createElement('template')
+ this.tmpl.innerHTML = `
+
+
+
+ `
+ }// }}}
+
+ constructor(tree, node, parent) {//{{{
+ super()
+ this.classList.add('node')
+
+ this.tree = tree
+ this.node = node
+ this.parent = parent
+
+ this.children_populated = false
+ this.rendered = false
+
+ 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)
+ })
+
+ if (this.node.Level === 0 || this.tree.getNodeExpanded(this.node.UUID))
+ this.fetchChildren()
+ }// }}}
+ async fetchChildren() {//{{{
+ await this.node.fetchChildren()
+ this.children_populated = true
+ }//}}}
+ render(force_update) {//{{{
+ if (this.rendered && force_update !== true)
+ return this
+
+ // Fetch the next level of children if the parent tree node is expanded and our children thus will be visible.
+ const expanded = this.node.Children.length > 0 && this.tree.getNodeExpanded(this.node.UUID)
+
+ if (!this.children_populated && this.tree.getNodeExpanded(this.parent?.node.UUID)) {
+ this.node.fetchChildren().then(() => this.children_populated = true)
+ }
+
+ // 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
+ if (expanded) {
+ this.elChildren.classList.add('expanded')
+ this.elChildren.classList.remove('collapsed')
+ } else {
+ this.elChildren.classList.remove('expanded')
+ this.elChildren.classList.add('collapsed')
+ }
+
+ // The expand icon
is only changed to not get a flickering when re-rendering.
+ if (this.node.Children.length === 0)
+ this.setImgSrc(this.elExpand, `/images/${window._VERSION}/leaf.svg`)
+ 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.tree.treeNodeComponents[node.UUID]
+ if (treenode === undefined) {
+ treenode = new N2TreeNode(this.tree, node, this)
+ this.tree.treeNodeComponents[node.UUID] = treenode
+ }
+ return treenode
+ })
+
+ for (const c of children)
+ this.elChildren.appendChild(c.render())
+
+ this.rendered = true
+ return this
+ }//}}}
+
+ setImgSrc(img, newSrc) {// {{{
+ if (img.getAttribute('src') === newSrc)
+ return
+ img.setAttribute('src', newSrc)
+ }// }}}
+}
+customElements.define('n2-treenode', N2TreeNode)
+
+// vim: foldmethod=marker
diff --git a/static/service_worker.js b/static/service_worker.js
index 8522b20..806eaad 100644
--- a/static/service_worker.js
+++ b/static/service_worker.js
@@ -1,50 +1,41 @@
const CACHE_NAME = 'notes2-{{ .VERSION }}'
const CACHED_ASSETS = [
'/',
+ '/notes2',
'/offline',
'/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',
'/images/{{ .VERSION }}/expanded.svg',
- '/images/{{ .VERSION }}/icon_history.svg',
- '/images/{{ .VERSION }}/icon_home.svg',
'/images/{{ .VERSION }}/icon_markdown_hollow.svg',
'/images/{{ .VERSION }}/icon_markdown.svg',
'/images/{{ .VERSION }}/icon_refresh.svg',
'/images/{{ .VERSION }}/icon_save_disabled.svg',
- '/images/{{ .VERSION }}/icon_save.svg',
'/images/{{ .VERSION }}/icon_search.svg',
- '/images/{{ .VERSION }}/icon_settings.svg',
- '/images/{{ .VERSION }}/icon_table.svg',
'/images/{{ .VERSION }}/leaf.svg',
- '/images/{{ .VERSION }}/logo_small.svg',
'/images/{{ .VERSION }}/logo.svg',
'/js/{{ .VERSION }}/api.mjs',
'/js/{{ .VERSION }}/app.mjs',
'/js/{{ .VERSION }}/checklist.mjs',
'/js/{{ .VERSION }}/crypto.mjs',
- '/js/{{ .VERSION }}/file.mjs',
'/js/{{ .VERSION }}/key.mjs',
- '/js/{{ .VERSION }}/lib/css_colorize.mjs',
'/js/{{ .VERSION }}/lib/custom_html_element.mjs',
+ '/js/{{ .VERSION }}/lib/fullcalendar.min.js',
'/js/{{ .VERSION }}/lib/node_modules/marked/lib/marked.esm.js',
'/js/{{ .VERSION }}/lib/node_modules/marked-token-position/lib/index.esm.js',
'/js/{{ .VERSION }}/lib/sjcl.js',
'/js/{{ .VERSION }}/marked_position.mjs',
'/js/{{ .VERSION }}/mbus.mjs',
+ '/js/{{ .VERSION }}/node.mjs',
'/js/{{ .VERSION }}/node_store.mjs',
'/js/{{ .VERSION }}/notes2.mjs',
- '/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 +111,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..8ffa278 100644
--- a/views/layouts/main.gotmpl
+++ b/views/layouts/main.gotmpl
@@ -14,8 +14,12 @@
"checklist": "/js/{{ .VERSION }}/checklist.mjs",
"crypto": "/js/{{ .VERSION }}/crypto.mjs",
"node_store": "/js/{{ .VERSION }}/node_store.mjs",
- "node": "/js/{{ .VERSION }}/page_node.mjs",
- "sidebar": "/js/{{ .VERSION }}/sidebar.mjs"
+ "node": "/js/{{ .VERSION }}/node.mjs",
+ "tree": "/js/{{ .VERSION }}/tree.mjs"
+ {{/*
+ "session": "/js/{{ .VERSION }}/session.mjs",
+ "ws": "/_js/{{ .VERSION }}/websocket.mjs"
+ */}}
}
}
@@ -29,6 +33,8 @@
import { MessageBus } from '/js/{{ .VERSION }}/mbus.mjs'
window._mbus = new MessageBus()
+
+
{{ block "page" . }}{{ end }}
diff --git a/views/pages/login.gotmpl b/views/pages/login.gotmpl
index 3e2235f..3f4406e 100644
--- a/views/pages/login.gotmpl
+++ b/views/pages/login.gotmpl
@@ -29,7 +29,7 @@ class Login {
const password = document.getElementById('password').value
API.authenticate(username, password)
.then(ans=>{
- location.href = '/'
+ location.href = '/notes2'
})
.catch(e=>{
setTimeout(()=>this.errorDiv.innerText = e, 75)
diff --git a/views/pages/notes2.gotmpl b/views/pages/notes2.gotmpl
index 2755aea..77b74a6 100644
--- a/views/pages/notes2.gotmpl
+++ b/views/pages/notes2.gotmpl
@@ -1,56 +1,18 @@
{{ define "page" }}
-
-
-
-
-
-
-
-
- >
+
-
-
-
-
-
-
-
-
-
-
- {{ .VERSION }}
-
- Create note
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-