diff --git a/authentication/pkg.go b/authentication/pkg.go
index 9eb6245..c0b9a2e 100644
--- a/authentication/pkg.go
+++ b/authentication/pkg.go
@@ -8,6 +8,9 @@ import (
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
+ // Internal
+ appUser "notes2/user"
+
// Standard
"database/sql"
"encoding/hex"
@@ -27,12 +30,6 @@ type Manager struct {
ExpireDays int
}
-type User struct {
- ID int
- Username string
- Name string
-}
-
func httpError(w http.ResponseWriter, err error) { // {{{
j, _ := json.Marshal(struct {
OK bool
@@ -165,16 +162,16 @@ func (mngr *Manager) AuthenticationHandler(w http.ResponseWriter, r *http.Reques
mngr.log.Info("authentication", "username", request.Username, "status", "accepted")
j, _ := json.Marshal(struct {
OK bool
- User User
+ User appUser.User
Token string
}{true, user, token})
w.Write(j)
} // }}}
-func (mngr *Manager) Authenticate(username, password string) (authenticated bool, user User, err error) { // {{{
+func (mngr *Manager) Authenticate(username, password string) (authenticated bool, user appUser.User, err error) { // {{{
var row *sql.Row
row = mngr.db.QueryRow(`
- SELECT id, username, name
+ SELECT id, username, name, preferences
FROM public.user
WHERE
LOWER(username) = LOWER($1) AND
@@ -183,13 +180,21 @@ func (mngr *Manager) Authenticate(username, password string) (authenticated bool
username,
password,
)
- err = row.Scan(&user.ID, &user.Username, &user.Name)
+ var data []byte
+ err = row.Scan(&user.ID, &user.Username, &user.Name, &data)
if err != nil && err.Error() == "sql: no rows in result set" {
err = nil
authenticated = false
return
}
if err != nil {
+ authenticated = false
+ return
+ }
+
+ err = json.Unmarshal(data, &user.Preferences)
+ if err != nil {
+ authenticated = false
return
}
@@ -278,7 +283,7 @@ func (mngr *Manager) ChangePassword(username, currentPassword, newPassword strin
changed = (rowsAffected == 1)
return
} // }}}
-func (mngr *Manager) NewClientUUID(user User) (clientUUID string, err error) { // {{{
+func (mngr *Manager) NewClientUUID(user appUser.User) (clientUUID string, err error) { // {{{
// Each client session has its own UUID.
// Loop through until a unique one is established.
var proposedClientUUID string
diff --git a/main.go b/main.go
index 10d578b..6e3cf94 100644
--- a/main.go
+++ b/main.go
@@ -4,6 +4,7 @@ import (
// Internal
"notes2/authentication"
"notes2/html_template"
+ appUser "notes2/user"
"os"
// Standard
@@ -23,7 +24,7 @@ import (
"text/template"
)
-const VERSION = "v11"
+const VERSION = "v29"
const CONTEXT_USER = 1
const SYNC_PAGINATION = 200
@@ -134,12 +135,16 @@ func main() { // {{{
http.HandleFunc("/offline", pageOffline)
http.HandleFunc("/user/authenticate", AuthManager.AuthenticationHandler)
+ http.HandleFunc("GET /user/preferences", authenticated(actionUserGetPreferences))
+ http.HandleFunc("POST /user/preferences", authenticated(actionUserSetPreferences))
http.HandleFunc("/sync/from_server/count/{sequence}", authenticated(actionSyncFromServerCount))
http.HandleFunc("/sync/from_server/{sequence}/{offset}", authenticated(actionSyncFromServer))
http.HandleFunc("/sync/to_server", authenticated(actionSyncToServer))
http.HandleFunc("/node/retrieve/{uuid}", authenticated(actionNodeRetrieve))
+ http.HandleFunc("/node/history/retrieve/{uuid}/{offset}", authenticated(actionNodeHistoryRetrieve))
+ http.HandleFunc("/node/history/count/{uuid}", authenticated(actionNodeHistoryCount))
http.HandleFunc("/service_worker.js", pageServiceWorker)
@@ -176,7 +181,7 @@ func authenticated(fn func(http.ResponseWriter, *http.Request)) func(http.Respon
}
// User object is added to the context for the next handler.
- user := NewUser(claims)
+ user := appUser.NewUser(claims)
r = r.WithContext(context.WithValue(r.Context(), CONTEXT_USER, user))
Log.Debug("webserver", "op", "request", "method", r.Method, "url", r.URL.String(), "username", user.Username, "client", user.ClientUUID)
@@ -264,7 +269,7 @@ func actionSyncFromServer(w http.ResponseWriter, r *http.Request) { // {{{
// The purpose of the Client UUID is to avoid
// sending nodes back once again to a client that
// just created or modified it.
- user := getUser(r)
+ user := getUserSession(r)
changedFrom, _ := strconv.Atoi(r.PathValue("sequence"))
offset, _ := strconv.Atoi(r.PathValue("offset"))
@@ -275,12 +280,6 @@ func actionSyncFromServer(w http.ResponseWriter, r *http.Request) { // {{{
return
}
- /*
- Log.Debug("/sync/from_server", "num_nodes", len(nodes), "maxSeq", maxSeq)
- foo, _ := json.Marshal(nodes)
- os.WriteFile(fmt.Sprintf("/tmp/nodes-%d.json", offset), foo, 0644)
- */
-
j, _ := json.Marshal(struct {
OK bool
Nodes []Node
@@ -293,7 +292,7 @@ func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{
// The purpose of the Client UUID is to avoid
// sending nodes back once again to a client that
// just created or modified it.
- user := getUser(r)
+ user := getUserSession(r)
changedFrom, _ := strconv.Atoi(r.PathValue("sequence"))
count, err := NodesCount(user.UserID, uint64(changedFrom), user.ClientUUID)
@@ -313,7 +312,7 @@ func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{
w.Write(j)
} // }}}
func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
- user := getUser(r)
+ user := getUserSession(r)
var err error
uuid := r.PathValue("uuid")
@@ -328,8 +327,48 @@ 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 := getUser(r)
+ user := getUserSession(r)
body, _ := io.ReadAll(r.Body)
var request struct {
@@ -341,9 +380,50 @@ func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{
return
}
- _, err = db.Exec(`CALL add_nodes($1, $2, $3::jsonb)`, user.UserID, user.ClientUUID, request.NodeData)
+ _, err = db.Exec(`CALL add_nodes($1, $2::uuid, $3::jsonb)`, user.UserID, user.ClientUUID, request.NodeData)
+ if err != nil {
+ Log.Error("sync", "error", err, "user_id", user.UserID, "client_uuid", user.ClientUUID, "node_data", request.NodeData)
+ httpError(w, err)
+ return
+ }
+
+ responseData(w, map[string]any{
+ "OK": true,
+ })
+} // }}}
+
+func actionUserGetPreferences(w http.ResponseWriter, r *http.Request) { // {{{
+ user := getUserSession(r)
+ prefs, err := user.Preferences()
+ if err != nil {
+ httpError(w, err)
+ return
+ }
+
+ responseData(w, map[string]any{
+ "OK": true,
+ "Preferences": prefs,
+ })
+} // }}}
+func actionUserSetPreferences(w http.ResponseWriter, r *http.Request) { // {{{
+ session := getUserSession(r)
+
+ // Verify the "default" profile is still there.
+ var newPrefs map[string]appUser.UserPreferences
+ body, _ := io.ReadAll(r.Body)
+ err := json.Unmarshal(body, &newPrefs)
+ if err != nil {
+ httpError(w, err)
+ return
+ }
+
+ if _, found := newPrefs["default"]; !found {
+ httpError(w, fmt.Errorf("'default' profile missing."))
+ return
+ }
+
+ err = session.SetPreferences(newPrefs)
if err != nil {
- Log.Error("sync", "error", err)
httpError(w, err)
return
}
@@ -395,7 +475,8 @@ func changePassword(username string) { // {{{
fmt.Printf("\nPassword changed\n")
} // }}}
-func getUser(r *http.Request) UserSession { // {{{
- user, _ := r.Context().Value(CONTEXT_USER).(UserSession)
+func getUserSession(r *http.Request) appUser.UserSession { // {{{
+ user, _ := r.Context().Value(CONTEXT_USER).(appUser.UserSession)
+ user.Db = db
return user
} // }}}
diff --git a/node.go b/node.go
index ffcc89f..a25c771 100644
--- a/node.go
+++ b/node.go
@@ -3,8 +3,8 @@ package main
import (
// External
werr "git.gibonuddevalla.se/go/wrappederror"
- "github.com/jmoiron/sqlx"
"github.com/derektata/lorem/ipsum"
+ "github.com/jmoiron/sqlx"
// Standard
"database/sql"
@@ -44,6 +44,7 @@ type Node struct {
UUID string
UserID int `db:"user_id"`
ParentUUID string `db:"parent_uuid"`
+ HistoryUUID string `db:"history_uuid"`
Name string
Created time.Time
Updated time.Time
@@ -53,11 +54,7 @@ type Node struct {
DeletedSeq sql.NullInt64 `db:"deleted_seq"`
Content string
ContentEncrypted string `db:"content_encrypted" json:"-"`
- Markdown bool
-
- // CryptoKeyID int `db:"crypto_key_id"`
- //Files []File
- //ChecklistGroups []ChecklistGroup
+ Special bool
}
func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint64, moreRowsExist bool, err error) { // {{{
@@ -78,7 +75,7 @@ func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint6
public.node
WHERE
user_id = $1 AND
- NOT history AND (
+ (
created_seq > $4 OR
updated_seq > $4 OR
deleted_seq > $4
@@ -126,7 +123,7 @@ func Nodes(userID, offset int, synced uint64, clientUUID string) (nodes []Node,
rows, err = db.Queryx(`
SELECT
uuid,
- COALESCE(parent_uuid, '') AS parent_uuid,
+ COALESCE(parent_uuid, '00000000-0000-0000-0000-000000000000'::uuid) AS parent_uuid,
name,
created,
updated,
@@ -135,14 +132,14 @@ func Nodes(userID, offset int, synced uint64, clientUUID string) (nodes []Node,
updated_seq,
deleted_seq,
content,
- content_encrypted,
- markdown
+ content_encrypted
FROM
public.node
WHERE
+ NOT special AND
user_id = $1 AND
- client != $5 AND
- NOT history AND (
+ client != $5::uuid AND
+ (
created_seq > $4 OR
updated_seq > $4 OR
deleted_seq > $4
@@ -195,7 +192,7 @@ func NodesCount(userID int, synced uint64, clientUUID string) (count int, err er
WHERE
user_id = $1 AND
client != $3 AND
- NOT history AND (
+ (
created_seq > $2 OR
updated_seq > $2 OR
deleted_seq > $2
@@ -248,6 +245,72 @@ 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 7eb8273..4aecc91 100644
--- a/sql/00001.sql
+++ b/sql/00001.sql
@@ -257,7 +257,7 @@ $$;
CREATE TABLE public.client (
id integer NOT NULL,
user_id integer NOT NULL,
- client_uuid character(36) DEFAULT ''::bpchar NOT NULL,
+ client_uuid uuid DEFAULT '00000000-0000-0000-0000-000000000000'::uuid NOT NULL,
created timestamp with time zone DEFAULT now() NOT NULL,
description character varying DEFAULT ''::character varying NOT NULL
);
@@ -302,8 +302,8 @@ CREATE SEQUENCE public.node_updates
CREATE TABLE public.node (
id integer NOT NULL,
user_id integer NOT NULL,
- uuid character(36) DEFAULT gen_random_uuid() NOT NULL,
- parent_uuid character(36),
+ "uuid" uuid DEFAULT gen_random_uuid() NOT NULL,
+ parent_uuid uuid,
created timestamp with time zone DEFAULT now() NOT NULL,
updated timestamp with time zone DEFAULT now() NOT NULL,
deleted timestamp with time zone,
@@ -315,7 +315,7 @@ CREATE TABLE public.node (
content_encrypted text DEFAULT ''::text NOT NULL,
markdown boolean DEFAULT false NOT NULL,
history boolean DEFAULT false NOT NULL,
- client character(36) DEFAULT ''::bpchar NOT NULL,
+ client uuid DEFAULT '00000000-0000-0000-0000-000000000000'::uuid NOT NULL,
client_sequence integer,
CONSTRAINT name_length CHECK ((length(TRIM(BOTH FROM name)) > 0))
);
@@ -328,7 +328,7 @@ CREATE TABLE public.node (
CREATE TABLE public.node_history (
id integer NOT NULL,
user_id integer NOT NULL,
- uuid character(36) NOT NULL,
+ "uuid" uuid NOT NULL,
parents character varying[],
created timestamp with time zone NOT NULL,
updated timestamp with time zone NOT NULL,
@@ -336,7 +336,7 @@ CREATE TABLE public.node_history (
content text NOT NULL,
content_encrypted text NOT NULL,
markdown boolean DEFAULT false NOT NULL,
- client character(36) DEFAULT ''::bpchar NOT NULL,
+ client uuid DEFAULT '00000000-0000-0000-0000-000000000000'::uuid NOT NULL,
client_sequence integer
);
diff --git a/sql/00003.sql b/sql/00003.sql
new file mode 100644
index 0000000..a0cd4b1
--- /dev/null
+++ b/sql/00003.sql
@@ -0,0 +1 @@
+ALTER TABLE public.node_history ADD history_uuid uuid NULL;
diff --git a/sql/00004.sql b/sql/00004.sql
new file mode 100644
index 0000000..eafbad2
--- /dev/null
+++ b/sql/00004.sql
@@ -0,0 +1,135 @@
+CREATE UNIQUE INDEX node_history_user_id_idx ON public.node_history (user_id,"uuid",history_uuid);
+
+
+ALTER TABLE public.node ALTER COLUMN "uuid" TYPE uuid USING "uuid"::uuid::uuid;
+ALTER TABLE public.node ALTER COLUMN parent_uuid TYPE uuid USING parent_uuid::uuid::uuid;
+ALTER TABLE public.node ALTER COLUMN client TYPE uuid USING client::uuid::uuid;
+
+
+
+CREATE OR REPLACE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid uuid, IN p_nodes jsonb)
+ LANGUAGE plpgsql
+AS $procedure$
+
+DECLARE
+ node_data jsonb;
+ node_updated timestamptz;
+ db_updated timestamptz;
+ db_uuid uuid;
+ db_client uuid;
+ db_client_seq int;
+ db_history_uuid uuid;
+ node_uuid uuid;
+ node_parent_uuid uuid;
+ node_history_uuid uuid;
+
+BEGIN
+ RAISE NOTICE '--------------------------';
+ FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes)
+ LOOP
+ node_uuid = (node_data->>'UUID')::uuid;
+ node_history_uuid = (node_data->>'HistoryUUID')::uuid;
+ node_updated = (node_data->>'Updated')::timestamptz;
+
+
+
+ -- Frontend is using an all-zero UUID to define the root node.
+ -- Database is using NULL.
+ IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' THEN
+ node_parent_uuid = NULL;
+ ELSE
+ node_parent_uuid = (node_data->>'ParentUUID')::uuid;
+ END IF;
+
+
+
+ -- Every jode has a new history UUID to keep the history entry uniquely identifiable
+ -- across clients. A history entry could potentially be sent again, but should be
+ -- safe to ignore as every change to a node should have a new history UUID.
+ --
+ -- The current node is also stored as history.
+ INSERT INTO node_history(
+ user_id, "uuid", "history_uuid", parents, created, updated,
+ "name", "content", markdown, "content_encrypted",
+ client, client_sequence
+ )
+ VALUES(
+ p_user_id, -- combined key
+ node_uuid, -- combined key
+ node_history_uuid, -- combined key
+ (jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors",
+ (node_data->>'Created')::timestamptz,
+ (node_data->>'Updated')::timestamptz,
+ (node_data->>'Name')::varchar,
+ (node_data->>'Content')::text,
+ (node_data->>'Markdown')::bool,
+ '', /* content_encrypted */
+ p_client_uuid,
+ (node_data->>'ClientSequence')::int
+ )
+ ON CONFLICT ("user_id", "uuid", "history_uuid")
+ DO NOTHING;
+
+
+
+ -- Retrieve the current modified timestamp for this node from the database.
+ SELECT
+ uuid, updated, client, client_sequence
+ INTO
+ db_uuid, db_updated, db_client, db_client_seq
+ FROM public."node"
+ WHERE
+ user_id = p_user_id AND
+ uuid::uuid = node_uuid::uuid;
+
+
+
+ -- Is the node not in database? It needs to be created.
+ IF db_uuid IS NULL THEN
+ RAISE NOTICE '01 New node %', node_uuid;
+
+ INSERT INTO public."node" (
+ user_id, "uuid", parent_uuid, created, updated,
+ "name", "content", markdown, "content_encrypted",
+ client, client_sequence
+ )
+ VALUES(
+ p_user_id,
+ node_uuid,
+ node_parent_uuid,
+ (node_data->>'Created')::timestamptz,
+ (node_data->>'Updated')::timestamptz,
+ (node_data->>'Name')::varchar,
+ (node_data->>'Content')::text,
+ (node_data->>'Markdown')::bool,
+ '', /* content_encrypted */
+ p_client_uuid,
+ (node_data->>'ClientSequence')::int
+ );
+
+ CONTINUE;
+
+ END IF;
+
+
+
+ -- Update the public node as well if it was older than incoming node.
+ IF node_updated > db_updated THEN
+ UPDATE public."node"
+ SET
+ updated = (node_data->>'Updated')::timestamptz,
+ updated_seq = nextval('node_updates'),
+ name = (node_data->>'Name')::varchar,
+ content = (node_data->>'Content')::text,
+ markdown = (node_data->>'Markdown')::bool,
+ client = p_client_uuid,
+ client_sequence = (node_data->>'ClientSequence')::int
+ WHERE
+ user_id = p_user_id AND
+ uuid::uuid = node_uuid::uuid;
+ END IF;
+
+ END LOOP;
+END
+$procedure$
+;
diff --git a/sql/00005.sql b/sql/00005.sql
new file mode 100644
index 0000000..b272085
--- /dev/null
+++ b/sql/00005.sql
@@ -0,0 +1,129 @@
+-- Some cleanup of old columns not used anymore.
+DROP INDEX public.node_history_client_idx;
+ALTER TABLE public.node_history DROP COLUMN client_sequence;
+
+ALTER TABLE public.node DROP COLUMN markdown;
+DROP INDEX public.node_history_idx;
+ALTER TABLE public.node DROP COLUMN history;
+ALTER TABLE public.node DROP COLUMN client_sequence;
+
+
+
+CREATE OR REPLACE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid uuid, IN p_nodes jsonb)
+ LANGUAGE plpgsql
+AS $procedure$
+
+DECLARE
+ node_data jsonb;
+ node_updated timestamptz;
+ db_updated timestamptz;
+ db_uuid uuid;
+ db_client uuid;
+ db_history_uuid uuid;
+ node_uuid uuid;
+ node_parent_uuid uuid;
+ node_history_uuid uuid;
+
+BEGIN
+ FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes)
+ LOOP
+ node_uuid = (node_data->>'UUID')::uuid;
+ node_history_uuid = (node_data->>'HistoryUUID')::uuid;
+ node_updated = (node_data->>'Updated')::timestamptz;
+
+
+
+ -- Frontend is using an all-zero UUID to define the root node.
+ -- Database is using NULL.
+ IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' OR node_data->>'ParentUUID' = '' THEN
+ node_parent_uuid = NULL;
+ ELSE
+ node_parent_uuid = (node_data->>'ParentUUID')::uuid;
+ END IF;
+
+
+
+ -- Every jode has a new history UUID to keep the history entry uniquely identifiable
+ -- across clients. A history entry could potentially be sent again, but should be
+ -- safe to ignore as every change to a node should have a new history UUID.
+ --
+ -- The current node is also stored as history.
+ INSERT INTO node_history(
+ user_id, "uuid", "history_uuid", parents, created, updated,
+ "name", "content", "content_encrypted",
+ client
+ )
+ VALUES(
+ p_user_id, -- combined key
+ node_uuid, -- combined key
+ node_history_uuid, -- combined key
+ (jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors",
+ COALESCE((node_data->>'Created')::timestamptz, NOW()),
+ COALESCE((node_data->>'Updated')::timestamptz, NOW()),
+ (node_data->>'Name')::varchar,
+ (node_data->>'Content')::text,
+ '', /* content_encrypted */
+ p_client_uuid
+ )
+ ON CONFLICT ("user_id", "uuid", "history_uuid")
+ DO NOTHING;
+
+
+
+ -- Retrieve the current modified timestamp for this node from the database.
+ SELECT
+ uuid, updated, client
+ INTO
+ db_uuid, db_updated, db_client
+ FROM public."node"
+ WHERE
+ user_id = p_user_id AND
+ uuid::uuid = node_uuid::uuid;
+
+
+
+ -- Is the node not in database? It needs to be created.
+ IF db_uuid IS NULL THEN
+ RAISE NOTICE '01 New node %', node_uuid;
+
+ INSERT INTO public."node" (
+ user_id, "uuid", parent_uuid, created, updated,
+ "name", "content", "content_encrypted",
+ client
+ )
+ VALUES(
+ p_user_id,
+ node_uuid,
+ node_parent_uuid,
+ COALESCE((node_data->>'Created')::timestamptz, NOW()),
+ COALESCE((node_data->>'Updated')::timestamptz, NOW()),
+ (node_data->>'Name')::varchar,
+ (node_data->>'Content')::text,
+ '', /* content_encrypted */
+ p_client_uuid
+ );
+
+ CONTINUE;
+
+ END IF;
+
+
+
+ -- Update the public node as well if it was older than incoming node.
+ IF node_updated > db_updated THEN
+ UPDATE public."node"
+ SET
+ updated = (node_data->>'Updated')::timestamptz,
+ updated_seq = nextval('node_updates'),
+ name = (node_data->>'Name')::varchar,
+ content = (node_data->>'Content')::text,
+ client = p_client_uuid
+ WHERE
+ user_id = p_user_id AND
+ uuid::uuid = node_uuid::uuid;
+ END IF;
+
+ END LOOP;
+END
+$procedure$
+;
diff --git a/sql/00006.sql b/sql/00006.sql
new file mode 100644
index 0000000..56f2acb
--- /dev/null
+++ b/sql/00006.sql
@@ -0,0 +1,119 @@
+CREATE OR REPLACE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid uuid, IN p_nodes jsonb)
+ LANGUAGE plpgsql
+AS $procedure$
+
+DECLARE
+ node_data jsonb;
+ node_updated timestamptz;
+ db_updated timestamptz;
+ db_uuid uuid;
+ db_client uuid;
+ db_history_uuid uuid;
+ node_uuid uuid;
+ node_parent_uuid uuid;
+ node_history_uuid uuid;
+
+BEGIN
+ FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes)
+ LOOP
+ node_uuid = (node_data->>'UUID')::uuid;
+ node_history_uuid = (node_data->>'HistoryUUID')::uuid;
+ node_updated = (node_data->>'Updated')::timestamptz;
+
+
+
+ -- Frontend is using an all-zero UUID to define the root node.
+ -- Database is using NULL.
+ IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' OR node_data->>'ParentUUID' = '' THEN
+ node_parent_uuid = NULL;
+ ELSE
+ node_parent_uuid = (node_data->>'ParentUUID')::uuid;
+ END IF;
+
+
+
+ -- Every jode has a new history UUID to keep the history entry uniquely identifiable
+ -- across clients. A history entry could potentially be sent again, but should be
+ -- safe to ignore as every change to a node should have a new history UUID.
+ --
+ -- The current node is also stored as history.
+ INSERT INTO node_history(
+ user_id, "uuid", "history_uuid", parents, created, updated,
+ "name", "content", "content_encrypted",
+ client
+ )
+ VALUES(
+ p_user_id, -- combined key
+ node_uuid, -- combined key
+ node_history_uuid, -- combined key
+ (jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors",
+ COALESCE((node_data->>'Created')::timestamptz, NOW()),
+ COALESCE((node_data->>'Updated')::timestamptz, NOW()),
+ (node_data->>'Name')::varchar,
+ (node_data->>'Content')::text,
+ '', /* content_encrypted */
+ p_client_uuid
+ )
+ ON CONFLICT ("user_id", "uuid", "history_uuid")
+ DO NOTHING;
+
+
+
+ -- Retrieve the current modified timestamp for this node from the database.
+ SELECT
+ uuid, updated, client
+ INTO
+ db_uuid, db_updated, db_client
+ FROM public."node"
+ WHERE
+ user_id = p_user_id AND
+ uuid::uuid = node_uuid::uuid;
+
+
+
+ -- Is the node not in database? It needs to be created.
+ IF db_uuid IS NULL THEN
+ RAISE NOTICE '01 New node %', node_uuid;
+
+ INSERT INTO public."node" (
+ user_id, "uuid", parent_uuid, created, updated,
+ "name", "content", "content_encrypted",
+ client
+ )
+ VALUES(
+ p_user_id,
+ node_uuid,
+ node_parent_uuid,
+ COALESCE((node_data->>'Created')::timestamptz, NOW()),
+ COALESCE((node_data->>'Updated')::timestamptz, NOW()),
+ (node_data->>'Name')::varchar,
+ (node_data->>'Content')::text,
+ '', /* content_encrypted */
+ p_client_uuid
+ );
+
+ CONTINUE;
+
+ END IF;
+
+
+
+ -- Update the public node as well if it was older than incoming node.
+ IF node_updated > db_updated THEN
+ UPDATE public."node"
+ SET
+ updated = (node_data->>'Updated')::timestamptz,
+ updated_seq = nextval('node_updates'),
+ parent_uuid = (node_data->>'ParentUUID')::uuid,
+ name = (node_data->>'Name')::varchar,
+ content = (node_data->>'Content')::text,
+ client = p_client_uuid
+ WHERE
+ user_id = p_user_id AND
+ uuid::uuid = node_uuid::uuid;
+ END IF;
+
+ END LOOP;
+END
+$procedure$
+;
diff --git a/sql/00007.sql b/sql/00007.sql
new file mode 100644
index 0000000..0b79d9c
--- /dev/null
+++ b/sql/00007.sql
@@ -0,0 +1,119 @@
+CREATE OR REPLACE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid uuid, IN p_nodes jsonb)
+ LANGUAGE plpgsql
+AS $procedure$
+
+DECLARE
+ node_data jsonb;
+ node_updated timestamptz;
+ db_updated timestamptz;
+ db_uuid uuid;
+ db_client uuid;
+ db_history_uuid uuid;
+ node_uuid uuid;
+ node_parent_uuid uuid;
+ node_history_uuid uuid;
+
+BEGIN
+ FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes)
+ LOOP
+ node_uuid = (node_data->>'UUID')::uuid;
+ node_history_uuid = (node_data->>'HistoryUUID')::uuid;
+ node_updated = (node_data->>'Updated')::timestamptz;
+
+
+
+ -- Frontend is using an all-zero UUID to define the root node.
+ -- Database is using NULL.
+ IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' OR node_data->>'ParentUUID' = '' THEN
+ node_parent_uuid = NULL;
+ ELSE
+ node_parent_uuid = (node_data->>'ParentUUID')::uuid;
+ END IF;
+
+
+
+ -- Every jode has a new history UUID to keep the history entry uniquely identifiable
+ -- across clients. A history entry could potentially be sent again, but should be
+ -- safe to ignore as every change to a node should have a new history UUID.
+ --
+ -- The current node is also stored as history.
+ INSERT INTO node_history(
+ user_id, "uuid", "history_uuid", parents, created, updated,
+ "name", "content", "content_encrypted",
+ client
+ )
+ VALUES(
+ p_user_id, -- combined key
+ node_uuid, -- combined key
+ node_history_uuid, -- combined key
+ (jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors",
+ COALESCE((node_data->>'Created')::timestamptz, NOW()),
+ COALESCE((node_data->>'Updated')::timestamptz, NOW()),
+ (node_data->>'Name')::varchar,
+ (node_data->>'Content')::text,
+ '', /* content_encrypted */
+ p_client_uuid
+ )
+ ON CONFLICT ("user_id", "uuid", "history_uuid")
+ DO NOTHING;
+
+
+
+ -- Retrieve the current modified timestamp for this node from the database.
+ SELECT
+ uuid, updated, client
+ INTO
+ db_uuid, db_updated, db_client
+ FROM public."node"
+ WHERE
+ user_id = p_user_id AND
+ uuid::uuid = node_uuid::uuid;
+
+
+
+ -- Is the node not in database? It needs to be created.
+ IF db_uuid IS NULL THEN
+ RAISE NOTICE '01 New node %', node_uuid;
+
+ INSERT INTO public."node" (
+ user_id, "uuid", parent_uuid, created, updated,
+ "name", "content", "content_encrypted",
+ client
+ )
+ VALUES(
+ p_user_id,
+ node_uuid,
+ node_parent_uuid,
+ COALESCE((node_data->>'Created')::timestamptz, NOW()),
+ COALESCE((node_data->>'Updated')::timestamptz, NOW()),
+ (node_data->>'Name')::varchar,
+ (node_data->>'Content')::text,
+ '', /* content_encrypted */
+ p_client_uuid
+ );
+
+ CONTINUE;
+
+ END IF;
+
+
+
+ -- Update the public node as well if it was older than incoming node.
+ IF node_updated > db_updated THEN
+ UPDATE public."node"
+ SET
+ updated = (node_data->>'Updated')::timestamptz,
+ updated_seq = nextval('node_updates'),
+ parent_uuid = node_parent_uuid,
+ name = (node_data->>'Name')::varchar,
+ content = (node_data->>'Content')::text,
+ client = p_client_uuid
+ WHERE
+ user_id = p_user_id AND
+ uuid::uuid = node_uuid::uuid;
+ END IF;
+
+ END LOOP;
+END
+$procedure$
+;
diff --git a/sql/00008.sql b/sql/00008.sql
new file mode 100644
index 0000000..2701ba5
--- /dev/null
+++ b/sql/00008.sql
@@ -0,0 +1,123 @@
+CREATE OR REPLACE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid uuid, IN p_nodes jsonb)
+ LANGUAGE plpgsql
+AS $procedure$
+
+DECLARE
+ node_data jsonb;
+ node_updated timestamptz;
+ db_updated timestamptz;
+ db_uuid uuid;
+ db_client uuid;
+ db_history_uuid uuid;
+ node_uuid uuid;
+ node_parent_uuid uuid;
+ node_history_uuid uuid;
+
+BEGIN
+ FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes)
+ LOOP
+ node_uuid = (node_data->>'UUID')::uuid;
+ node_history_uuid = (node_data->>'HistoryUUID')::uuid;
+ node_updated = (node_data->>'Updated')::timestamptz;
+
+
+
+ -- Frontend is using an all-zero UUID to define the root node.
+ -- Database is using NULL.
+ IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' OR node_data->>'ParentUUID' = '' THEN
+ node_parent_uuid = NULL;
+ ELSE
+ node_parent_uuid = (node_data->>'ParentUUID')::uuid;
+ END IF;
+
+ -- Safeguard against being your own parent.
+ IF node_uuid = node_parent_uuid THEN
+ RAISE EXCEPTION 'Node UUID is same as node parent UUID.' USING ERRCODE = 'XPRNT';
+ END IF;
+
+
+ -- Every jode has a new history UUID to keep the history entry uniquely identifiable
+ -- across clients. A history entry could potentially be sent again, but should be
+ -- safe to ignore as every change to a node should have a new history UUID.
+ --
+ -- The current node is also stored as history.
+ INSERT INTO node_history(
+ user_id, "uuid", "history_uuid", parents, created, updated,
+ "name", "content", "content_encrypted",
+ client
+ )
+ VALUES(
+ p_user_id, -- combined key
+ node_uuid, -- combined key
+ node_history_uuid, -- combined key
+ (jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors",
+ COALESCE((node_data->>'Created')::timestamptz, NOW()),
+ COALESCE((node_data->>'Updated')::timestamptz, NOW()),
+ (node_data->>'Name')::varchar,
+ (node_data->>'Content')::text,
+ '', /* content_encrypted */
+ p_client_uuid
+ )
+ ON CONFLICT ("user_id", "uuid", "history_uuid")
+ DO NOTHING;
+
+
+
+ -- Retrieve the current modified timestamp for this node from the database.
+ SELECT
+ uuid, updated, client
+ INTO
+ db_uuid, db_updated, db_client
+ FROM public."node"
+ WHERE
+ user_id = p_user_id AND
+ uuid::uuid = node_uuid::uuid;
+
+
+
+ -- Is the node not in database? It needs to be created.
+ IF db_uuid IS NULL THEN
+ RAISE NOTICE '01 New node %', node_uuid;
+
+ INSERT INTO public."node" (
+ user_id, "uuid", parent_uuid, created, updated,
+ "name", "content", "content_encrypted",
+ client
+ )
+ VALUES(
+ p_user_id,
+ node_uuid,
+ node_parent_uuid,
+ COALESCE((node_data->>'Created')::timestamptz, NOW()),
+ COALESCE((node_data->>'Updated')::timestamptz, NOW()),
+ (node_data->>'Name')::varchar,
+ (node_data->>'Content')::text,
+ '', /* content_encrypted */
+ p_client_uuid
+ );
+
+ CONTINUE;
+
+ END IF;
+
+
+
+ -- Update the public node as well if it was older than incoming node.
+ IF node_updated > db_updated THEN
+ UPDATE public."node"
+ SET
+ updated = (node_data->>'Updated')::timestamptz,
+ updated_seq = nextval('node_updates'),
+ parent_uuid = node_parent_uuid,
+ name = (node_data->>'Name')::varchar,
+ content = (node_data->>'Content')::text,
+ client = p_client_uuid
+ WHERE
+ user_id = p_user_id AND
+ uuid::uuid = node_uuid::uuid;
+ END IF;
+
+ END LOOP;
+END
+$procedure$
+;
diff --git a/sql/00009.sql b/sql/00009.sql
new file mode 100644
index 0000000..50487f3
--- /dev/null
+++ b/sql/00009.sql
@@ -0,0 +1,35 @@
+-- Special node such as orphaned and deleted nodes.
+ALTER TABLE public.node ADD special bool DEFAULT false NOT NULL;
+
+
+-- Needs to be dropped in order to drop the index on UUID.
+ALTER TABLE public.node DROP CONSTRAINT node_node_fk;
+
+-- Index was missing user ID.
+DROP INDEX public.node_uuid_idx;
+CREATE UNIQUE INDEX node_user_uuid_idx ON public.node (user_id,"uuid");
+
+-- Restore the "foreign" key of parent UUID back to UUID.
+ALTER TABLE public.node ADD CONSTRAINT node_node_fk FOREIGN KEY (user_id,parent_uuid) REFERENCES public.node(user_id,"uuid") ON DELETE RESTRICT ON UPDATE RESTRICT;
+
+
+-- Auto-create the special nodes for each user.
+CREATE OR REPLACE FUNCTION create_user_nodes()
+RETURNS TRIGGER AS $$
+BEGIN
+ -- NEW holds the row being created.
+ -- No semi-colons omitted here, PL/pgSQL requires them.
+ INSERT INTO public.node (user_id, uuid, parent_uuid, special, name)
+ VALUES
+ (NEW.id, '00000000-0000-0000-0000-000000000000'::uuid, null, true, 'Start'),
+ (NEW.id, '00000000-0000-0000-0000-000000000001'::uuid, null, true, 'Orphaned nodes'),
+ (NEW.id, '00000000-0000-0000-0000-000000000002'::uuid, null, true, 'Deleted nodes');
+
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trg_after_user_insert
+AFTER INSERT ON public.user
+FOR EACH ROW
+EXECUTE FUNCTION create_user_nodes();
diff --git a/sql/00010.sql b/sql/00010.sql
new file mode 100644
index 0000000..ecd8ab4
--- /dev/null
+++ b/sql/00010.sql
@@ -0,0 +1 @@
+ALTER TABLE public."user" ADD preferences jsonb DEFAULT '{}' NOT NULL;
diff --git a/static/css/markdown.css b/static/css/markdown.css
index 631f578..832d4a2 100644
--- a/static/css/markdown.css
+++ b/static/css/markdown.css
@@ -1,34 +1,73 @@
.el-node-markdown {
padding-top: 16px;
- h1 {
- border-bottom: 1px solid #ccc;
- margin-top: 32px;
- margin-bottom: 8px;
-
- display: inline-block;
- font-size: 1.25em;
-
- border-radius: 8px;
- color: #fff;
- background-color: var(--color1);
- padding: 4px 12px;
+ .heading-container {
+ display: grid;
+ grid-template-columns: min-content 1fr;
+ grid-gap: 12px;
+ white-space: nowrap;
+ align-items: center;
+ margin-bottom: 16px;
&:first-child {
- margin-top: 32px;
+ margin-top: 32px !important;
+ .line {
+ display: none !important;
+ }
}
+
+ .line {
+ border-bottom: 1px solid var(--line-color);
+ }
+
+ &[data-heading="1"] {
+ margin-top: 64px;
+ margin-bottom: 32px;
+ }
+
+ &[data-heading="2"],
+ &[data-heading="3"] {
+ margin-top: 16px;
+
+ .line {
+ display: none;
+ }
+ }
+
+ h1, h2, h3 {
+ margin: 0;
+ }
+
+ h1 {
+ border-bottom: 1px solid #ccc;
+
+ display: inline-block;
+ font-size: 1.25em;
+
+ clip-path: polygon(0 0, 100% 0, calc(100% - 16px) 100%, 0 100%);
+
+ color: #fff;
+ background-color: var(--color1);
+ padding: 4px 24px 4px 16px;
+
+ }
+
+ h2 {
+ font-size: 1.25em;
+ color: var(--color1);
+ }
+
+ h3 {
+ &:before {
+ font-size: 1.0em;
+ content: "> ";
+ color: var(--color1);
+ }
+ }
+
}
- h2 {
- font-size: 1.25em;
- margin-top: 32px;
- margin-bottom: 0px;
- color: var(--color1);
- }
-
- h3:before {
- font-size: 1.0em;
- content: "> ";
+ a {
color: var(--color1);
}
@@ -44,7 +83,7 @@
table {
border: 1px solid #ccc;
border-collapse: collapse;
- margin-top: 14px;
+ margin-top: 16px;
th {
text-align: left;
@@ -63,6 +102,11 @@
border: 1px solid #ccc;
padding: 2px 4px;
border-radius: 4px;
+
+ &.copy {
+ border: var(--markdown-copy-border);
+ background-color: var(--markdown-copy-background);
+ }
}
pre {
@@ -70,6 +114,15 @@
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 d9b6170..7fdea0b 100644
--- a/static/css/notes2.css
+++ b/static/css/notes2.css
@@ -5,14 +5,19 @@
--thumbnail-width: 300px;
--thumbnail-height: 100px;
- /*
- --colorize: invert(10%) sepia(61%) saturate(5017%) hue-rotate(323deg) brightness(90%) contrast(109%);
- */
--colorize: invert(59%) sepia(71%) saturate(3270%) hue-rotate(327deg) brightness(100%) contrast(99%);
--line-color: #ccc;
--tree-expander: 0px;
--functions-width: 150px;
+
+ --menu-color: #fff;
+ --menu-item-hover-color: #f4f4f4;
+
+ --font-monospace: "Liberation Mono", monospace;
+
+ --markdown-copy-border: 1px solid #0a0;
+ --markdown-copy-background: #e3f4d7;
}
html {
@@ -23,6 +28,15 @@ html {
filter: var(--colorize);
}
+textarea {
+ font-family: var(--font-monospace);
+}
+
+button {
+ font-size: 1em;
+ padding: 4px 8px;
+}
+
/* ------------------------------------- *
* Default application grid in wide mode *
* ------------------------------------- */
@@ -30,31 +44,56 @@ html {
min-height: 100vh;
display: grid;
- grid-template-areas:
- "tree-expander tree pad1 crumbs crumbs pad2"
- "tree-expander tree pad1 name functions pad2"
- "tree-expander tree pad1 content content pad2"
- ;
- 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);
+ &.page-node {
+ grid-template-areas:
+ "tree-expander tree pad1 crumbs crumbs pad2"
+ "tree-expander tree pad1 name functions pad2"
+ "tree-expander tree pad1 content content pad2"
+ ;
+
+ grid-template-columns:
+ /* Tree-expander */
+ var(--tree-expander)
+ /* Tree */
+ min-content minmax(32px, 1fr)
+ /* Sync */
+ minmax(min-content, calc(var(--content-width) - var(--functions-width)))
+ /* Functions */
+ var(--functions-width)
+ /* Content */
+ minmax(32px, 1fr);
+
+ grid-template-rows:
+ /* Crumbs */
+ min-content
+ /* Name */
+ min-content
+ /* Content */
+ 1fr;
+ }
+
+ /* The other pages just gets the whole page without dividing it up. */
+ &:not(.page-node) {
+ grid-template-areas:
+ "tree-expander tree pad1 n2-page pad2"
+ ;
+
+ grid-template-columns:
+ /* Tree-expander */
+ var(--tree-expander)
+ /* Tree */
+ min-content
+ /* pad1 */
+ 32px
+ /* Content */
+ 1fr
+ /* pad2 */
+ 32px;
+
+ grid-template-rows: 1fr;
+ }
- grid-template-rows:
- /* Crumbs */
- min-content
- /* Name */
- min-content
- /* Content */
- 1fr;
/* Tree expander is collapsed as default */
--tree-expander: 0px;
@@ -66,7 +105,7 @@ html {
border-right: none;
}
- n2-tree {
+ n2-sidebar {
display: none;
}
@@ -138,7 +177,7 @@ html {
border-right: 1px solid var(--line-color);
- n2-tree {
+ n2-sidebar {
.el-treenodes {
margin: 24px 32px 32px 32px;
}
@@ -160,6 +199,11 @@ html {
img {
width: auto;
height: 18px;
+
+ &.deleted {
+ height: 24px;
+ transform: translateX(3px) translateY(3px);
+ }
}
}
@@ -192,10 +236,61 @@ html {
}
}
+
+/* =============== *
+ * 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;
@@ -203,31 +298,6 @@ html {
background-color: #faf;
}
- &.node {
- #page-node {
- display: contents;
- }
- }
-
- &.storage {
- #page-storage {
- display: contents;
-
- n2-pagestorage {
- grid-area: content;
- }
- }
- }
-
- &.history {
- #page-history {
- display: contents;
-
- n2-pagehistory {
- grid-area: content;
- }
- }
- }
}
#crumbs {
@@ -282,65 +352,66 @@ html {
}
n2-syncprogress {
- --radius: 8px;
-
display: grid;
- grid-area: sync;
- display: grid;
- justify-items: center;
- align-items: center;
-
- position: relative;
+ position: fixed;
+ top: 8px;
+ right: 8px;
+ padding: 8px 16px;
+ z-index: 16384;
+ border-radius: 6px;
+ font-weight: bold;
+ background-color: var(--color1);
+ color: #fff;
+ box-shadow: rgba(0, 0, 0, 0.1) 0px 10px 15px -3px, rgba(0, 0, 0, 0.05) 0px 4px 6px -2px;
opacity: 0;
- transition: height 0s 500ms, opacity 500ms linear, visibility 0s 500ms;
+ transition: opacity 250ms;
&.show {
opacity: 1;
- transition: visibility, height 0s, opacity 500ms linear;
}
- progress {
- width: 100%;
- height: 24px;
- border-radius: 8px;
+ &.ok {
+ background-color: #5aa02c;
}
- .count {
- position: absolute;
- top: 16px;
- width: 100%;
- white-space: nowrap;
- color: #888;
- text-align: center;
- font-size: 12pt;
- font-weight: bold;
- }
+ grid-template-columns: min-content repeat(3, min-content);
+ grid-gap: 8px 8px;
+ white-space: nowrap;
+ align-items: center;
+ justify-items: end;
- progress[value]::-webkit-progress-bar {
- background-color: #eee;
- box-shadow: 0 2px var(--radius) rgba(0, 0, 0, 0.25) inset;
- border-radius: var(--radius);
+ img {
+ grid-row: 1/3;
+ height: 34px;
+ margin-right: 8px;
}
+}
- progress[value]::-moz-progress-bar {
- background-color: #eee;
- box-shadow: 0 2px var(--radius) rgba(0, 0, 0, 0.25) inset;
- border-radius: var(--radius);
+#page-root {
+ &>div {
+ grid-area: content;
+ align-self: start;
+ margin-top: 64px;
+
+ display: grid;
+ justify-items: center;
+
+ /* logo */
+ img {
+ margin-bottom: 16px;
+ height: 32px;
+ }
+
+ .create {
+ border: 2px solid #529b00;
+ padding: 16px 32px;
+ margin-top: 64px;
+ background-color: #d9ffc9;
+ cursor: pointer;
+
+ }
}
-
- progress[value]::-webkit-progress-value {
- background: rgb(186, 95, 89);
- background: linear-gradient(180deg, rgba(186, 95, 89, 1) 0%, rgba(254, 95, 85, 1) 50%, rgba(186, 95, 89, 1) 100%);
- border-radius: var(--radius);
- }
-
- progress[value]::-moz-progress-value {
- background: rgb(186, 95, 89);
- background: linear-gradient(180deg, rgba(186, 95, 89, 1) 0%, rgba(254, 95, 85, 1) 50%, rgba(186, 95, 89, 1) 100%);
- border-radius: var(--radius);
- }
-
}
/* ============================================================= */
@@ -368,6 +439,8 @@ n2-nodeui {
font-size: 1.75em;
margin-top: 8px;
margin-bottom: 0px;
+ white-space: nowrap;
+ width: min-content;
}
.el-functions {
@@ -381,7 +454,6 @@ n2-nodeui {
grid-area: content;
justify-self: center;
word-wrap: break-word;
- font-family: monospace;
font-size: 1em;
color: #333;
@@ -405,6 +477,10 @@ n2-nodeui {
grid-area: content;
display: none;
+ font-family: var(--font-monospace);
+ font-size: 1em;
+ font-weight: 400;
+
border-top: 1px solid #e0e0e0;
margin-top: 8px;
margin-bottom: 32px;
@@ -483,7 +559,7 @@ dialog.op {
}
#tree {
- n2-tree {
+ n2-sidebar {
.el-treenodes {
height: calc(100vh - 64px - 64px);
margin: 0px;
diff --git a/static/css/page_history.css b/static/css/page_history.css
new file mode 100644
index 0000000..bc807df
--- /dev/null
+++ b/static/css/page_history.css
@@ -0,0 +1,210 @@
+#page-history {
+ container-type: inline-size;
+}
+
+/* View when two columns doesn't fit on screen. */
+@container (width < 1100px) {
+ n2-pagehistory {
+ grid-template-columns: 1fr minmax(300px, 900px) 1fr !important;
+
+ .column-2 {
+ grid-column: 2 / 3 !important;
+ }
+ }
+}
+
+/* View when not even one column with well on screen */
+/* Node name is placed on a separate row. */
+@container (width < 500px) {
+ .el-nodes {
+ grid-template-columns: min-content minmax(min-content, max-content) 1fr !important;
+ background-color: unset !important;
+ border: unset !important;
+ gap: unset !important;
+
+ .el-index {
+ border-top-left-radius: 6px;
+ border-left: 1px solid var(--line-color);
+ }
+
+ .el-index, .el-updated, .el-size {
+ border-top: 1px solid var(--line-color);
+ }
+
+ .el-size {
+ text-align: right;
+ border-right: 1px solid var(--line-color);
+ border-top-right-radius: 6px;
+ padding-right: 8px !important;
+ }
+
+ .el-name {
+ grid-column: 1 / -1;
+ padding-bottom: 8px;
+ padding-top: 0px;
+ border-bottom: 1px solid var(--line-color);
+ border-left: 1px solid var(--line-color);
+ border-right: 1px solid var(--line-color);
+ border-bottom-left-radius: 6px;
+ border-bottom-right-radius: 6px;
+ margin-bottom: 16px;
+ }
+
+ n2-pagehistorynode > * {
+ padding-left: 8px !important;
+ padding-right: 0px !important;
+ }
+ }
+}
+
+n2-pagehistory {
+ display: grid;
+ grid-template-rows: min-content min-content min-content;
+ grid-template-columns: 1fr minmax(600px, 800px) minmax(400px, 900px) 1fr;
+ grid-gap: 0px 32px;
+
+ .column-1 {
+ grid-column: 2 / 3;
+ }
+
+ .column-2 {
+ grid-column: 3 / 4;
+ max-width: 900px;
+
+ .group {
+ background-color: #fff;
+ }
+ }
+
+ .back,
+ .node-name {
+ grid-column: 2 / 4;
+ display: grid;
+ grid-template-columns: min-content 1fr;
+ grid-gap: 8px;
+ align-items: center;
+ }
+
+ .group-label {
+ font-weight: bold;
+ background-color: #444;
+ color: #fff;
+ padding: 8px 32px;
+ display: inline-block;
+ margin-left: 32px;
+ transform: translateY(14px);
+ border-radius: 6px;
+ }
+
+ .group {
+ border: 1px solid #ccc;
+ padding: 32px;
+ margin-bottom: 32px;
+ border-radius: 8px;
+ background-color: #fafafa;
+
+ box-shadow:
+ rgba(0, 0, 0, 0.4) 0px 2px 4px,
+ rgba(0, 0, 0, 0.3) 0px 7px 13px -3px,
+ rgba(0, 0, 0, 0.2) 0px -3px 0px inset;
+ }
+
+ .el-stats {
+ margin-bottom: 16px;
+ display: grid;
+ grid-template-columns: min-content 1fr;
+ grid-gap: 8px 12px;
+ white-space: nowrap;
+ }
+
+ .el-fetch-history-progress {
+ margin-top: 16px;
+ }
+
+ .el-back-image,
+ .el-back-text {
+ cursor: pointer;
+ }
+
+ .el-node-name {
+ margin-left: 8px;
+ }
+
+ .el-nodes {
+ grid-column: 1 / -1;
+
+ display: grid;
+ grid-template-columns: min-content minmax(min-content, max-content) min-content 1fr;
+
+ background-color: var(--line-color);
+ gap: 1px;
+ border: 1px solid var(--line-color);
+
+ n2-pagehistorynode>* {
+ padding: 8px 12px;
+ background-color: #fff;
+ white-space: nowrap;
+ }
+
+ n2-pagehistorynode {
+
+ &.selected .el-index:after {
+ position: absolute;
+ left: -20px;
+
+ content: '>';
+ color: var(--color1);
+ font-weight: bold;
+ margin-right: 8px;
+ }
+
+ .el-index {
+ position: relative;
+ text-align: right;
+ }
+
+ .el-updated {
+ white-space: initial;
+ }
+
+ .el-date {
+ white-space: nowrap;
+ font-weight: bold;
+ }
+
+ .el-time {
+ white-space: nowrap;
+ color: #555;
+ }
+
+ .el-name {
+ white-space: initial;
+ /*overflow-wrap: anywhere;*/
+ word-break: break-all;
+ color: var(--color1);
+ }
+ }
+ }
+
+ .el-pagination {
+ grid-column: 1 / -1;
+ margin-top: 16px;
+
+ display: grid;
+ grid-template-columns: repeat(3, min-content);
+ grid-gap: 16px;
+ align-items: center;
+ white-space: nowrap;
+ user-select: none;
+
+ .el-prev,
+ .el-next {
+ font-weight: bold;
+ cursor: pointer;
+ border: 1px solid #aaa;
+ background-color: #eee;
+ padding: 8px 16px;
+ border-radius: 4px;
+ }
+ }
+}
diff --git a/static/images/icon_back.svg b/static/images/icon_back.svg
new file mode 100644
index 0000000..504976b
--- /dev/null
+++ b/static/images/icon_back.svg
@@ -0,0 +1,49 @@
+
+
+
+
diff --git a/static/images/icon_drag.svg b/static/images/icon_drag.svg
new file mode 100644
index 0000000..02d628e
--- /dev/null
+++ b/static/images/icon_drag.svg
@@ -0,0 +1,71 @@
+
+
+
+
diff --git a/static/images/icon_drag_ok.svg b/static/images/icon_drag_ok.svg
new file mode 100644
index 0000000..94ba949
--- /dev/null
+++ b/static/images/icon_drag_ok.svg
@@ -0,0 +1,75 @@
+
+
+
+
diff --git a/static/images/icon_drag_source.svg b/static/images/icon_drag_source.svg
new file mode 100644
index 0000000..6378ed9
--- /dev/null
+++ b/static/images/icon_drag_source.svg
@@ -0,0 +1,49 @@
+
+
+
+
diff --git a/static/images/icon_menu.svg b/static/images/icon_menu.svg
new file mode 100644
index 0000000..cfdd1e8
--- /dev/null
+++ b/static/images/icon_menu.svg
@@ -0,0 +1,56 @@
+
+
+
+
diff --git a/static/images/icon_new_document.svg b/static/images/icon_new_document.svg
new file mode 100644
index 0000000..a105e05
--- /dev/null
+++ b/static/images/icon_new_document.svg
@@ -0,0 +1,49 @@
+
+
+
+
diff --git a/static/images/icon_transfer.svg b/static/images/icon_transfer.svg
new file mode 100644
index 0000000..59c900e
--- /dev/null
+++ b/static/images/icon_transfer.svg
@@ -0,0 +1,49 @@
+
+
+
+
diff --git a/static/images/leaf.svg b/static/images/leaf.svg
index 9d200c3..17f4fe2 100644
--- a/static/images/leaf.svg
+++ b/static/images/leaf.svg
@@ -24,12 +24,12 @@
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="31.614857"
- inkscape:cx="5.0609117"
- inkscape:cy="9.5524708"
+ inkscape:cx="5.0450964"
+ inkscape:cy="9.5682862"
inkscape:window-width="2190"
inkscape:window-height="1401"
inkscape:window-x="1463"
- inkscape:window-y="0"
+ inkscape:window-y="18"
inkscape:window-maximized="1"
inkscape:current-layer="layer1"
showgrid="false" />
+
+
+
diff --git a/static/images/leaf_orphaned.svg b/static/images/leaf_orphaned.svg
new file mode 100644
index 0000000..8b1cc37
--- /dev/null
+++ b/static/images/leaf_orphaned.svg
@@ -0,0 +1,61 @@
+
+
+
+
diff --git a/static/js/api.mjs b/static/js/api.mjs
index 3fff10a..26a19de 100644
--- a/static/js/api.mjs
+++ b/static/js/api.mjs
@@ -1,7 +1,7 @@
export class API {
// query resolves into the JSON data produced by the application, or an exception with 'type' and 'error' properties.
static async query(method, path, request) {
- return new Promise((resolve, reject) => {
+ try {
const body = JSON.stringify(request)
const headers = {}
@@ -12,33 +12,22 @@ export class API {
headers.Authorization = `Bearer ${token}`
}
- fetch(path, { method, headers, body })
- .then(response => {
- // An HTTP communication level error occured.
- if (!response.ok || response.status != 200)
- return reject({
- type: 'http',
- error: response,
- })
- return response.json()
- })
- .then(json => {
- // Application level response are handled here.
- if (!json.OK)
- return reject({
- type: 'application',
- error: json.Error,
- application: json,
- })
- resolve(json)
- })
- .catch(err =>
- // Catch any other errors from fetch.
- reject({
- type: 'http',
- error: err,
- }))
- })
+ const res = await fetch(path, { method, headers, body })
+ // An HTTP communication level error occured.
+ if (!res.ok || res.status != 200)
+ throw new Error('HTTP error', { cause: { type: 'http', error: res, }})
+
+ // Application level response are handled here.
+ const json = await res.json()
+ if (!json.OK)
+ throw new Error(json.Error, { cause: { type: 'application', application: json, }})
+
+ return json
+
+ } catch (err) {
+ // Catch any other errors from fetch.
+ throw new Error(err.message, { cause: { type: 'http', error: err, }})
+ }
}
static hasAuthenticationToken() {//{{{
diff --git a/static/js/app.mjs b/static/js/app.mjs
index efdddd1..90bad39 100644
--- a/static/js/app.mjs
+++ b/static/js/app.mjs
@@ -1,40 +1,72 @@
import { ROOT_NODE } from 'node_store'
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
-import { N2Tree } from 'tree'
+import { N2Sidebar } from 'sidebar'
import { Node } from 'node'
+import { N2PreferenceSet } from './page_preferences.mjs'
export class App {
+ static PAGES = ['node', 'history', 'storage']
+
constructor() {// {{{
this.currentNode = null
- this.tree = new N2Tree()
+ this.sidebar = new N2Sidebar()
this.crumbs = new N2Crumbs()
this.crumbsElement = document.getElementById('crumbs')
this.nodeUI = document.getElementById('note')
+ this.dragIcon = new N2DragIcon()
- _mbus.subscribe('TREE_TRUNK_FETCHED', async () => {
- document.getElementById('tree').append(this.tree.render())
+ this.preferences = this.getPreferences()
+
+ this.sidebar.render().then(sidebar => {
+ document.getElementById('tree').append(sidebar)
document.getElementById('tree-nodes')?.focus()
+ })
+ // Start node shows a system-wide page instead of node editing
+ // since the start node is kind of magic and doesn't fit into
+ // the syncing system.
+ const determineNodePage = uuid => {
+ const el = document.getElementById('notes2')
+ if (uuid == ROOT_NODE)
+ el.classList.add('root-node-override')
+ else
+ el.classList.remove('root-node-override')
+ }
+
+ _mbus.subscribe('TREE_RENDERED', async () => {
+ // Subscribing to the start node existing after the tree trunk is
+ // fetched since the NODE_COMPONENT_EXIST message isn't sent for the
+ // root node itself, and the root node should be selected in the tree
+ // after it is rendered when the site is shown without UUID in the URL.
const startNode = await this.getStartNode()
+ determineNodePage(startNode.UUID)
this.goToNode(startNode.UUID, false, false)
})
_mbus.subscribe('TREE_NODE_SELECTED', event => {
const node = event.detail.data
+ determineNodePage(node.UUID)
this.goToNode(node.UUID, false, false)
})
_mbus.subscribe('GO_TO_NODE', event => {
const node = event.detail.data
+ determineNodePage(node.nodeUUID)
this.goToNode(node.nodeUUID, node.dontPush, node.dontExpand)
})
_mbus.subscribe('SHOW_PAGE', ({ detail: { data: { page } } }) => {
- const classList = document.querySelector('#main-page').classList
- classList.forEach(e =>
- classList.remove(e)
- )
- classList.add(page)
+ const classList = document.getElementById('notes2').classList
+ classList.forEach(e => {
+ if (e.startsWith('page-'))
+ classList.remove(e)
+ })
+ classList.add('page-' + page)
+ })
+
+ _mbus.subscribe('DEVICE_PREFERENCE_SET_UPDATED', ()=>{
+ this.preferences = this.getPreferences()
+ console.log(this.preferences.data)
})
window.addEventListener('keydown', event => this.keyHandler(event))
@@ -44,6 +76,9 @@ export class App {
document.getElementById('node-content')?.focus()
})
+ document.querySelector('#page-root .create').addEventListener('click', () => this.createNode())
+ document.body.append(this.dragIcon)
+
_mbus.dispatch('SHOW_PAGE', { page: 'node' })
window._sync = new Sync()
@@ -53,69 +88,52 @@ export class App {
// There a slight delay to initiate sync seems reasonable.
setTimeout(() => window._sync.run(), 1000)
}// }}}
-
keyHandler(event) {//{{{
let handled = true
- // All keybindings is Alt+Shift, since the popular browsers at the time (2023) allows to override thees.
+ // Most keybindings is Alt+Shift, since the popular browsers at the time (2023) allows to override thees.
// Ctrl+S is the exception to using Alt+Shift, since it is overridable and in such widespread use for saving.
// Thus, the exception is acceptable to consequent use of alt+shift.
- if (!(event.shiftKey && event.altKey) && !(event.key.toUpperCase() === 'S' && event.ctrlKey))
- return
+ const CTRL = !event.shiftKey && event.ctrlKey && !event.altKey
+ const SHIFT_ALT = event.shiftKey && !event.ctrlKey && event.altKey
+ const SHIFT_CTRL_ALT = event.shiftKey && event.ctrlKey && event.altKey
switch (event.key.toUpperCase()) {
+ case 'F2':
+ this.nodeUI.renameNode()
+ break
case 'T':
- if (document.activeElement.id === 'tree-nodes') {
+ if (!SHIFT_ALT) { handled = false; break }
+ if (document.activeElement.id === 'tree-nodes')
this.nodeUI.takeFocus()
- } else {
- this.tree.focus()
- }
+ else
+ this.sidebar.focus()
break
case 'F':
+ if (!SHIFT_ALT) { handled = false; break }
_mbus.dispatch('op-search')
break
- /*
- case 'C':
- this.showPage('node')
- break
-
- case 'E':
- this.showPage('keys')
- break
- */
case 'M':
+ if (!SHIFT_ALT) { handled = false; break }
globalThis._mbus.dispatch('MARKDOWN_TOGGLE')
break
case 'N':
- this.createNode()
+ if (SHIFT_ALT)
+ this.createNode()
+ else if (SHIFT_CTRL_ALT) {
+ this.createNode(this.currentNode?.ParentUUID)
+ } else {
+ handled = false
+ }
break
- /*
- case 'P':
- this.showPage('node-properties')
- break
-
- */
case 'S':
- this.saveNode()
- /*
- else if (this.page.value === 'node-properties')
- this.nodeProperties.current.save()
- */
+ if (!CTRL) { handled = false; break }
+ this.nodeUI.saveNode()
break
- /*
-
- case 'U':
- this.showPage('upload')
- break
-
- case 'F':
- this.showPage('search')
- break
- */
default:
handled = false
@@ -140,47 +158,28 @@ export class App {
return await nodeStore.get(nodeUUID)
}//}}}
async saveNode() {//{{{
- if (!this.currentNode.isModified())
- return
- /* The node history is a local store for node history.
- * This could be provisioned from the server or cleared if
- * deemed unnecessary.
- *
- * The send queue is what will be sent back to the server
- * to have a recorded history of the notes.
- *
- * A setting to be implemented in the future could be to
- * not save the history locally at all. */
- const node = this.currentNode
-
- // The node is still in its old state and will present
- // the unmodified content to the node store.
- const history = nodeStore.nodesHistory.add(node)
-
- // Prepares the node object for saving.
- // Sets Updated value to current date and time.
- await node.save()
-
- // Updated node is added to the send queue to be stored on server.
- const sendQueue = nodeStore.sendQueue.add(node)
-
- // Updated node is saved to the primary node store.
- const nodeStoreAdding = nodeStore.add([node])
-
- await Promise.all([history, sendQueue, nodeStoreAdding])
}//}}}
- async createNode() {//{{{
- let name = prompt("Name")
+ async moveNode(node, targetNodeUUID) {// {{{
+ node.moveToParent(targetNodeUUID)
+ await node.save()
+ }// }}}
+ async createNode(createUnderUUID) {//{{{
+ const parentUUID = createUnderUUID ? createUnderUUID : this.currentNode.UUID
+ const p = createUnderUUID ? 'Name for sibling document' : 'Name for sub-document'
+
+ let name = prompt(p)
if (!name)
return
- const nn = Node.create(name, this.currentNode.UUID)
- nn.save()
-
- nodeStore.sendQueue.add(nn)
- nodeStore.add([nn])
+ const nn = Node.create(name, parentUUID)
+ await nn.save()
+ // Treenode is forcefully rerendered and children refetched to both show the new node
+ // and to get it resorted.
+ const parentTreenode = this.sidebar.getTreeNode(parentUUID)
+ await parentTreenode.render(true, true)
+ _mbus.dispatch('GO_TO_NODE', { nodeUUID: nn.UUID })
}//}}}
async goToNode(nodeUUID, dontPush, dontExpand) {//{{{
if (nodeUUID === null || nodeUUID === undefined)
@@ -199,17 +198,33 @@ export class App {
node.reset() // any modifications are discarded.
this.currentNode = node
- this.tree.setSelected(node, dontExpand)
+ this.sidebar.setSelected(node, dontExpand)
const ancestors = await nodeStore.getNodeAncestry(node)
- _mbus.dispatch('CRUMBS_SET', ancestors, () => this.crumbsElement.replaceChildren(this.crumbs.render()))
- _mbus.dispatch('NODE_UI_OPEN', node)
- _mbus.dispatch('NODE_UNMODIFIED')
- _mbus.dispatch('TREE_EXPANSION', { expand: false, when: 'narrow' })
// Scrolls node into view.
- this.tree.makeVisible(node)
+ // makeVisible normally expands all ancestor nodes to make the whole chain visible.
+ // This is a bad idea when quickly navigating the tree, since the arrow navigation
+ // has collapsed nodes which the event calling goToNode can come to undo, if the
+ // event processing lags behind.
+ await this.sidebar.makeVisible(node, ancestors, dontExpand)
+
+ _mbus.dispatch('CRUMBS_SET', ancestors, () => this.crumbsElement.replaceChildren(this.crumbs.render()))
+ _mbus.dispatch('NODE_UI_OPEN', node)
+ _mbus.dispatch('TREE_EXPANSION', { expand: false, when: 'narrow' })
+ _mbus.dispatch('NODE_UNMODIFIED')
+ _mbus.dispatch('SHOW_PAGE', { page: 'node' })
}//}}}
+ pageIsVisible(page) {// {{{
+ let classList = document.querySelector('#main-page').classList
+ return classList.contains(page)
+ }// }}}
+ getPreferences() {// {{{
+ const devPrefSet = localStorage.getItem('device_preference_set') || 'default'
+ const userData = localStorage.getItem('user') || '{"default": {}}'
+ const user = JSON.parse(userData)
+ return new N2PreferenceSet(devPrefSet, user.Preferences[devPrefSet])
+ }// }}}
}
class N2Crumbs extends CustomHTMLElement {
@@ -243,7 +258,6 @@ class N2Crumbs extends CustomHTMLElement {
return this
}// }}}
}
-customElements.define('n2-crumbs', N2Crumbs)
class N2Crumb extends CustomHTMLElement {
static {// {{{
@@ -274,7 +288,6 @@ class N2Crumb extends CustomHTMLElement {
this.elLink.addEventListener('click', () => _mbus.dispatch("GO_TO_NODE", { nodeUUID: this.uuid, dontPush: false, dontExpand: true }))
}// }}}
}
-customElements.define('n2-crumb', N2Crumb)
function tmpl(html) {// {{{
const el = document.createElement('template')
@@ -348,4 +361,52 @@ class OpSearch extends Op {
}// }}}
}
+class N2DragIcon extends CustomHTMLElement {
+ static {// {{{
+ this.tmpl = document.createElement('template')
+ this.tmpl.innerHTML = `
+
+
+ `
+ }// }}}
+ constructor() {// {{{
+ super(true)
+
+ document.addEventListener('dragover', e => {
+ this.style.left = `${e.clientX + 8}px`
+ this.style.top = `${e.clientY}px`
+ })
+
+ this.dragSource = null
+ }// }}}
+ start() {// {{{
+ this.style.display = 'block'
+ }// }}}
+ end() {// {{{
+ this.style.display = 'none'
+ }// }}}
+ icon(name) {// {{{
+ if (name != '')
+ name = '_' + name
+ this.elIcon.setAttribute('src', `/images/${_VERSION}/icon_drag${name}.svg`)
+ }// }}}
+ setSource(s) {// {{{
+ this.dragSource = s
+ }// }}}
+ getSource() {// {{{
+ return this.dragSource
+ }// }}}
+}
+
+customElements.define('n2-crumbs', N2Crumbs)
+customElements.define('n2-crumb', N2Crumb)
+customElements.define('n2-dragicon', N2DragIcon)
+
// vim: foldmethod=marker
diff --git a/static/js/lib/custom_html_element.mjs b/static/js/lib/custom_html_element.mjs
index 2cec808..d1fb7ae 100644
--- a/static/js/lib/custom_html_element.mjs
+++ b/static/js/lib/custom_html_element.mjs
@@ -1,7 +1,17 @@
+/* Use data-el or data-field attribute.
+ * Element with data-el="hum-ding" is accessible as this.elHumDing and fields with
+ * data-field="long-dong" as this.fieldLongDong.
+ *
+ * All field values can be retrieved with fieldValues() and uses the data-field attribute
+ * as LongDong as key.
+ */
+
export class CustomHTMLElement extends HTMLElement {
constructor(useShadow) {// {{{
super()
+ this._fields = new Map()
+
const workOn = useShadow ? this.attachShadow({ mode: 'open' }) : this
workOn.appendChild(this.constructor.tmpl.content.cloneNode(true))
workOn.querySelectorAll('*').forEach(el => {
@@ -9,6 +19,7 @@ export class CustomHTMLElement extends HTMLElement {
if (field !== undefined) {
const fieldName = this.toElementName('field', field)
this[fieldName] = el
+ this._fields.set(this.toElementName('', field), el)
}
const name = el.dataset.el
@@ -19,39 +30,22 @@ export class CustomHTMLElement extends HTMLElement {
}
})
}// }}}
+ allFields() {// {{{
+ return this._fields
+ }// }}}
+ fieldValues() {// {{{
+ const state = {}
+ for (const [name, field] of this._fields) {
+ if (field.tagName.toLowerCase() == 'input' && field.getAttribute('type').toLowerCase() == 'checkbox')
+ state[name] = field.checked
+ else
+ state[name] = field.value
+
+ }
+ return state
+ }// }}}
toElementName(prefix, str) {// {{{
str = prefix + '-' + str
return str.replace(/-(id|[a-z])/g, match => match.toUpperCase().replace('-', ''))
}// }}}
}
-
-export class StupidPreactCustomHTMLElement extends HTMLElement {
- constructor() {// {{{
- super()
-
- // Stupid stuff because of Preact.
- this.clonedNodes = this.constructor.tmpl.content.cloneNode(true)
- this.clonedNodes.querySelectorAll('*').forEach(el => {
- const field = el.dataset.field
- if (field !== undefined) {
- const fieldName = this.toElementName('field', field)
- this[fieldName] = el
- }
-
- const name = el.dataset.el
- if (name !== undefined) {
- const elName = this.toElementName('el', name)
- this[elName] = el
- el.classList.add('el-' + name)
- }
- })
- }// }}}
- toElementName(prefix, str) {// {{{
- str = prefix + '-' + str
- return str.replace(/-(id|[a-z])/g, match => match.toUpperCase().replace('-', ''))
- }// }}}
- connectedCallback() {// {{{
- // Stupid stuff because of Preact.
- this.appendChild(this.clonedNodes)
- }// }}}
-}
diff --git a/static/js/marked_position.mjs b/static/js/marked_position.mjs
index 62a6996..8c81eb4 100644
--- a/static/js/marked_position.mjs
+++ b/static/js/marked_position.mjs
@@ -92,7 +92,9 @@ function escapeHtmlEntities(html, encode) {// {{{
export class MarkedPosition {
constructor() {// {{{
- window.setpos = (event) => this.setpos(event)
+ window.marked_setpos = (event) => this.setpos(event)
+ window.marked_changecheckbox = (event) => this.changecheckbox(event)
+ window.marked_copy_to_clipboard = (event, tagname) => this.copy_to_clipboard(event, tagname)
this.render()
}// }}}
setpos(event) {// {{{
@@ -106,20 +108,61 @@ export class MarkedPosition {
}
})
}// }}}
+ changecheckbox(event) {// {{{
+ event.stopPropagation()
+ event.preventDefault()
+
+ _mbus.dispatch('MARKDOWN_CHANGE_CHECKBOX', {
+ checkbox: event.target,
+ position: {
+ start: event.target.closest('[data-offset-start]').dataset.offsetStart,
+ end: event.target.closest('[data-offset-start]').dataset.offsetEnd,
+ }
+ })
+ }// }}}
+ async copy_to_clipboard(event, tagname) {// {{{
+ if (!event.shiftKey)
+ return
+
+ try {
+ // Stop text selections on the page to the mouse pointer.
+ // Old selections are remove as well to give a cleaner view
+ // of the copied text/highlighting.
+ event.preventDefault()
+ event.stopPropagation()
+ window.getSelection().removeAllRanges()
+
+ const text = event.target.innerText
+ await navigator.clipboard.writeText(text)
+
+ const tagClasslist = event.target.closest(tagname).classList
+ tagClasslist.add('copy')
+ setTimeout(()=>tagClasslist.remove('copy'), 250)
+
+ } catch (err) {
+ console.error('Failed to copy: ', err)
+ alert('Failed to copy: ', err)
+ }
+ }// }}}
+
render() {// {{{
- const markedObject = this
this.marked = new Marked()
this.marked.use(markedTokenPosition())
this.marked.use({
renderer: {
heading(token) {
const content = this.parser.parseInline(token.tokens)
- return `${content}\n`
+ return `
+
+ `
},
paragraph(token) {
const content = this.parser.parseInline(token.tokens)
- return `${content}
\n`
+ return `${content}
\n`
},
list(token) {
@@ -138,7 +181,7 @@ export class MarkedPosition {
},
listitem(token) {
- return `${this.parser.parse(token.tokens)}\n`
+ return `${this.parser.parse(token.tokens)}\n`
},
code(token) {
@@ -147,12 +190,12 @@ export class MarkedPosition {
const code = token.text.replace(other.endingNewline, '') + '\n'
if (!langString) {
- return ``
+ return ``
+ (token.escaped ? code : escapeHtmlEntities(code, true))
+ '
\n'
}
- return `'
+ (token.escaped ? code : escapeHtmlEntities(code, true))
@@ -161,7 +204,7 @@ export class MarkedPosition {
blockquote(token) {
const body = this.parser.parse(token.tokens)
- return `\n${body}
\n`
+ return `\n${body}
\n`
},
html(token) {
@@ -173,13 +216,13 @@ export class MarkedPosition {
},
hr(token) {
- return `
\n`
+ return `
\n`
},
checkbox(token) {
- return ` '
+ + 'type="checkbox"> '
},
table(token) {
@@ -222,7 +265,7 @@ export class MarkedPosition {
if (token.tokens.length > 0) {
const start = token.tokens[0].position.start.offset
const end = token.tokens[0].position.end.offset
- ofs = `ondblclick="setpos(event)" data-offset-start="${start}" data-offset-end="${end}"`
+ ofs = `ondblclick="marked_setpos(event)" data-offset-start="${start}" data-offset-end="${end}"`
}
const content = this.parser.parseInline(token.tokens);
@@ -234,23 +277,23 @@ export class MarkedPosition {
},
strong(token) {
- return `${this.parser.parseInline(token.tokens)}`
+ return `${this.parser.parseInline(token.tokens)}`
},
em(token) {
- return `${this.parser.parseInline(token.tokens)}`
+ return `${this.parser.parseInline(token.tokens)}`
},
codespan(token) {
- return `${escapeHtmlEntities(token.text, true)}`
+ return `${escapeHtmlEntities(token.text, true)}`
},
br(token) {
- return `
`
+ return `
`
},
del(token) {
- return `${this.parser.parseInline(token.tokens)}`
+ return `${this.parser.parseInline(token.tokens)}`
},
link(token) {
@@ -260,7 +303,7 @@ export class MarkedPosition {
return text
}
token.href = cleanHref
- let out = ' {
@@ -57,7 +61,7 @@ export class NodeStore {
break
case 6:
- nodesHistory = db.createObjectStore('nodes_history', { keyPath: ['UUID', 'Updated'] })
+ nodesHistory = db.createObjectStore('nodes_history', { keyPath: ['UUID', 'HistoryUUID'] })
break
case 7:
@@ -74,10 +78,9 @@ export class NodeStore {
req.onsuccess = (event) => {
this.db = event.target.result
this.sendQueue = new SimpleNodeStore(this.db, 'send_queue')
- this.nodesHistory = new SimpleNodeStore(this.db, 'nodes_history')
+ this.nodesHistory = new NodeHistoryStore(this.db, 'nodes_history')
this.files = new SimpleNodeStore(this.db, 'files')
- this.initializeRootNode()
- .then(() => resolve())
+ resolve()
}
req.onerror = (event) => {
@@ -85,40 +88,11 @@ export class NodeStore {
}
})
}//}}}
- initializeRootNode() {//{{{
- return new Promise((resolve, reject) => {
- // The root node is a magical node which displays as the first node if none is specified.
- // If not already existing, it will be created.
- const trx = this.db.transaction('nodes', 'readwrite')
- const nodes = trx.objectStore('nodes')
- const getRequest = nodes.get(ROOT_NODE)
- getRequest.onsuccess = (event) => {
- // Root node exists - nice!
- if (event.target.result !== undefined) {
- resolve(event.target.result)
- return
- }
-
- const putRequest = nodes.put({
- UUID: ROOT_NODE,
- Name: 'Notes2',
- Content: 'Hello, World!',
- Updated: new Date().toISOString(),
- ParentUUID: '',
- })
- putRequest.onsuccess = (event) => {
- resolve(event.target.result)
- }
- putRequest.onerror = (event) => {
- reject(event.target.error)
- }
- }
- getRequest.onerror = (event) => reject(event.target.error)
- })
- }//}}}
- purgeCache() {//{{{
- this.nodes = {}
- }//}}}
+ initializeSpecialNodes() {// {{{
+ this.nodes[ROOT_NODE] = new Node({ UUID: ROOT_NODE, Name: 'Start', Special: true }, -1)
+ this.nodes[DELETED_NODE] = new Node({ UUID: DELETED_NODE, Name: 'Deleted nodes', Special: true }, -1)
+ this.nodes[ORPHANED_NODE] = new Node({ UUID: ORPHANED_NODE, Name: 'Orphaned nodes', Special: true }, -1)
+ }// }}}
node(uuid, dataIfUndefined, newLevel) {//{{{
let n = this.nodes[uuid]
@@ -199,9 +173,7 @@ export class NodeStore {
}
Promise.all(hasChildrenPromises)
- .then(() => {
- resolve(nodes)
- })
+ .then(() => resolve(nodes))
}
req.onerror = (event) => reject(event.target.error)
})
@@ -249,6 +221,7 @@ export class NodeStore {
nodeStore = t.objectStore('nodes')
t.oncomplete = (_event) => {
+ console.log('complete')
resolve()
}
@@ -273,6 +246,14 @@ export class NodeStore {
}//}}}
get(uuid, suppliedNodestore) {//{{{
return new Promise((resolve, reject) => {
+ switch (uuid) {
+ case ROOT_NODE:
+ case DELETED_NODE:
+ case ORPHANED_NODE:
+ resolve(this.nodes[uuid])
+ return
+ }
+
// A nodestore can be provided in order to
// avoid creating new transactions.
let trx
@@ -310,6 +291,16 @@ export class NodeStore {
return
}
+ if (node.UUID === DELETED_NODE || node.ParentUUID === DELETED_NODE) {
+ resolve(accumulated)
+ return
+ }
+
+ if (node.UUID === ORPHANED_NODE || node.ParentUUID === ORPHANED_NODE) {
+ resolve(accumulated)
+ return
+ }
+
const getRequest = nodeParentIndex.get(node.ParentUUID)
getRequest.onsuccess = (event) => {
// Node not found in IndexedDB.
@@ -360,6 +351,7 @@ class SimpleNodeStore {
// Node to be moved is first stored in the new queue.
const req = store.put(node.data)
req.onsuccess = () => {
+ console.log('here')
resolve()
}
req.onerror = (event) => {
@@ -437,28 +429,91 @@ class SimpleNodeStore {
}//}}}
}
-export class StoreFile {
- static createFromFileObject(f) {
- const obj = new StoreFile()
- obj.name = f.name
- obj.size = f.size
- obj.mime = f.type
- return obj
- }
- constructor() {
- this.name = ''
- this.size = 0
- this.mime = ''
+class NodeHistoryStore extends SimpleNodeStore {
+ constructor(db, storeName) {//{{{
+ super(db, storeName)
+ }//}}}
+ count(uuid) {//{{{
+ if (uuid === undefined)
+ return super.count()
- this.objectURL = null // URL.createObjectURL(blob)
- }
- data() {
- return {
- }
- }
+ 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() {
+export function uuidv7() {// {{{
// random bytes
const value = new Uint8Array(16)
crypto.getRandomValues(value)
@@ -482,6 +537,6 @@ export function uuidv7() {
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
return `${str.slice(0, 8)}-${str.slice(8, 12)}-${str.slice(12, 16)}-${str.slice(16, 20)}-${str.slice(20)}`
-}
+}// }}}
// vim: foldmethod=marker
diff --git a/static/js/page_history.mjs b/static/js/page_history.mjs
index a931faa..44061f6 100644
--- a/static/js/page_history.mjs
+++ b/static/js/page_history.mjs
@@ -1,15 +1,319 @@
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 {
+ static PAGESIZE = 15
+ static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
- History
- `
- }
+
- constructor() {
+
+

+
Back to node
+
+
+
+

+
+
+
+
+
Actions
+
+
+
+
+
+
+
History
+
+
+
History on server:
+
+
+
History on client:
+
+
+
+
+
+
+
+
+
+
+ `
+ }// }}}
+
+ constructor() {// {{{
super()
- }
+ this.selectedNode = null
+
+ this.setAttribute('tabindex', '-1')
+ this.addEventListener('keydown', event => this.keyHandler(event))
+
+ // Connect back icon and text to give the user a way back to the node.
+ this.elBackImage.addEventListener('click', () => _mbus.dispatch('SHOW_PAGE', { page: 'node' }))
+ this.elBackText.addEventListener('click', () => _mbus.dispatch('SHOW_PAGE', { page: 'node' }))
+ this.elPrev.addEventListener('click', () => this.prevPage())
+ this.elNext.addEventListener('click', () => this.nextPage())
+ this.elDownloadHistory.addEventListener('click', async () => {
+ await this.downloadHistory()
+ await this.useNode(this.node)
+ this.render(true)
+
+ })
+
+ _mbus.subscribe('SHOW_PAGE', async (event) => {
+ if(event.detail.data.page != 'history')
+ return
+
+ await this.useNode(_app.nodeUI.node)
+ this.render()
+ })
+
+ _mbus.subscribe('HISTORY_NODE_SELECTED', (event) => {
+ this.selectedNode = event.detail.data.historyNode
+
+ // Any selected history node is rendered with markdown.
+ const marked = new MarkedPosition()
+ this.elNodeMarkdown.innerHTML = marked.parse(this.selectedNode?.node.content())
+ })
+ }// }}}
+ async render(keepFetchHistoryProgress) {// {{{
+ this.elNodeName.innerText = this.node.get('Name')
+ this.elPage.innerText = `${this.page} / ${this.pages}`
+ this.elStatsOnClient.innerText = `${this.nodesTotal}`
+ this.elStatsOnServer.innerText = `${this.historyOnServerTotal}`
+
+ if (this.nodesTotal <= N2PageHistory.PAGESIZE)
+ this.elPagination.style.display = 'none'
+ else
+ this.elPagination.style.display = ''
+
+ let nodes = await nodeStore.nodesHistory.retrievePage(this.node.UUID, N2PageHistory.PAGESIZE, this.page)
+ let i = 0
+ let divs = nodes.map(n => {
+ i++
+ const index = 1 + this.nodesTotal - (N2PageHistory.PAGESIZE * (this.page - 1) + i)
+ const div = new N2PageHistoryNode(n, index)
+ div.render()
+ return div
+ })
+ this.elNodes.replaceChildren(...divs)
+
+ if (!keepFetchHistoryProgress)
+ this.elFetchHistoryProgress.innerText = ''
+
+ // Select the first node.
+ if (!this.selectedNode) {
+ this.elNodes.firstElementChild?.select()
+ }
+ }// }}}
+
+ async useNode(node) {// {{{
+ this.node = node
+ this.page = 1
+
+ this.nodesTotal = await nodeStore.nodesHistory.count(this.node.UUID)
+ this.historyOnServerTotal = await this.getServerTotal()
+ this.pages = Math.ceil(this.nodesTotal / N2PageHistory.PAGESIZE)
+ }// }}}
+ keyHandler(event) {// {{{
+ let handled = true
+ switch (event.key) {
+ case 'ArrowLeft':
+ this.prevPage()
+ break
+
+ case 'ArrowRight':
+ this.nextPage()
+ break
+
+ case 'ArrowUp':
+ const prevNode = this.selectedNode?.previousElementSibling
+ if (prevNode)
+ prevNode.select()
+ break
+
+ case 'ArrowDown':
+ const nextNode = this.selectedNode?.nextElementSibling
+ if (nextNode)
+ nextNode.select()
+ break
+
+ default:
+ handled = false
+ }
+
+ if (handled) {
+ event.stopPropagation()
+ event.preventDefault()
+ }
+ }// }}}
+
+ prevPage() {// {{{
+ if (this.page == 1)
+ return
+
+ // Selecting a node on another page is wrong.
+ this.selectedNode = null
+ this.page--
+ this.render()
+ }// }}}
+ nextPage() {// {{{
+ if (this.page >= this.pages)
+ return
+ // Selecting a node on another page is wrong.
+ this.selectedNode = null
+ this.page++
+ this.render()
+ }// }}}
+
+ async getServerTotal() {// {{{
+ const res = await fetch(`/node/history/count/${this.node.UUID}`, {
+ headers: {
+ "Authorization": 'Bearer ' + localStorage.getItem('token'),
+ }
+ })
+ const json = await res.json()
+
+ if (!json.OK) {
+ alert(json.Error)
+ return
+ }
+
+ return json.Count
+ }// }}}
+ async downloadHistory() {// {{{
+ try {
+ const nodes = []
+ let offset = 0
+ let hasMore = true
+
+ while (hasMore) {
+ const history = await this.downloadHistoryPage(offset)
+ hasMore = history.HasMore
+ for (const nodeData of history.Nodes) {
+ nodes.push(new Node(nodeData))
+ }
+ offset = nodes.length
+ this.elFetchHistoryProgress.innerText = `${nodes.length} fetched.`
+ }
+
+ let num = 0
+ for (const node of nodes) {
+ const ok = await nodeStore.nodesHistory.hasNode(node.UUID, node.get('Updated'))
+ if (ok) num++
+ await nodeStore.nodesHistory.add(node)
+ }
+
+ this.elFetchHistoryProgress.innerText = `${nodes.length} fetched - all history fetched.`
+ } catch (e) {
+ console.error(e)
+ alert(e)
+ }
+ }// }}}
+ async downloadHistoryPage(offset) {// {{{
+ const res = await fetch(`/node/history/retrieve/${this.node.UUID}/${offset}`, {
+ headers: {
+ "Authorization": 'Bearer ' + localStorage.getItem('token'),
+ }
+ })
+ const json = await res.json()
+
+ if (!json.OK) {
+ alert(json.Error)
+ return
+ }
+
+ return json
+ }// }}}
}
customElements.define('n2-pagehistory', N2PageHistory)
+
+
+class N2PageHistoryNode extends CustomHTMLElement {
+ static {// {{{
+ this.tmpl = document.createElement('template')
+ this.tmpl.innerHTML = `
+
+
+
+
+ `
+ }// }}}
+ constructor(node, index) {// {{{
+ super()
+
+ this.node = node
+ this.index = index
+
+ this.style.display = 'contents'
+ this.selected = false
+
+ this.addEventListener('click', () => this.select())
+
+ // Another history node has been selected.
+ _mbus.subscribe('HISTORY_NODE_SELECTED', (event) => {
+ if (this.node.get('Updated') == event.detail.data.historyNode.node.get('Updated'))
+ return
+ this.selected = false
+ this.render()
+ })
+ }// }}}
+
+ select() {// {{{
+ this.selected = true
+ // Other nodes are told to unselect and rerender.
+ _mbus.dispatch('HISTORY_NODE_SELECTED', { historyNode: this })
+ this.render()
+ }// }}}
+ render() {// {{{
+ const date = this.node.get('Updated').slice(0, 10)
+ const time = this.node.get('Updated').slice(11, 19)
+
+ if (this.selected)
+ this.classList.add('selected')
+ else
+ this.classList.remove('selected')
+
+ this.elIndex.innerText = this.index
+ this.elDate.innerText = date
+ this.elTime.innerText = time
+ this.elSize.innerText = this.formatSize(this.node.get('Content').length)
+ this.elName.innerText = this.node.get('Name')
+ }// }}}
+ formatSize(s) {// {{{
+ let div = 1
+ let unit = 'B'
+ if (s >= 1048576) {
+ div = 1048576
+ unit = 'MB'
+ } else if (s >= 1024) {
+ div = 1024
+ unit = 'kB'
+ }
+
+ return new Intl.NumberFormat(undefined, {
+ maximumFractionDigits: 0
+ }).format(Math.round(s / div)) + ' ' + unit
+ }// }}}
+}
+customElements.define('n2-pagehistorynode', N2PageHistoryNode)
diff --git a/static/js/page_node.mjs b/static/js/page_node.mjs
index e26cb86..2106ada 100644
--- a/static/js/page_node.mjs
+++ b/static/js/page_node.mjs
@@ -2,21 +2,86 @@ import { ROOT_NODE, uuidv7 } from 'node_store'
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
import { MarkedPosition } from './marked_position.mjs'
+class N2NodeMenu extends CustomHTMLElement {
+ static {// {{{
+ this.tmpl = document.createElement('template')
+ this.tmpl.innerHTML = `
+
+
+ `
+ }// }}}
+ constructor() {// {{{
+ super()
+ }// }}}
+}
+customElements.define('n2-nodemenu', N2NodeMenu)
+
export class N2PageNodeUI extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
@@ -27,9 +92,13 @@ export class N2PageNodeUI extends CustomHTMLElement {
+
`
}// }}}
@@ -38,12 +107,14 @@ export class N2PageNodeUI extends CustomHTMLElement {
this.node = null
this.style.display = 'contents'
- this.classList.add('show-markdown') // TODO Should probably be moved to settings.
this.marked = new MarkedPosition()
_mbus.subscribe('NODE_UI_OPEN', event => {
this.node = event.detail.data
- this.showMarkdown(true)
+
+
+ if (!this.node.isSpecial())
+ this.showMarkdown(true)
this.render()
})
@@ -62,23 +133,29 @@ export class N2PageNodeUI extends CustomHTMLElement {
_mbus.subscribe('MARKDOWN_TOGGLE', () => this.showMarkdown(!this.showMarkdown()))
_mbus.subscribe('MARKDOWN_EDIT', ({ detail }) => this.editMarkdown(detail.data))
+ _mbus.subscribe('MARKDOWN_CHANGE_CHECKBOX', ({ detail }) => this.checkboxUpdated(detail.data))
- this.elName.addEventListener('click', () => {
- const name = prompt('Change title', this.node.data.Name)
- if (name === null)
- return
+ // Binding the node rename handler.
+ this.elName.addEventListener('click', async () => this.renameNode())
- try {
- this.node.setName(name)
- } catch (err) {
- console.error(err)
- alert(err)
- }
- })
+ // Bind handlers for content keyboard input and paste.
this.elNodeContent.addEventListener('input', event => this.contentChanged(event))
this.elNodeContent.addEventListener('paste', async (event) => this.pasteHandler(event))
+
+ // Bind node icon handlers.
+ this.elIconSave.addEventListener('click', () => this.saveNode())
this.elIconMarkdown.addEventListener('click', () => this.showMarkdown(!this.showMarkdown()))
- this.elIconTableFormat.addEventListener('click', event => {
+ this.elIconNewDocument.addEventListener('click', event => {
+ if (event.shiftKey)
+ _app.createNode(this.node.ParentUUID)
+ else
+ _app.createNode()
+ })
+
+ // Bind node menu items to handlers.
+ this.elNodeMenu.elFormatTables.addEventListener('click', event => {
+ this.elNodeMenu.hidePopover()
+
if (!event.shiftKey)
this.elNodeContent.value = this.formatAllTables(this.elNodeContent.value)
else {
@@ -92,8 +169,12 @@ export class N2PageNodeUI extends CustomHTMLElement {
this.node.setContent(this.elNodeContent.value)
})
- this.elIconHistory.addEventListener('click', () => _mbus.dispatch('SHOW_PAGE', { page: 'history' }))
+ 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() {// {{{
@@ -110,6 +191,38 @@ export class N2PageNodeUI extends CustomHTMLElement {
} else
this.elNodeContent.focus({ preventScroll: true })
}// }}}
+ async renameNode() {// {{{
+ const name = prompt('Change title', this.node.data.Name)
+ if (name === null)
+ return
+
+ try {
+ // Document isn't only renamed, but also saved at once.
+ // Not really correct, but good enough to not have to implement
+ // a separate way to only rename the document. Since history is
+ // preserved it shouldn't be that horrible.
+ this.node.setName(name)
+ await this.node.save()
+
+ // Re-render the parent treenode forcefully to sort it again.
+ const parentUUID = this.node.ParentUUID
+ if (!parentUUID)
+ return
+ const parentTreeNode = _app.sidebar.getTreeNode(parentUUID)
+ parentTreeNode?.render(true, true)
+ } catch (err) {
+ console.error(err)
+ alert(err)
+ }
+ }// }}}
+ async saveNode() {// {{{
+ if (!this.node.isModified())
+ return
+
+ // 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)
@@ -258,6 +371,30 @@ export class N2PageNodeUI extends CustomHTMLElement {
return lines
}// }}}
+ // "marked" sends a messagebus event when checking/unchecking a checkbox.
+ // Updates node and content textarea.
+ checkboxUpdated(eventData) {// {{{
+ const checkbox = eventData.checkbox
+ const pos = eventData.position
+ const content = this.node.content()
+
+ // Basic validation to verify that Marked does what is known and expected at this writing.
+ const mdCheckboxStr = content.slice(pos.start, pos.end)
+ if (!mdCheckboxStr.match(/^\[[ xX]\] $/)) {
+ alert(`Checkbox string didn't pass validation: '${mdCheckboxStr}'`)
+ console.error(`Checkbox string didn't pass validation: '${mdCheckboxStr}'`)
+ }
+
+ // Node is modified with the new value. User has to save manually, otherwise other changes could be saved
+ // when a save wasn't expected.
+ const newValue = `[${checkbox.checked ? 'x' : ' '}] `
+ const modifiedContent = this.node.content().slice(0, pos.start) + newValue + this.node.content().slice(pos.end)
+ this.node.setContent(modifiedContent)
+
+ // Also update the textarea since the node model doesn't know about it.
+ this.elNodeContent.setRangeText(newValue, pos.start, pos.end, 'select')
+
+ }// }}}
}
customElements.define('n2-nodeui', N2PageNodeUI)
@@ -275,15 +412,20 @@ export class Node {
return 0
}//}}}
static create(name, parentUUID) {// {{{
- return new Node({
+ const node = new Node({
UUID: uuidv7(),
Created: (new Date()).toISOString(),
Content: '',
Name: name,
ParentUUID: parentUUID,
Markdown: false,
- History: false,
})
+
+ // Newly created node (not constructed from existing data) is considered modified
+ // since node.save returns early if it isn't modified.
+ node._modified = true
+
+ return node
}// }}}
constructor(nodeData, level) {//{{{
@@ -345,10 +487,6 @@ export class Node {
this.Children[i]._parent = this
}
- // Notify the tree that all children are fetched and ready to process.
- //_notes2.current.tree.fetchChildrenOn(this.UUID)
- _mbus.dispatch(`NODE_CHILDREN_FETCHED_${this.UUID}`)
-
return this.Children
}//}}}
setHasChildren(v) {// {{{
@@ -366,12 +504,23 @@ export class Node {
getParent() {//{{{
return this._parent
}//}}}
+ moveToParent(newParentUUID) {// {{{
+ if (this.UUID === newParentUUID)
+ throw new Error("New parent UUID is the same as node UUID. Can't be your own parent.")
+
+ this.ParentUUID = newParentUUID
+ this.data.ParentUUID = newParentUUID
+ this._modified = true
+ }// }}}
isLastSibling() {//{{{
return this._sibling_after === null
}//}}}
isFirstSibling() {//{{{
return this._sibling_before === null
}//}}}
+ isSpecial() {// {{{
+ return this.data.Special
+ }// }}}
content() {//{{{
/* TODO - implement crypto
if (this.CryptoKeyID != 0 && !this._decrypted)
@@ -393,17 +542,52 @@ export class Node {
_mbus.dispatch('NODE_MODIFIED', { node: this })
}// }}}
async save() {//{{{
+ // Just safeguarding not using the root node,
+ // which sort of exist but isn't supposed to communicate to server.
+ if (this.UUID == ROOT_NODE)
+ return
+
this.data.Content = this._content
this.data.Updated = new Date().toISOString()
+ this.data.HistoryUUID = uuidv7() // every time the node is saved a new history UUID identifies the changed node.
this._modified = false
_mbus.dispatch('NODE_UNMODIFIED')
// When stored into database and ancestry was changed,
// the ancestry path could be interesting.
+ /*
const ancestors = await nodeStore.getNodeAncestry(this)
this.data.Ancestors = ancestors.map(a => a.get('Name')).reverse()
+ */
+ /* The node history is a local store for node history.
+ * This could be provisioned from the server or cleared if
+ * deemed unnecessary.
+ *
+ * The send queue is what will be sent back to the server
+ * to have a recorded history of the notes.
+ *
+ * A setting to be implemented in the future could be to
+ * not save the history locally at all. */
+
+ // Current node is added to history. It will be duplicated with the "nodes" store
+ // for simplicity, to hopefully avoid bugs.
+ const history = nodeStore.nodesHistory.add(this)
+
+ // Updated node is added to the send queue to be stored on server.
+
+ const sendQueue = nodeStore.sendQueue.add(this)
+
+ // Updated node is saved to the primary node store.
+ const nodeStoreAdding = nodeStore.add([this])
+
+ console.log('waiting')
+ await Promise.all([history, sendQueue, nodeStoreAdding])
+ console.log('waiting done')
+
+ return
}//}}}
}
+
// vim: foldmethod=marker
diff --git a/static/js/page_preferences.mjs b/static/js/page_preferences.mjs
new file mode 100644
index 0000000..9655278
--- /dev/null
+++ b/static/js/page_preferences.mjs
@@ -0,0 +1,283 @@
+import { CustomHTMLElement } from "./lib/custom_html_element.mjs"
+import { API } from './api.mjs'
+
+export class N2PagePreferences extends CustomHTMLElement {
+ static {// {{{
+ this.tmpl = document.createElement('template')
+ this.tmpl.innerHTML = `
+
+ Preferences
+
+ Changes preferences to not download images or files on the device doesn't remove the already downloaded data.
+
+
+
Device preference set
+
+
+
+
+
+
+
+ `
+ }// }}}
+ constructor() {// {{{
+ super(true)
+ this.sets = []
+
+ this.elNewSet.addEventListener('click', () => this.newSet())
+ this.elSave.addEventListener('click', () => this.save())
+ this.elDevPreferenceSet.addEventListener('change', event=>this.changePreferenceSet(event))
+
+ window._mbus.subscribe('SHOW_PAGE', async event => {
+ if (event.detail.data?.page == 'preferences') {
+ this.sets = await this.getPreferenceSets()
+ this.render()
+ }
+ })
+
+ window._mbus.subscribe('PREFERENCE_SET_MODIFIED', () => this.preferencesModified())
+ window._mbus.subscribe('PREFERENCE_SET_DELETE', event => this.preferencesDelete(event.detail.data.set))
+ }// }}}
+ sortSets(a, b) {// {{{
+ if (a.name == 'default') return -1
+ if (b.name == 'default') return 1
+
+ if (a.name.toLowerCase() < b.name.toLowerCase()) return -1
+ if (a.name.toLowerCase() > b.name.toLowerCase()) return 1
+
+ return 0
+ }// }}}
+ async render() {// {{{
+ try {
+ this.sets.sort(this.sortSets)
+ this.elSets.replaceChildren(...this.sets)
+
+ const setNames = this.sets.entries().map(([i, set]) => {
+ const optn = document.createElement('option')
+ optn.innerText = set.name
+ return optn
+ })
+ this.elDevPreferenceSet.replaceChildren(...setNames)
+ } catch (e) {
+ console.error(e)
+ alert(e.message)
+ }
+ }// }}}
+ async getPreferenceSets() {// {{{
+ const userData = localStorage.getItem('user')
+ if (userData === null)
+ throw new Error('Could not find user in localStorage')
+
+ const user = JSON.parse(userData)
+ const prefsData = user.Preferences
+
+ if (prefsData === undefined)
+ throw new Error('User object is missing preferences')
+
+ if (!prefsData.hasOwnProperty('default'))
+ throw new Error('The "default" preferences set is missing')
+
+ return Object.keys(prefsData).map(name => new N2PreferenceSet(name, prefsData[name]))
+ }// }}}
+ async retrieveServerPreferences() {// {{{
+ try {
+ API.query('GET', '/user/preferences')
+ } catch (e) {
+ console.error(e)
+ alert(`Error retrieving preferences: ${e.message}`)
+ }
+ }// }}}
+ changePreferenceSet(event) {// {{{
+ this.preferencesModified()
+ }// }}}
+ newSet() {// {{{
+ let name = prompt("Name for new preference set")
+ if (!name)
+ return
+
+ name = name.trim()
+ if (name === '')
+ return
+
+ if (name == 'default') {
+ alert(`Name can't be "default".`)
+ return
+ }
+
+ const exists = this.sets.some(s => s.name.toLowerCase() == name.toLowerCase())
+ if (exists) {
+ alert(`Set with name "${name}" already exist.`)
+ return
+ }
+
+ this.sets.push(new N2PreferenceSet(name, {}))
+ this.preferencesModified()
+ this.render()
+ }// }}}
+ preferencesModified() {// {{{
+ this.elSave.removeAttribute('disabled')
+ }// }}}
+ preferencesDelete(deleteSet) {// {{{
+ if (deleteSet.name == 'default') {
+ alert("Can't delete the default set.")
+ return
+ }
+
+ if (!confirm(`Confirm deleting "${deleteSet.name}"`))
+ return
+
+ this.sets = this.sets.filter(set => {
+ return !(set.name === deleteSet.name)
+ })
+
+ this.preferencesModified()
+ this.render()
+ }// }}}
+ async save() {// {{{
+ try {
+ let newPrefs = {}
+ this.sets.forEach(s => {
+ const setState = s.getState()
+ newPrefs[setState.name] = setState.state
+ })
+
+ // Throws exception on both HTTP and application errors.
+ await API.query('POST', '/user/preferences', newPrefs)
+
+ const userData = localStorage.getItem('user')
+ const user = JSON.parse(userData)
+ user.Preferences = newPrefs
+ localStorage.setItem('user', JSON.stringify(user))
+ localStorage.setItem('device_preference_set', this.elDevPreferenceSet.value)
+ _mbus.dispatch('DEVICE_PREFERENCE_SET_UPDATED')
+ } catch (e) {
+ console.error(e)
+ alert(e.message)
+ } finally {
+ this.elSave.setAttribute('disabled', true)
+ }
+
+ }// }}}
+}
+customElements.define('n2-pagepreferences', N2PagePreferences)
+
+// Preferences is a set of preferences, of which there can be many named.
+export class N2PreferenceSet extends CustomHTMLElement {
+ static {// {{{
+ this.tmpl = document.createElement('template')
+ this.tmpl.innerHTML = `
+
+
+
+
+
+
+
+
+
+ `
+ }// }}}
+ constructor(name, data) {// {{{
+ super(true)
+ this.name = name
+ this.data = data
+ this.render()
+
+ // Enable the save button when settings are modified.
+ this.allFields().forEach(f =>
+ f.addEventListener('input', () => _mbus.dispatch('PREFERENCE_SET_MODIFIED'))
+ )
+
+ this.elName.addEventListener('click', () => this.updateName())
+ this.elDelete.addEventListener('click', () => this.deleteSet())
+ }// }}}
+ updateName() {// {{{
+ if (this.name == 'default') {
+ alert('Can not change name of the default profile.')
+ return
+ }
+
+ const name = prompt("Change name", this.name)
+ if (!name)
+ return
+
+ this.name = name
+ this.render()
+ _mbus.dispatch('PREFERENCE_SET_MODIFIED')
+ }// }}}
+ deleteSet() {// {{{
+ _mbus.dispatch('PREFERENCE_SET_DELETE', { set: this })
+ }// }}}
+ render() {// {{{
+ this.elName.innerText = this.name
+
+ this.fieldDownloadImages.checked = this.data.DownloadImages
+ this.fieldDownloadFiles.checked = this.data.DownloadFiles
+ }// }}}
+ getState() {// {{{
+ const name = this.name.trim()
+ if (name === '')
+ throw new Error('Name can not be empty.')
+
+ return {
+ name: this.name.trim(),
+ state: this.fieldValues(),
+ }
+ }// }}}
+}
+customElements.define('n2-preferenceset', N2PreferenceSet)
diff --git a/static/js/page_storage.mjs b/static/js/page_storage.mjs
index 931a718..a007130 100644
--- a/static/js/page_storage.mjs
+++ b/static/js/page_storage.mjs
@@ -13,7 +13,10 @@ export class N2PageStorage extends CustomHTMLElement {
constructor() {
super()
- window._mbus.subscribe('SHOW_PAGE', () => this.render())
+ window._mbus.subscribe('SHOW_PAGE', event => {
+ if (event.detail.data?.page == 'storage')
+ this.render()
+ })
}
async render() {
const countNodes = await globalThis.nodeStore.nodeCount()
diff --git a/static/js/tree.mjs b/static/js/sidebar.mjs
similarity index 60%
rename from static/js/tree.mjs
rename to static/js/sidebar.mjs
index 928d60d..6cd5814 100644
--- a/static/js/tree.mjs
+++ b/static/js/sidebar.mjs
@@ -1,4 +1,5 @@
-import { ROOT_NODE } from 'node_store'
+import { ROOT_NODE, ORPHANED_NODE, DELETED_NODE } from 'node_store'
+import { Node } from 'node'
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
import { Color, Solver } from './lib/css_colorize.mjs'
@@ -58,12 +59,12 @@ class TreeExpansionHandler {// {{{
}
}// }}}
-export class N2Tree extends CustomHTMLElement {
+export class N2Sidebar extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
-
![]()
+
`
}// }}}
- constructor(tree, node, parent) {//{{{
+ constructor(sidebar, node, parent) {//{{{
super()
+ this.setAttribute('draggable', 'true')
this.classList.add('node')
- this.tree = tree
+ this.sidebar = sidebar
this.node = node
this.parent = parent
this.children_populated = false
this.rendered = false
+ this.dragNode = null
- 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)
+ 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)
})
- if (this.node.Level === 0 || this.tree.getNodeExpanded(this.node.UUID))
- this.fetchChildren()
+ // 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))
}// }}}
- async fetchChildren() {//{{{
+
+ dragStart(e) {// {{{
+ if (this.node.isModified()) {
+ alert('Save note before moving it.')
+ e.stopPropagation()
+ e.preventDefault()
+ return
+ }
+
+ this.classList.add('drag-source')
+ const blankPixel = new Image()
+ blankPixel.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
+ e.dataTransfer.setDragImage(blankPixel, 0, 0)
+ e.dataTransfer.allowedEffects = 'none'
+ e.stopPropagation()
+ _app.dragIcon.setSource(this)
+ _app.dragIcon.start()
+ }// }}}
+ dragEnd(e) {// {{{
+ this.classList.remove('drag-source')
+ _app.dragIcon.end()
+ e.stopPropagation()
+ }// }}}
+ dragOver(e) {// {{{
+ e.dataTransfer.dropEffect = 'move'
+ e.preventDefault()
+ }// }}}
+ async dragDrop(e) {// {{{
+ try {
+ e.stopPropagation()
+ const sourceNode = _app.dragIcon.getSource()
+
+ // Abort if user drops the node back on itself.
+ if (sourceNode.node.UUID === this.node.UUID)
+ return
+
+ await _app.moveNode(sourceNode.node, this.node.UUID)
+
+ _app.sidebar.setNodeExpanded(this, true)
+ await this.render(true, true)
+ await sourceNode.render(true, true)
+ } catch (e) {
+ console.error(e)
+ alert(e)
+ } finally {
+ this.dragLeave(e)
+ }
+ }// }}}
+ dragEnter(e) {// {{{
+ const targetNode = e.target.closest('n2-treenode')
+ if (targetNode.classList.contains('drag-source'))
+ return
+ e.stopPropagation()
+ _app.dragIcon.icon('ok')
+ this.classList.add('drag-target')
+ }// }}}
+ dragLeave(e) {// {{{
+ e.stopPropagation()
+ e.dataTransfer.dropEffect = 'none'
+ e.dataTransfer.setDragImage(N2TreeNode.DRAG_ICON, -16, 8)
+ _app.dragIcon.icon('')
+ this.classList.remove('drag-target')
+ }// }}}
+
+ async expandNode(event) {// {{{
+ const expanded = _app.sidebar.getNodeExpanded(this.node.UUID)
+
+ if (event.shiftKey) {
+ _app.sidebar.recursiveExpand(this.node, !expanded)
+ } else {
+ _app.sidebar.setNodeExpanded(this.node, !expanded)
+ }
+ }// }}}
+ async fetchChildren(force_fetch) {//{{{
+ if (this.children_populated && !force_fetch)
+ return
+
await this.node.fetchChildren()
this.children_populated = true
}//}}}
- render(force_update) {//{{{
+ async render(force_update, force_refetch_children) {//{{{
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.hasChildren() && this.tree.getNodeExpanded(this.node.UUID)
+ if (this.sidebar.getNodeExpanded(this.node.UUID) || force_refetch_children)
+ await this.fetchChildren(force_refetch_children)
- 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
+ // Update the name and selected status.
this.elName.querySelector('span').innerText = this.node.get('Name')
- if (this.tree.isSelected(this.node))
+ 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')
@@ -562,28 +701,42 @@ export class N2TreeNode extends CustomHTMLElement {
}
// The expand icon
is only changed to not get a flickering when re-rendering.
- if (!this.node.hasChildren())
+ 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.tree.getNodeExpanded(this.node.UUID))
+ 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?
- this.elChildren.innerHTML = ''
let children = []
if (expanded)
children = this.node.Children.map(node => {
- let treenode = this.tree.treeNodeComponents[node.UUID]
+ let treenode = this.sidebar.treeNodeComponents[node.UUID]
if (treenode === undefined) {
- treenode = new N2TreeNode(this.tree, node, this)
- this.tree.treeNodeComponents[node.UUID] = treenode
+ treenode = new N2TreeNode(this.sidebar, node, this)
+ this.sidebar.treeNodeComponents[node.UUID] = treenode
}
return treenode
})
+ const renderedChildren = []
for (const c of children)
- this.elChildren.appendChild(c.render())
+ renderedChildren.push(await c.render())
+ this.elChildren.replaceChildren(...renderedChildren)
this.rendered = true
return this
@@ -595,6 +748,24 @@ export class N2TreeNode extends CustomHTMLElement {
img.setAttribute('src', newSrc)
}// }}}
}
+
+class SpecialNodeDeleted extends N2TreeNode {
+ constructor(sidebar, node, parent) {//{{{
+ super(sidebar, node, parent)
+ this.removeAttribute('draggable')
+ }//}}}
+}
+
+class SpecialNodeOrphaned extends N2TreeNode {
+ constructor(sidebar, node, parent) {//{{{
+ super(sidebar, node, parent)
+ this.removeAttribute('draggable')
+ }//}}}
+}
+
+customElements.define('n2-sidebar', N2Sidebar)
customElements.define('n2-treenode', N2TreeNode)
+customElements.define('n2-specialnodedeleted', SpecialNodeDeleted)
+customElements.define('n2-specialnodeorphaned', SpecialNodeOrphaned)
// vim: foldmethod=marker
diff --git a/static/js/sync.mjs b/static/js/sync.mjs
index 291e0b9..daa603f 100644
--- a/static/js/sync.mjs
+++ b/static/js/sync.mjs
@@ -17,10 +17,12 @@ export class Sync {
const state = await nodeStore.getAppState('latest_sync_node')
const oldMax = (state?.value ? state.value : 0)
- let nodeCount = await this.getNodeCount(oldMax)
- nodeCount += await nodeStore.sendQueue.count()
+ let nodeCountDownload = await this.getNodeCount(oldMax)
+ let nodeCountUpload = await nodeStore.sendQueue.count()
- _mbus.dispatch('SYNC_COUNT', { count: nodeCount })
+ _mbus.dispatch('SYNC_START')
+ _mbus.dispatch('SYNC_DOWNLOAD_COUNT', { count: nodeCountDownload })
+ _mbus.dispatch('SYNC_UPLOAD_COUNT', { count: nodeCountUpload })
await this.nodesFromServer(oldMax)
.then(durationNodes => {
@@ -28,6 +30,7 @@ export class Sync {
console.log(`Total time: ${Math.round(1000 * durationNodes) / 1000}s`)
})
+ // Uploads of modified nodes to server.
await this.nodesToServer()
} finally {
_mbus.dispatch('SYNC_DONE')
@@ -78,15 +81,16 @@ export class Sync {
handled++
if (handled % 100 === 0)
- _mbus.dispatch('SYNC_HANDLED', { handled })
+ _mbus.dispatch('SYNC_DOWNLOADED', { handled })
}
} while (res.Continue)
- _mbus.dispatch('SYNC_HANDLED', { handled })
+ _mbus.dispatch('SYNC_DOWNLOADED', { handled })
nodeStore.setAppState('latest_sync_node', currMax)
} catch (e) {
- console.log('sync node tree', e)
+ console.error('sync node tree', e)
+ alert(e.message)
} finally {
syncEnd = Date.now()
const duration = (syncEnd - syncStart) / 1000
@@ -154,8 +158,8 @@ export class Sync {
_mbus.dispatch('SYNC_UPLOADED', { count: nodesToSend.length })
} catch (e) {
- console.trace(e)
- alert(e)
+ console.error(e)
+ alert(e.message)
return
}
}
@@ -166,59 +170,80 @@ export class N2SyncProgress extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
-
- 0 / 0
+
+ 0
/
0
+ 0
/
0
`
}// }}}
constructor() {//{{{
super()
-
this.reset()
- _mbus.subscribe('SYNC_COUNT', event => this.progressHandler(event))
- _mbus.subscribe('SYNC_HANDLED', event => this.progressHandler(event))
+ _mbus.subscribe('SYNC_START', () => this.reset())
+ _mbus.subscribe('SYNC_DOWNLOAD_COUNT', event => this.progressHandler(event))
+ _mbus.subscribe('SYNC_UPLOAD_COUNT', event => this.progressHandler(event))
+ _mbus.subscribe('SYNC_DOWNLOADED', event => this.progressHandler(event))
+ _mbus.subscribe('SYNC_UPLOADED', event => this.progressHandler(event))
_mbus.subscribe('SYNC_DONE', event => this.progressHandler(event))
}//}}}
reset() {//{{{
+ this.classList.remove('ok')
this.state = {
- nodesToSync: 0,
- nodesSynced: 0,
+ nodesToDownload: 0,
+ nodesToUpload: 0,
+ nodesDowloaded: 0,
+ nodesUploaded: 0,
}
+ this.render()
}//}}}
progressHandler(event) {//{{{
const eventData = event.detail.data
switch (event.type) {
- case 'SYNC_COUNT':
- this.state.nodesToSync = eventData.count
+ case 'SYNC_DOWNLOAD_COUNT':
+ this.state.nodesToDownload = eventData.count
this.setSyncState(true)
break
- case 'SYNC_HANDLED':
- this.state.nodesSynced = eventData.handled
+ case 'SYNC_UPLOAD_COUNT':
+ this.state.nodesToUpload = eventData.count
+ this.setSyncState(true)
+ break
+
+ case 'SYNC_DOWNLOADED':
+ this.state.nodesDowloaded = eventData.handled
+ break
+
+ case 'SYNC_UPLOADED':
+ this.state.nodesUploaded += eventData.count
break
case 'SYNC_DONE':
+ this.classList.add('ok')
+
// Hides the progress bar.
this.setSyncState(false)
// Don't update anything if nothing was synced.
- if (this.state.nodesSynced === 0)
+ if (this.state.nodesDowloaded === 0)
break
// Reload the tree nodes to reflect the new/updated nodes.
- window._app.tree.reset()
+ window._app.sidebar.reset()
break
}
this.render()
}//}}}
render() {//{{{
- this.elProgress.max = this.state.nodesToSync
- this.elProgress.value = this.state.nodesSynced
- this.elCount.innerText = `${this.state.nodesSynced} / ${this.state.nodesToSync}`
+ this.elDownloadTransferred.innerText = this.state.nodesDowloaded
+ this.elDownloadTotal.innerText = this.state.nodesToDownload
+
+ this.elUploadTransferred.innerText = this.state.nodesUploaded
+ this.elUploadTotal.innerText = this.state.nodesToUpload
}//}}}
setSyncState(state) {// {{{
if (state)
this.classList.add('show')
else
+ // Give the user a chance to see what it ended on.
setTimeout(() => this.classList.remove('show'), 1500)
}// }}}
}
diff --git a/static/service_worker.js b/static/service_worker.js
index b6a1a13..8522b20 100644
--- a/static/service_worker.js
+++ b/static/service_worker.js
@@ -6,6 +6,7 @@ const CACHED_ASSETS = [
'/css/{{ .VERSION }}/main.css',
'/css/{{ .VERSION }}/markdown.css',
'/css/{{ .VERSION }}/notes2.css',
+ '/css/{{ .VERSION }}/page_history.css',
'/css/{{ .VERSION }}/theme.css',
'/images/{{ .VERSION }}/collapsed.svg',
@@ -42,8 +43,8 @@ const CACHED_ASSETS = [
'/js/{{ .VERSION }}/page_history.mjs',
'/js/{{ .VERSION }}/page_node.mjs',
'/js/{{ .VERSION }}/page_storage.mjs',
+ '/js/{{ .VERSION }}/sidebar.mjs',
'/js/{{ .VERSION }}/sync.mjs',
- '/js/{{ .VERSION }}/tree.mjs',
]
async function precache() {
@@ -119,9 +120,13 @@ self.addEventListener('activate', event => {
})
self.addEventListener('fetch', event => {
- // console.debug('SERVICE WORKER: fetch', event.request.url)
+ // 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
- if ({{ .DevMode }})
+ if (`{{ .DevMode }}` == 'true')
return event
event.respondWith(fetchAsset(event))
diff --git a/user.go b/user.go
deleted file mode 100644
index b1c2abf..0000000
--- a/user.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package main
-
-import (
- // External
- "github.com/golang-jwt/jwt/v5"
-)
-
-type UserSession struct {
- UserID int
- Username string
- Password string
- Name string
- ClientUUID string
-}
-
-func NewUser(claims jwt.MapClaims) (u UserSession) {
- uid, _ := claims["uid"].(float64)
- name, _ := claims["name"].(string)
- username, _ := claims["login"].(string)
- clientUUID, _ := claims["cid"].(string)
-
- u.UserID = int(uid)
- u.Username = username
- u.Name = name
- u.ClientUUID = clientUUID
- return
-}
diff --git a/user/pkg.go b/user/pkg.go
new file mode 100644
index 0000000..bcdfac8
--- /dev/null
+++ b/user/pkg.go
@@ -0,0 +1,63 @@
+package user
+
+import (
+ // External
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/jmoiron/sqlx"
+
+ // Standard
+ "encoding/json"
+)
+
+type User struct {
+ ID int
+ Username string
+ Name string
+ Preferences map[string]UserPreferences
+}
+
+type UserSession struct {
+ UserID int
+ Username string
+ Password string
+ Name string
+ ClientUUID string
+ Db *sqlx.DB
+}
+
+type UserPreferences struct {
+ DownloadImages bool
+ DownloadFiles bool
+}
+
+func NewUser(claims jwt.MapClaims) (u UserSession) {
+ uid, _ := claims["uid"].(float64)
+ name, _ := claims["name"].(string)
+ username, _ := claims["login"].(string)
+ clientUUID, _ := claims["cid"].(string)
+
+ u.UserID = int(uid)
+ u.Username = username
+ u.Name = name
+ u.ClientUUID = clientUUID
+ return
+}
+
+func (u UserSession) Preferences() (prefs map[string]UserPreferences, err error) {
+ row := u.Db.QueryRow(`SELECT preferences FROM public.user WHERE id=$1`, u.UserID)
+
+ var data []byte
+ err = row.Scan(&data)
+ if err != nil {
+ return
+ }
+
+ err = json.Unmarshal(data, &prefs)
+ return
+}
+
+func (u UserSession) SetPreferences(prefs map[string]UserPreferences) (err error) {
+ j, _ := json.Marshal(prefs)
+ _, err = u.Db.Exec(`UPDATE public.user SET preferences=$2 WHERE id=$1`, u.UserID, j)
+ return
+}
diff --git a/views/layouts/main.gotmpl b/views/layouts/main.gotmpl
index ead95a9..c5fbead 100644
--- a/views/layouts/main.gotmpl
+++ b/views/layouts/main.gotmpl
@@ -15,7 +15,7 @@
"crypto": "/js/{{ .VERSION }}/crypto.mjs",
"node_store": "/js/{{ .VERSION }}/node_store.mjs",
"node": "/js/{{ .VERSION }}/page_node.mjs",
- "tree": "/js/{{ .VERSION }}/tree.mjs"
+ "sidebar": "/js/{{ .VERSION }}/sidebar.mjs"
}
}
diff --git a/views/pages/notes2.gotmpl b/views/pages/notes2.gotmpl
index f15bbb5..2755aea 100644
--- a/views/pages/notes2.gotmpl
+++ b/views/pages/notes2.gotmpl
@@ -1,5 +1,12 @@
{{ define "page" }}
-
+
+
+
+
+
+
+
+
+
+
+

+
{{ .VERSION }}
+
+
Create note
+
+
+
-
-
-
-
-
+
-
+
+
+
+
+
+