Compare commits

..

52 commits
v18 ... main

Author SHA1 Message Date
Magnus Åhall
74851b9c4d Prefs managing mostly done 2026-06-21 10:59:38 +02:00
Magnus Åhall
81d02b82dc Small refactor for user preferences 2026-06-18 09:21:23 +02:00
Magnus Åhall
1a712fb7a9 Fix home/end to account for special pages 2026-06-16 10:20:15 +02:00
Magnus Åhall
f5cbfb0b22 Fixed regression of recursive expansion toggle 2026-06-16 10:12:48 +02:00
Magnus Åhall
ea3bdaca03 Select leaf nodes on icon 2026-06-16 10:10:16 +02:00
Magnus Åhall
0fe5cd78b3 Removed vi keybindings to enable search at a later date. 2026-06-16 10:08:36 +02:00
Magnus Åhall
d6d8b64bb9 Fixed recursive open of nodes, bumped to v29 2026-06-16 09:59:01 +02:00
Magnus Åhall
c36b4ace13 Nicer code copy, bumped to v28 2026-06-16 09:43:31 +02:00
Magnus Åhall
7c46127938 Bumped to v27 2026-06-16 09:37:20 +02:00
Magnus Åhall
b0a95c9382 Copy code 2026-06-16 09:37:10 +02:00
Magnus Åhall
ecf68132a1 Bumped to v26 2026-06-16 08:54:04 +02:00
Magnus Åhall
58ece3f823 Bumped to v25 2026-06-16 08:53:43 +02:00
Magnus Åhall
86fcbbb68f Fixed selected node highlighting 2026-06-16 08:53:15 +02:00
Magnus Åhall
d1c3b9e963 Fixed better font settings 2026-06-16 08:48:26 +02:00
Magnus Åhall
dbd3872f0f Fixed flashing history page 2026-06-16 08:35:58 +02:00
Magnus Åhall
e71516fd76 Working node menu 2026-06-16 08:27:01 +02:00
Magnus Åhall
15bd742ef7 More work on special pages 2026-06-16 06:54:10 +02:00
Magnus Åhall
da7999fb24 Work on special pages 2026-06-15 19:13:27 +02:00
Magnus Åhall
d9adfd3a91 Preparation for special nodes 2026-06-15 17:39:01 +02:00
Magnus Åhall
edd3d11b09 Fix three layers of safeguards to ensure node doesn't become it's own parent 2026-06-15 16:39:56 +02:00
Magnus Åhall
960c9e2625 Bumped to v24 2026-06-14 15:22:35 +02:00
Magnus Åhall
658733b1d8 Fixed moving node to root 2026-06-14 15:22:17 +02:00
Magnus Åhall
04c936e730 Fixed drag-and-drop with drag source instead of drag target 2026-06-14 15:02:27 +02:00
Magnus Åhall
63434678ce Added menu icon 2026-06-14 14:37:37 +02:00
Magnus Åhall
53d8d16086 Initial work on node menu 2026-06-14 14:36:54 +02:00
Magnus Åhall
61b0ba9ada Initial work on drag-and-drop 2026-06-14 14:36:28 +02:00
Magnus Åhall
1055404dc0 Fixed ctrl+s 2026-06-13 07:52:07 +02:00
Magnus Åhall
0001cdebdc Fixed keyhandler preventing event default on non-handled events, bumped to v23 2026-06-12 09:41:06 +02:00
Magnus Åhall
b45d8c9240 Bumped to v22 2026-06-12 09:36:33 +02:00
Magnus Åhall
df399f5d37 Update node content when toggling checkboxes 2026-06-12 09:35:45 +02:00
Magnus Åhall
4e495a5c94 Bumped to v21 2026-06-12 08:47:39 +02:00
Magnus Åhall
9af733be64 Icons and keybindings for creating sub-documents and sibling documents 2026-06-12 08:47:24 +02:00
Magnus Åhall
ffb7f4ac53 Go to newly created node. 2026-06-12 08:27:06 +02:00
Magnus Åhall
2d036f847a Better debuggability for node sync problems 2026-06-12 07:25:02 +02:00
Magnus Åhall
73d87d61c4 Fixed syncing alert not showing the proper error 2026-06-12 07:13:25 +02:00
Magnus Åhall
5dac84efdc Cleaner CSS page management 2026-06-12 07:12:38 +02:00
Magnus Åhall
cc2415a06d Bumped to v20 2026-06-11 09:08:30 +02:00
Magnus Åhall
b3ca0d29d0 Root page override 2026-06-10 20:03:31 +02:00
Magnus Åhall
31eee4ede5 Removed old fields 2026-06-10 18:21:30 +02:00
Magnus Åhall
8a22cf569f Cleanup 2026-06-10 17:24:13 +02:00
Magnus Åhall
9ebda04428 Node renaming 2026-06-10 17:21:29 +02:00
Magnus Åhall
c583138270 Show newly created node 2026-06-10 17:08:30 +02:00
Magnus Åhall
95a26e67d5 Better node saving/history 2026-06-10 16:37:33 +02:00
Magnus Åhall
3e8d5b6d9a Fixes for HistoryUUID 2026-06-10 08:03:48 +02:00
Magnus Åhall
be7f5dbf30 Sync better 2026-06-09 17:23:44 +02:00
Magnus Åhall
1f24f1f2f2 Sync shows/disappears 2026-06-09 17:03:01 +02:00
Magnus Åhall
25b763703d Bumped to v19 2026-06-09 10:27:22 +02:00
Magnus Åhall
744984fc46 Better sync 2026-06-09 10:27:16 +02:00
Magnus Åhall
227fa2208b WIP sync 2026-06-09 06:43:07 +02:00
Magnus Åhall
805f7ae318 Headings with lines to divide the page 2026-06-08 22:24:28 +02:00
Magnus Åhall
389c2fe69a Fixed bug in arrow up 2026-06-08 21:59:06 +02:00
Magnus Åhall
2921613d97 Show node page when switching node 2026-06-08 21:51:03 +02:00
36 changed files with 2545 additions and 457 deletions

View file

@ -8,6 +8,9 @@ import (
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/lib/pq" "github.com/lib/pq"
// Internal
appUser "notes2/user"
// Standard // Standard
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
@ -27,12 +30,6 @@ type Manager struct {
ExpireDays int ExpireDays int
} }
type User struct {
ID int
Username string
Name string
}
func httpError(w http.ResponseWriter, err error) { // {{{ func httpError(w http.ResponseWriter, err error) { // {{{
j, _ := json.Marshal(struct { j, _ := json.Marshal(struct {
OK bool 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") mngr.log.Info("authentication", "username", request.Username, "status", "accepted")
j, _ := json.Marshal(struct { j, _ := json.Marshal(struct {
OK bool OK bool
User User User appUser.User
Token string Token string
}{true, user, token}) }{true, user, token})
w.Write(j) 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 var row *sql.Row
row = mngr.db.QueryRow(` row = mngr.db.QueryRow(`
SELECT id, username, name SELECT id, username, name, preferences
FROM public.user FROM public.user
WHERE WHERE
LOWER(username) = LOWER($1) AND LOWER(username) = LOWER($1) AND
@ -183,13 +180,21 @@ func (mngr *Manager) Authenticate(username, password string) (authenticated bool
username, username,
password, 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" { if err != nil && err.Error() == "sql: no rows in result set" {
err = nil err = nil
authenticated = false authenticated = false
return return
} }
if err != nil { if err != nil {
authenticated = false
return
}
err = json.Unmarshal(data, &user.Preferences)
if err != nil {
authenticated = false
return return
} }
@ -278,7 +283,7 @@ func (mngr *Manager) ChangePassword(username, currentPassword, newPassword strin
changed = (rowsAffected == 1) changed = (rowsAffected == 1)
return 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. // Each client session has its own UUID.
// Loop through until a unique one is established. // Loop through until a unique one is established.
var proposedClientUUID string var proposedClientUUID string

75
main.go
View file

@ -4,6 +4,7 @@ import (
// Internal // Internal
"notes2/authentication" "notes2/authentication"
"notes2/html_template" "notes2/html_template"
appUser "notes2/user"
"os" "os"
// Standard // Standard
@ -23,7 +24,7 @@ import (
"text/template" "text/template"
) )
const VERSION = "v18" const VERSION = "v29"
const CONTEXT_USER = 1 const CONTEXT_USER = 1
const SYNC_PAGINATION = 200 const SYNC_PAGINATION = 200
@ -134,6 +135,8 @@ func main() { // {{{
http.HandleFunc("/offline", pageOffline) http.HandleFunc("/offline", pageOffline)
http.HandleFunc("/user/authenticate", AuthManager.AuthenticationHandler) 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/count/{sequence}", authenticated(actionSyncFromServerCount))
http.HandleFunc("/sync/from_server/{sequence}/{offset}", authenticated(actionSyncFromServer)) http.HandleFunc("/sync/from_server/{sequence}/{offset}", authenticated(actionSyncFromServer))
@ -178,7 +181,7 @@ func authenticated(fn func(http.ResponseWriter, *http.Request)) func(http.Respon
} }
// User object is added to the context for the next handler. // User 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)) 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) Log.Debug("webserver", "op", "request", "method", r.Method, "url", r.URL.String(), "username", user.Username, "client", user.ClientUUID)
@ -266,7 +269,7 @@ func actionSyncFromServer(w http.ResponseWriter, r *http.Request) { // {{{
// The purpose of the Client UUID is to avoid // The purpose of the Client UUID is to avoid
// sending nodes back once again to a client that // sending nodes back once again to a client that
// just created or modified it. // just created or modified it.
user := getUser(r) user := getUserSession(r)
changedFrom, _ := strconv.Atoi(r.PathValue("sequence")) changedFrom, _ := strconv.Atoi(r.PathValue("sequence"))
offset, _ := strconv.Atoi(r.PathValue("offset")) offset, _ := strconv.Atoi(r.PathValue("offset"))
@ -277,12 +280,6 @@ func actionSyncFromServer(w http.ResponseWriter, r *http.Request) { // {{{
return 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 { j, _ := json.Marshal(struct {
OK bool OK bool
Nodes []Node Nodes []Node
@ -295,7 +292,7 @@ func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{
// The purpose of the Client UUID is to avoid // The purpose of the Client UUID is to avoid
// sending nodes back once again to a client that // sending nodes back once again to a client that
// just created or modified it. // just created or modified it.
user := getUser(r) user := getUserSession(r)
changedFrom, _ := strconv.Atoi(r.PathValue("sequence")) changedFrom, _ := strconv.Atoi(r.PathValue("sequence"))
count, err := NodesCount(user.UserID, uint64(changedFrom), user.ClientUUID) count, err := NodesCount(user.UserID, uint64(changedFrom), user.ClientUUID)
@ -315,7 +312,7 @@ func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{
w.Write(j) w.Write(j)
} // }}} } // }}}
func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
user := getUser(r) user := getUserSession(r)
var err error var err error
uuid := r.PathValue("uuid") uuid := r.PathValue("uuid")
@ -331,7 +328,7 @@ func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
}) })
} // }}} } // }}}
func actionNodeHistoryRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ func actionNodeHistoryRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
user := getUser(r) user := getUserSession(r)
var err error var err error
uuid := r.PathValue("uuid") uuid := r.PathValue("uuid")
@ -354,7 +351,7 @@ func actionNodeHistoryRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
}) })
} // }}} } // }}}
func actionNodeHistoryCount(w http.ResponseWriter, r *http.Request) { // {{{ func actionNodeHistoryCount(w http.ResponseWriter, r *http.Request) { // {{{
user := getUser(r) user := getUserSession(r)
var err error var err error
uuid := r.PathValue("uuid") uuid := r.PathValue("uuid")
@ -371,7 +368,7 @@ func actionNodeHistoryCount(w http.ResponseWriter, r *http.Request) { // {{{
}) })
} // }}} } // }}}
func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{ func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{
user := getUser(r) user := getUserSession(r)
body, _ := io.ReadAll(r.Body) body, _ := io.ReadAll(r.Body)
var request struct { var request struct {
@ -383,9 +380,50 @@ func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{
return 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 { if err != nil {
Log.Error("sync", "error", err)
httpError(w, err) httpError(w, err)
return return
} }
@ -437,7 +475,8 @@ func changePassword(username string) { // {{{
fmt.Printf("\nPassword changed\n") fmt.Printf("\nPassword changed\n")
} // }}} } // }}}
func getUser(r *http.Request) UserSession { // {{{ func getUserSession(r *http.Request) appUser.UserSession { // {{{
user, _ := r.Context().Value(CONTEXT_USER).(UserSession) user, _ := r.Context().Value(CONTEXT_USER).(appUser.UserSession)
user.Db = db
return user return user
} // }}} } // }}}

22
node.go
View file

@ -44,6 +44,7 @@ type Node struct {
UUID string UUID string
UserID int `db:"user_id"` UserID int `db:"user_id"`
ParentUUID string `db:"parent_uuid"` ParentUUID string `db:"parent_uuid"`
HistoryUUID string `db:"history_uuid"`
Name string Name string
Created time.Time Created time.Time
Updated time.Time Updated time.Time
@ -53,11 +54,7 @@ type Node struct {
DeletedSeq sql.NullInt64 `db:"deleted_seq"` DeletedSeq sql.NullInt64 `db:"deleted_seq"`
Content string Content string
ContentEncrypted string `db:"content_encrypted" json:"-"` ContentEncrypted string `db:"content_encrypted" json:"-"`
Markdown bool Special bool
// CryptoKeyID int `db:"crypto_key_id"`
//Files []File
//ChecklistGroups []ChecklistGroup
} }
func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint64, moreRowsExist bool, err error) { // {{{ 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 public.node
WHERE WHERE
user_id = $1 AND user_id = $1 AND
NOT history AND ( (
created_seq > $4 OR created_seq > $4 OR
updated_seq > $4 OR updated_seq > $4 OR
deleted_seq > $4 deleted_seq > $4
@ -126,7 +123,7 @@ func Nodes(userID, offset int, synced uint64, clientUUID string) (nodes []Node,
rows, err = db.Queryx(` rows, err = db.Queryx(`
SELECT SELECT
uuid, uuid,
COALESCE(parent_uuid, '') AS parent_uuid, COALESCE(parent_uuid, '00000000-0000-0000-0000-000000000000'::uuid) AS parent_uuid,
name, name,
created, created,
updated, updated,
@ -135,14 +132,14 @@ func Nodes(userID, offset int, synced uint64, clientUUID string) (nodes []Node,
updated_seq, updated_seq,
deleted_seq, deleted_seq,
content, content,
content_encrypted, content_encrypted
markdown
FROM FROM
public.node public.node
WHERE WHERE
NOT special AND
user_id = $1 AND user_id = $1 AND
client != $5 AND client != $5::uuid AND
NOT history AND ( (
created_seq > $4 OR created_seq > $4 OR
updated_seq > $4 OR updated_seq > $4 OR
deleted_seq > $4 deleted_seq > $4
@ -195,7 +192,7 @@ func NodesCount(userID int, synced uint64, clientUUID string) (count int, err er
WHERE WHERE
user_id = $1 AND user_id = $1 AND
client != $3 AND client != $3 AND
NOT history AND ( (
created_seq > $2 OR created_seq > $2 OR
updated_seq > $2 OR updated_seq > $2 OR
deleted_seq > $2 deleted_seq > $2
@ -255,6 +252,7 @@ func RetrieveNodeHistory(userID int, nodeUUID string, offset int) (nodes []Node,
rows, err = db.Queryx(` rows, err = db.Queryx(`
SELECT SELECT
uuid, uuid,
history_uuid,
user_id, user_id,
name, name,
created, created,

View file

@ -257,7 +257,7 @@ $$;
CREATE TABLE public.client ( CREATE TABLE public.client (
id integer NOT NULL, id integer NOT NULL,
user_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, created timestamp with time zone DEFAULT now() NOT NULL,
description character varying DEFAULT ''::character varying NOT NULL description character varying DEFAULT ''::character varying NOT NULL
); );
@ -302,8 +302,8 @@ CREATE SEQUENCE public.node_updates
CREATE TABLE public.node ( CREATE TABLE public.node (
id integer NOT NULL, id integer NOT NULL,
user_id integer NOT NULL, user_id integer NOT NULL,
uuid character(36) DEFAULT gen_random_uuid() NOT NULL, "uuid" uuid DEFAULT gen_random_uuid() NOT NULL,
parent_uuid character(36), parent_uuid uuid,
created timestamp with time zone DEFAULT now() NOT NULL, created timestamp with time zone DEFAULT now() NOT NULL,
updated timestamp with time zone DEFAULT now() NOT NULL, updated timestamp with time zone DEFAULT now() NOT NULL,
deleted timestamp with time zone, deleted timestamp with time zone,
@ -315,7 +315,7 @@ CREATE TABLE public.node (
content_encrypted text DEFAULT ''::text NOT NULL, content_encrypted text DEFAULT ''::text NOT NULL,
markdown boolean DEFAULT false NOT NULL, markdown boolean DEFAULT false NOT NULL,
history 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, client_sequence integer,
CONSTRAINT name_length CHECK ((length(TRIM(BOTH FROM name)) > 0)) CONSTRAINT name_length CHECK ((length(TRIM(BOTH FROM name)) > 0))
); );
@ -328,7 +328,7 @@ CREATE TABLE public.node (
CREATE TABLE public.node_history ( CREATE TABLE public.node_history (
id integer NOT NULL, id integer NOT NULL,
user_id integer NOT NULL, user_id integer NOT NULL,
uuid character(36) NOT NULL, "uuid" uuid NOT NULL,
parents character varying[], parents character varying[],
created timestamp with time zone NOT NULL, created timestamp with time zone NOT NULL,
updated 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 text NOT NULL,
content_encrypted text NOT NULL, content_encrypted text NOT NULL,
markdown boolean DEFAULT false 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 client_sequence integer
); );

1
sql/00003.sql Normal file
View file

@ -0,0 +1 @@
ALTER TABLE public.node_history ADD history_uuid uuid NULL;

135
sql/00004.sql Normal file
View file

@ -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$
;

129
sql/00005.sql Normal file
View file

@ -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$
;

119
sql/00006.sql Normal file
View file

@ -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$
;

119
sql/00007.sql Normal file
View file

@ -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$
;

123
sql/00008.sql Normal file
View file

@ -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$
;

35
sql/00009.sql Normal file
View file

@ -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();

1
sql/00010.sql Normal file
View file

@ -0,0 +1 @@
ALTER TABLE public."user" ADD preferences jsonb DEFAULT '{}' NOT NULL;

View file

@ -1,36 +1,75 @@
.el-node-markdown { .el-node-markdown {
padding-top: 16px; padding-top: 16px;
.heading-container {
display: grid;
grid-template-columns: min-content 1fr;
grid-gap: 12px;
white-space: nowrap;
align-items: center;
margin-bottom: 16px;
&:first-child {
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 { h1 {
border-bottom: 1px solid #ccc; border-bottom: 1px solid #ccc;
margin-top: 32px;
margin-bottom: 8px;
display: inline-block; display: inline-block;
font-size: 1.25em; font-size: 1.25em;
border-radius: 8px; clip-path: polygon(0 0, 100% 0, calc(100% - 16px) 100%, 0 100%);
color: #fff; color: #fff;
background-color: var(--color1); background-color: var(--color1);
padding: 4px 12px; padding: 4px 24px 4px 16px;
&:first-child {
margin-top: 32px;
}
} }
h2 { h2 {
font-size: 1.25em; font-size: 1.25em;
margin-top: 32px;
margin-bottom: 0px;
color: var(--color1); color: var(--color1);
} }
h3:before { h3 {
&:before {
font-size: 1.0em; font-size: 1.0em;
content: "> "; content: "> ";
color: var(--color1); color: var(--color1);
} }
}
}
a {
color: var(--color1);
}
p { p {
line-height: 150%; line-height: 150%;
@ -44,7 +83,7 @@
table { table {
border: 1px solid #ccc; border: 1px solid #ccc;
border-collapse: collapse; border-collapse: collapse;
margin-top: 14px; margin-top: 16px;
th { th {
text-align: left; text-align: left;
@ -63,6 +102,11 @@
border: 1px solid #ccc; border: 1px solid #ccc;
padding: 2px 4px; padding: 2px 4px;
border-radius: 4px; border-radius: 4px;
&.copy {
border: var(--markdown-copy-border);
background-color: var(--markdown-copy-background);
}
} }
pre { pre {
@ -72,6 +116,14 @@
border-radius: 4px; border-radius: 4px;
white-space: pre-wrap; white-space: pre-wrap;
&.copy {
border: var(--markdown-copy-border);
background-color: var(--markdown-copy-background);
code {
background-color: inherit !important;
}
}
code { code {
border: unset; border: unset;
padding: unset; padding: unset;

View file

@ -10,6 +10,14 @@
--line-color: #ccc; --line-color: #ccc;
--tree-expander: 0px; --tree-expander: 0px;
--functions-width: 150px; --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 { html {
@ -20,6 +28,10 @@ html {
filter: var(--colorize); filter: var(--colorize);
} }
textarea {
font-family: var(--font-monospace);
}
button { button {
font-size: 1em; font-size: 1em;
padding: 4px 8px; padding: 4px 8px;
@ -61,9 +73,10 @@ button {
1fr; 1fr;
} }
&.page-history { /* The other pages just gets the whole page without dividing it up. */
&:not(.page-node) {
grid-template-areas: grid-template-areas:
"tree-expander tree pad1 n2-pagehistory pad2" "tree-expander tree pad1 n2-page pad2"
; ;
grid-template-columns: grid-template-columns:
@ -186,6 +199,11 @@ button {
img { img {
width: auto; width: auto;
height: 18px; height: 18px;
&.deleted {
height: 24px;
transform: translateX(3px) translateY(3px);
}
} }
} }
@ -218,10 +236,61 @@ button {
} }
} }
/* =============== *
* PAGE MANAGEMENT *
* =============== */
[id^="page-"] { [id^="page-"] {
display: none; 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 { #main-page {
display: contents; display: contents;
@ -229,30 +298,6 @@ button {
background-color: #faf; background-color: #faf;
} }
&.node {
#page-node {
display: contents;
}
}
&.storage {
#page-storage {
display: contents;
n2-pagestorage {
grid-area: content;
}
}
}
&.history {
#page-history {
display: grid;
grid-area: n2-pagehistory;
n2-pagehistory {}
}
}
} }
#crumbs { #crumbs {
@ -307,65 +352,66 @@ button {
} }
n2-syncprogress { n2-syncprogress {
--radius: 8px;
display: grid; display: grid;
grid-area: sync; position: fixed;
display: grid; top: 8px;
justify-items: center; right: 8px;
align-items: center; padding: 8px 16px;
z-index: 16384;
position: relative; 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; opacity: 0;
transition: height 0s 500ms, opacity 500ms linear, visibility 0s 500ms; transition: opacity 250ms;
&.show { &.show {
opacity: 1; opacity: 1;
transition: visibility, height 0s, opacity 500ms linear;
} }
progress { &.ok {
width: 100%; background-color: #5aa02c;
height: 24px;
border-radius: 8px;
} }
.count { grid-template-columns: min-content repeat(3, min-content);
position: absolute; grid-gap: 8px 8px;
top: 16px;
width: 100%;
white-space: nowrap; white-space: nowrap;
color: #888; align-items: center;
text-align: center; justify-items: end;
font-size: 12pt;
font-weight: bold; img {
grid-row: 1/3;
height: 34px;
margin-right: 8px;
}
}
#page-root {
&>div {
grid-area: content;
align-self: start;
margin-top: 64px;
display: grid;
justify-items: center;
/* logo */
img {
margin-bottom: 16px;
height: 32px;
} }
progress[value]::-webkit-progress-bar { .create {
background-color: #eee; border: 2px solid #529b00;
box-shadow: 0 2px var(--radius) rgba(0, 0, 0, 0.25) inset; padding: 16px 32px;
border-radius: var(--radius); margin-top: 64px;
} background-color: #d9ffc9;
cursor: pointer;
progress[value]::-moz-progress-bar {
background-color: #eee;
box-shadow: 0 2px var(--radius) rgba(0, 0, 0, 0.25) inset;
border-radius: var(--radius);
} }
progress[value]::-webkit-progress-value {
background: rgb(186, 95, 89);
background: linear-gradient(180deg, rgba(186, 95, 89, 1) 0%, rgba(254, 95, 85, 1) 50%, rgba(186, 95, 89, 1) 100%);
border-radius: var(--radius);
} }
progress[value]::-moz-progress-value {
background: rgb(186, 95, 89);
background: linear-gradient(180deg, rgba(186, 95, 89, 1) 0%, rgba(254, 95, 85, 1) 50%, rgba(186, 95, 89, 1) 100%);
border-radius: var(--radius);
}
} }
/* ============================================================= */ /* ============================================================= */
@ -393,6 +439,8 @@ n2-nodeui {
font-size: 1.75em; font-size: 1.75em;
margin-top: 8px; margin-top: 8px;
margin-bottom: 0px; margin-bottom: 0px;
white-space: nowrap;
width: min-content;
} }
.el-functions { .el-functions {
@ -406,7 +454,6 @@ n2-nodeui {
grid-area: content; grid-area: content;
justify-self: center; justify-self: center;
word-wrap: break-word; word-wrap: break-word;
font-family: monospace;
font-size: 1em; font-size: 1em;
color: #333; color: #333;
@ -430,6 +477,10 @@ n2-nodeui {
grid-area: content; grid-area: content;
display: none; display: none;
font-family: var(--font-monospace);
font-size: 1em;
font-weight: 400;
border-top: 1px solid #e0e0e0; border-top: 1px solid #e0e0e0;
margin-top: 8px; margin-top: 8px;
margin-bottom: 32px; margin-bottom: 32px;

View file

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="24"
height="24"
viewBox="0 0 6.35 6.35"
version="1.1"
id="svg1"
sodipodi:docname="icon_drag.svg"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
xml:space="preserve"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="22.627417"
inkscape:cx="11.291611"
inkscape:cy="10.84967"
inkscape:window-width="2190"
inkscape:window-height="1401"
inkscape:window-x="1463"
inkscape:window-y="18"
inkscape:window-maximized="1"
inkscape:current-layer="layer1"
showgrid="false" /><defs
id="defs1" /><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-107.95,-148.16667)"><title
id="title1">folder-open</title><title
id="title1-1">folder-open-outline</title><title
id="title1-5">notebook-outline</title><title
id="title1-8">text-box-outline</title><path
style="fill:#ffffff;stroke-width:0.264583"
d="m 108.3015,148.56838 h 3.95851 v 3.96688 h -3.95851 z"
id="path3"
sodipodi:nodetypes="ccccc" /><path
d="m 108.47917,148.16667 c -0.29369,0 -0.52917,0.23548 -0.52917,0.52917 V 152.4 c 0,0.29369 0.23548,0.52917 0.52917,0.52917 h 3.70416 c 0.29369,0 0.52917,-0.23548 0.52917,-0.52917 v -3.70416 c 0,-0.29369 -0.23548,-0.52917 -0.52917,-0.52917 h -3.70416 m 0,0.52917 h 3.70416 V 152.4 h -3.70416 v -3.70416"
id="path1"
style="fill:#666666;fill-opacity:1;stroke:none;stroke-width:0.264583"
sodipodi:nodetypes="cssssssscccccc" /><path
d="m 109.00833,149.225 v 0.52917 h 2.64584 V 149.225 h -2.64584 m 0,1.05834 v 0.52916 h 2.64584 v -0.52916 h -2.64584 m 0,1.05833 v 0.52917 h 2.64584 v -0.52917 z"
id="path2"
style="fill:#999999;fill-opacity:1;stroke-width:0.264583"
sodipodi:nodetypes="ccccccccccccccc" /><g
id="g5"
transform="translate(0.26458031,0.26458956)"><g
id="g8"
transform="matrix(1.2067669,0,0,1.2067669,-23.043599,-31.373186)"><circle
style="fill:#800000;fill-opacity:1;stroke:none;stroke-width:1.14487;stroke-dasharray:none;stroke-opacity:1"
id="path8"
cx="112.05721"
cy="152.28557"
r="1.5347482" /></g><path
style="fill:none;stroke:#ffffff;stroke-width:0.79375;stroke-dasharray:none;stroke-opacity:1"
d="m 111.32748,151.54414 1.71172,1.71172"
id="path4" /><path
style="fill:none;stroke:#ffffff;stroke-width:0.79375;stroke-dasharray:none;stroke-opacity:1"
d="m 113.0392,151.54414 -1.71172,1.71172"
id="path5" /></g></g></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="24"
height="24"
viewBox="0 0 6.35 6.35"
version="1.1"
id="svg1"
sodipodi:docname="icon_drag_ok.svg"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
xml:space="preserve"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="32"
inkscape:cx="13.859375"
inkscape:cy="14.890625"
inkscape:window-width="2190"
inkscape:window-height="1401"
inkscape:window-x="1463"
inkscape:window-y="18"
inkscape:window-maximized="1"
inkscape:current-layer="layer1"
showgrid="false" /><defs
id="defs1" /><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-107.95,-148.16667)"><title
id="title1">folder-open</title><title
id="title1-1">folder-open-outline</title><title
id="title1-5">notebook-outline</title><title
id="title1-8">text-box-outline</title><path
style="fill:#ffffff;stroke-width:0.264583"
d="m 108.3015,148.56838 h 3.95851 v 3.96688 h -3.95851 z"
id="path3"
sodipodi:nodetypes="ccccc" /><title
id="title1-53">text-box-check-outline</title><path
style="stroke-width:0.264583;fill:#999999;fill-opacity:1"
d="m 111.65417,149.75417 h -2.64584 V 149.225 h 2.64584"
id="path7" /><path
style="fill:#999999;fill-opacity:1;stroke-width:0.264583"
d="m 109.00833,150.8125 v -0.52916 h 2.64584 v 0.52916"
id="path6"
sodipodi:nodetypes="cccc" /><path
style="fill:#999999;fill-opacity:1;stroke-width:0.313059"
d="m 111.65417,151.87084 h -2.64584 v -0.52917 h 2.64584"
id="path5"
sodipodi:nodetypes="cccc" /><path
style="fill:#666666;fill-opacity:1;stroke-width:0.264583"
d="m 111.26226,152.92917 h -2.78309 c -0.29369,0 -0.52917,-0.23548 -0.52917,-0.52917 v -3.70416 c 0,-0.29369 0.23548,-0.52917 0.52917,-0.52917 h 3.70416 c 0.29369,0 0.52917,0.23548 0.52917,0.52917 v 2.7004 c -0.1614,-0.0926 -0.33867,-0.15875 -0.52917,-0.1905 v -2.5099 h -3.70416 V 152.4 h 2.59259 c 0.0318,0.1905 0.0979,0.36777 0.1905,0.52917"
id="path4"
sodipodi:nodetypes="cssssssccccccc" /><g
id="g8"
transform="matrix(1.2067669,0,0,1.2067669,-23.043599,-31.373186)"><ellipse
style="fill:#338000;fill-opacity:1;stroke:none;stroke-width:1.14488;stroke-dasharray:none;stroke-opacity:1"
id="path8"
cx="112.27646"
cy="152.50487"
rx="1.5347482"
ry="1.5347837" /><path
style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583"
d="m 112.01188,153.31978 -0.72761,-0.79375 0.30692,-0.30692 0.42069,0.42069 0.94985,-0.94985 0.30692,0.37306"
id="path1-5" /></g></g></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="15.999988"
height="15.999988"
viewBox="0 0 4.2333301 4.2333301"
version="1.1"
id="svg1"
sodipodi:docname="icon_drag_source.svg"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="8.4025252"
inkscape:cx="45.522029"
inkscape:cy="-1.1306125"
inkscape:window-width="2190"
inkscape:window-height="1401"
inkscape:window-x="1463"
inkscape:window-y="18"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-187.14773,-188.73523)">
<title
id="title1">drag-variant</title>
<path
d="m 191.38106,190.85189 -0.8907,0.89269 -0.49793,-0.49594 0.39279,-0.39675 -0.39279,-0.38881 0.49793,-0.49792 0.8907,0.88673 m -2.11667,-2.11666 0.88674,0.89071 -0.49791,0.49792 -0.38883,-0.39278 -0.39674,0.39278 -0.49594,-0.49792 0.89268,-0.89071 m 0,4.23333 -0.88673,-0.8907 0.49792,-0.49793 0.38881,0.39279 0.39675,-0.39279 0.49594,0.49793 -0.89269,0.8907 m -2.11666,-2.11667 0.89071,-0.89268 0.49792,0.49594 -0.39278,0.39674 0.39278,0.38883 -0.49792,0.49791 -0.89071,-0.88674 m 2.11666,-0.39674 a 0.3967509,0.3967509 0 0 1 0.39675,0.39674 0.3967509,0.3967509 0 0 1 -0.39675,0.39675 0.3967509,0.3967509 0 0 1 -0.39674,-0.39675 0.3967509,0.3967509 0 0 1 0.39674,-0.39674 z"
id="path1"
style="stroke-width:0.198375" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="12"
height="23.999981"
viewBox="0 0 3.1750001 6.349995"
version="1.1"
id="svg1"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
sodipodi:docname="icon_menu.svg"
xml:space="preserve"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="11.859035"
inkscape:cx="8.6010372"
inkscape:cy="17.32856"
inkscape:window-width="2190"
inkscape:window-height="1401"
inkscape:window-x="1463"
inkscape:window-y="18"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" /><defs
id="defs1" /><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-147.15925,-92.339586)"><title
id="title1">menu</title><title
id="title1-6">hamburger</title><circle
style="fill:#000000;stroke:none;stroke-width:0.264583"
id="path3"
cx="149.55338"
cy="93.120461"
r="0.78087437" /><circle
style="fill:#000000;stroke:none;stroke-width:0.264583"
id="circle4"
cx="149.55338"
cy="97.908707"
r="0.78087437" /><circle
style="fill:#000000;stroke:none;stroke-width:0.264583"
id="circle5"
cx="149.55338"
cy="95.514587"
r="0.78087437" /></g></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="21.714331"
height="24"
viewBox="0 0 5.7452499 6.35"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)"
sodipodi:docname="icon_new_document.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="0.74118967"
inkscape:cx="8.769685"
inkscape:cy="10.793459"
inkscape:window-width="1916"
inkscape:window-height="1041"
inkscape:window-x="1920"
inkscape:window-y="1080"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-102.65833,-145.78542)">
<title
id="title1">file-document-plus-outline</title>
<path
d="m 108.40358,150.62351 h -0.90715 v -0.90714 h -0.60476 v 0.90714 h -0.90715 v 0.60477 h 0.90715 v 0.90714 h 0.60476 v -0.90714 h 0.90715 m -5.14048,-5.44286 c -0.33565,0 -0.60477,0.27214 -0.60477,0.60475 v 4.83811 c 0,0.33563 0.26912,0.60475 0.60477,0.60475 h 2.3616 c -0.10892,-0.18747 -0.18446,-0.3931 -0.22075,-0.60475 h -2.14085 v -4.83811 h 2.11666 v 1.51191 h 1.51191 v 1.23372 c 0.0998,-0.0151 0.20259,-0.0242 0.30237,-0.0242 0.10286,0 0.2026,0.009 0.30239,0.0242 v -1.53609 l -1.81428,-1.81429 m -1.81429,3.02381 v 0.60476 h 2.41904 v -0.60476 m -2.41904,1.20952 v 0.60476 h 1.5119 v -0.60476 z"
id="path1"
style="stroke-width:0.302381" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="5.89642mm"
height="6.3499999mm"
viewBox="0 0 5.89642 6.3499999"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)"
sodipodi:docname="icon_transfer.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="1"
inkscape:cx="9.5"
inkscape:cy="10"
inkscape:window-width="1916"
inkscape:window-height="1161"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-102.39375,-145.78542)">
<title
id="title1">file-arrow-up-down-outline</title>
<path
d="m 105.14239,151.22828 c 0.0363,0.21771 0.11189,0.42031 0.21771,0.60475 h -2.36158 c -0.33565,0 -0.60477,-0.26912 -0.60477,-0.60475 v -4.83811 c 0,-0.33261 0.26912,-0.60475 0.60477,-0.60475 h 2.41904 l 1.81428,1.81429 v 1.53912 c -0.0998,-0.0151 -0.19956,-0.0272 -0.30238,-0.0272 -0.10285,0 -0.20259,0.0121 -0.30237,0.0272 v -1.23675 h -1.51191 v -1.51191 h -2.11666 v 4.83811 h 2.14387 m 1.18231,-1.51191 -0.75596,0.90714 h 0.45358 v 1.20952 h 0.60477 v -1.20952 h 0.45356 l -0.75595,-0.90714 m 1.51191,1.51191 v -1.20953 h -0.60477 v 1.20953 h -0.45356 l 0.75595,0.90714 0.75594,-0.90714 z"
id="path1"
style="stroke-width:0.302381;fill:#ffffff" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -24,12 +24,12 @@
inkscape:deskcolor="#d1d1d1" inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px" inkscape:document-units="px"
inkscape:zoom="31.614857" inkscape:zoom="31.614857"
inkscape:cx="5.0609117" inkscape:cx="5.0450964"
inkscape:cy="9.5524708" inkscape:cy="9.5682862"
inkscape:window-width="2190" inkscape:window-width="2190"
inkscape:window-height="1401" inkscape:window-height="1401"
inkscape:window-x="1463" inkscape:window-x="1463"
inkscape:window-y="0" inkscape:window-y="18"
inkscape:window-maximized="1" inkscape:window-maximized="1"
inkscape:current-layer="layer1" inkscape:current-layer="layer1"
showgrid="false" /><defs showgrid="false" /><defs

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before After
Before After

View file

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="24.000015"
height="23.999998"
viewBox="0 0 6.350004 6.3499995"
version="1.1"
id="svg1"
sodipodi:docname="leaf_deleted.svg"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
xml:space="preserve"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="22.627417"
inkscape:cx="3.292466"
inkscape:cy="15.62264"
inkscape:window-width="2190"
inkscape:window-height="1401"
inkscape:window-x="1463"
inkscape:window-y="18"
inkscape:window-maximized="1"
inkscape:current-layer="layer1"
showgrid="false" /><defs
id="defs1" /><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-107.95,-148.16667)"><title
id="title1">folder-open</title><title
id="title1-1">folder-open-outline</title><title
id="title1-5">notebook-outline</title><title
id="title1-8">text-box-outline</title><path
style="fill:#ffffff;stroke-width:0.264583"
d="m 108.3015,148.56838 h 3.95851 v 3.96688 h -3.95851 z"
id="path3"
sodipodi:nodetypes="ccccc" /><path
d="m 108.47917,148.16667 c -0.29369,0 -0.52917,0.23548 -0.52917,0.52917 V 152.4 c 0,0.29369 0.23548,0.52917 0.52917,0.52917 h 3.70416 c 0.29369,0 0.52917,-0.23548 0.52917,-0.52917 v -3.70416 c 0,-0.29369 -0.23548,-0.52917 -0.52917,-0.52917 h -3.70416 m 0,0.52917 h 3.70416 V 152.4 h -3.70416 v -3.70416"
id="path1"
style="fill:#ababab;fill-opacity:1;stroke-width:0.264583"
sodipodi:nodetypes="cssssssscccccc" /><path
d="m 109.00833,149.225 v 0.52917 h 2.64584 V 149.225 h -2.64584 m 0,1.05834 v 0.52916 h 2.64584 v -0.52916 h -2.64584 m 0,1.05833 v 0.52917 h 1.85209 v -0.52917 z"
id="path2"
style="fill:#c7c7c7;fill-opacity:1;stroke-width:0.264583"
sodipodi:nodetypes="ccccccccccccccc" /><title
id="title1-9">delete-circle</title><g
id="g1"
transform="matrix(1.6249303,0,0,1.6249427,-68.307567,-93.38766)"><rect
style="fill:#ffffff;stroke:none;stroke-width:0.79375"
id="rect1"
width="1.5817325"
height="1.8579081"
x="110.28522"
y="150.33034" /><path
d="m 111.0761,149.95667 c 0.72034,0 1.30261,0.58227 1.30261,1.30262 0,0.72035 -0.58227,1.3026 -1.30261,1.3026 -0.72035,0 -1.30263,-0.58225 -1.30263,-1.3026 0,-0.72035 0.58227,-1.30262 1.30263,-1.30262 m 0.6513,0.65131 h -0.32566 l -0.13026,-0.13026 h -0.39078 l -0.13027,0.13026 h -0.32565 v 0.26052 h 1.30262 v -0.26052 m -1.0421,1.43288 h 0.78157 a 0.13026143,0.13026143 0 0 0 0.13026,-0.13026 v -0.91184 h -1.04209 v 0.91184 a 0.13026143,0.13026143 0 0 0 0.13026,0.13026 z"
id="path1-1"
style="fill:#aa0000;stroke-width:0.130261" /></g></g></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="24.000015"
height="23.999998"
viewBox="0 0 6.350004 6.3499995"
version="1.1"
id="svg1"
sodipodi:docname="leaf_orphaned.svg"
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)"
xml:space="preserve"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="22.627417"
inkscape:cx="3.2924659"
inkscape:cy="15.600543"
inkscape:window-width="1916"
inkscape:window-height="1161"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1"
showgrid="false" /><defs
id="defs1" /><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-107.95,-148.16667)"><title
id="title1">folder-open</title><title
id="title1-1">folder-open-outline</title><title
id="title1-5">notebook-outline</title><title
id="title1-8">text-box-outline</title><path
style="fill:#ffffff;stroke-width:0.264583"
d="m 108.3015,148.56838 h 3.95851 v 3.96688 h -3.95851 z"
id="path3"
sodipodi:nodetypes="ccccc" /><path
d="m 108.47917,148.16667 c -0.29369,0 -0.52917,0.23548 -0.52917,0.52917 V 152.4 c 0,0.29369 0.23548,0.52917 0.52917,0.52917 h 3.70416 c 0.29369,0 0.52917,-0.23548 0.52917,-0.52917 v -3.70416 c 0,-0.29369 -0.23548,-0.52917 -0.52917,-0.52917 h -3.70416 m 0,0.52917 h 3.70416 V 152.4 h -3.70416 v -3.70416"
id="path1"
style="fill:#ababab;fill-opacity:1;stroke-width:0.264583"
sodipodi:nodetypes="cssssssscccccc" /><path
d="m 109.00833,149.225 v 0.52917 h 2.64584 V 149.225 h -2.64584 m 0,1.05834 v 0.52916 h 2.64584 v -0.52916 h -2.64584 m 0,1.05833 v 0.52917 h 1.85209 v -0.52917 z"
id="path2"
style="fill:#c7c7c7;fill-opacity:1;stroke-width:0.264583"
sodipodi:nodetypes="ccccccccccccccc" /><title
id="title1-9">delete-circle</title><title
id="title1-53">ghost</title><path
d="m 112.39499,150.28334 a 1.9050024,1.9050024 0 0 0 -1.905,1.905 v 2.32833 l 0.635,-0.635 0.635,0.635 0.635,-0.635 0.635,0.635 0.635,-0.635 0.63501,0.635 v -2.32833 a 1.9050024,1.9050024 0 0 0 -1.90501,-1.905 m -0.635,1.27 a 0.42333387,0.42333387 0 0 1 0.42334,0.42333 0.42333387,0.42333387 0 0 1 -0.42334,0.42334 0.42333387,0.42333387 0 0 1 -0.42333,-0.42334 0.42333387,0.42333387 0 0 1 0.42333,-0.42333 m 1.27,0 a 0.42333387,0.42333387 0 0 1 0.42334,0.42333 0.42333387,0.42333387 0 0 1 -0.42334,0.42334 0.42333387,0.42333387 0 0 1 -0.42333,-0.42334 0.42333387,0.42333387 0 0 1 0.42333,-0.42333 z"
id="path1-5"
style="fill:#005190;fill-opacity:1;stroke-width:0.264583" /></g></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -1,7 +1,7 @@
export class API { export class API {
// query resolves into the JSON data produced by the application, or an exception with 'type' and 'error' properties. // query resolves into the JSON data produced by the application, or an exception with 'type' and 'error' properties.
static async query(method, path, request) { static async query(method, path, request) {
return new Promise((resolve, reject) => { try {
const body = JSON.stringify(request) const body = JSON.stringify(request)
const headers = {} const headers = {}
@ -12,33 +12,22 @@ export class API {
headers.Authorization = `Bearer ${token}` headers.Authorization = `Bearer ${token}`
} }
fetch(path, { method, headers, body }) const res = await fetch(path, { method, headers, body })
.then(response => {
// An HTTP communication level error occured. // An HTTP communication level error occured.
if (!response.ok || response.status != 200) if (!res.ok || res.status != 200)
return reject({ throw new Error('HTTP error', { cause: { type: 'http', error: res, }})
type: 'http',
error: response,
})
return response.json()
})
.then(json => {
// Application level response are handled here. // Application level response are handled here.
const json = await res.json()
if (!json.OK) if (!json.OK)
return reject({ throw new Error(json.Error, { cause: { type: 'application', application: json, }})
type: 'application',
error: json.Error, return json
application: json,
}) } catch (err) {
resolve(json)
})
.catch(err =>
// Catch any other errors from fetch. // Catch any other errors from fetch.
reject({ throw new Error(err.message, { cause: { type: 'http', error: err, }})
type: 'http', }
error: err,
}))
})
} }
static hasAuthenticationToken() {//{{{ static hasAuthenticationToken() {//{{{

View file

@ -2,51 +2,61 @@ import { ROOT_NODE } from 'node_store'
import { CustomHTMLElement } from './lib/custom_html_element.mjs' import { CustomHTMLElement } from './lib/custom_html_element.mjs'
import { N2Sidebar } from 'sidebar' import { N2Sidebar } from 'sidebar'
import { Node } from 'node' import { Node } from 'node'
import { N2PreferenceSet } from './page_preferences.mjs'
export class App { export class App {
static PAGES = ['node', 'history', 'storage']
constructor() {// {{{ constructor() {// {{{
this.currentNode = null this.currentNode = null
this.sidebar = new N2Sidebar() this.sidebar = new N2Sidebar()
this.crumbs = new N2Crumbs() this.crumbs = new N2Crumbs()
this.crumbsElement = document.getElementById('crumbs') this.crumbsElement = document.getElementById('crumbs')
this.nodeUI = document.getElementById('note') this.nodeUI = document.getElementById('note')
this.dragIcon = new N2DragIcon()
this.preferences = this.getPreferences()
this.sidebar.render().then(sidebar => { this.sidebar.render().then(sidebar => {
document.getElementById('tree').append(sidebar) document.getElementById('tree').append(sidebar)
document.getElementById('tree-nodes')?.focus() 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 () => { _mbus.subscribe('TREE_RENDERED', async () => {
// Subscribing to the start node existing after the tree trunk is // Subscribing to the start node existing after the tree trunk is
// fetched since the NODE_COMPONENT_EXIST message isn't sent for the // 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 // 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. // after it is rendered when the site is shown without UUID in the URL.
const startNode = await this.getStartNode() const startNode = await this.getStartNode()
determineNodePage(startNode.UUID)
if (startNode.UUID == ROOT_NODE)
this.goToNode(startNode.UUID, false, false)
else
this.goToNode(startNode.UUID, false, false) this.goToNode(startNode.UUID, false, false)
}) })
_mbus.subscribe('TREE_NODE_SELECTED', event => { _mbus.subscribe('TREE_NODE_SELECTED', event => {
const node = event.detail.data const node = event.detail.data
determineNodePage(node.UUID)
this.goToNode(node.UUID, false, false) this.goToNode(node.UUID, false, false)
}) })
_mbus.subscribe('GO_TO_NODE', event => { _mbus.subscribe('GO_TO_NODE', event => {
const node = event.detail.data const node = event.detail.data
determineNodePage(node.nodeUUID)
this.goToNode(node.nodeUUID, node.dontPush, node.dontExpand) this.goToNode(node.nodeUUID, node.dontPush, node.dontExpand)
}) })
_mbus.subscribe('SHOW_PAGE', ({ detail: { data: { page } } }) => { _mbus.subscribe('SHOW_PAGE', ({ detail: { data: { page } } }) => {
let classList = document.querySelector('#main-page').classList const classList = document.getElementById('notes2').classList
classList.forEach(e =>
classList.remove(e)
)
classList.add(page)
classList = document.querySelector('#notes2').classList
classList.forEach(e => { classList.forEach(e => {
if (e.startsWith('page-')) if (e.startsWith('page-'))
classList.remove(e) classList.remove(e)
@ -54,6 +64,11 @@ export class App {
classList.add('page-' + page) classList.add('page-' + page)
}) })
_mbus.subscribe('DEVICE_PREFERENCE_SET_UPDATED', ()=>{
this.preferences = this.getPreferences()
console.log(this.preferences.data)
})
window.addEventListener('keydown', event => this.keyHandler(event)) window.addEventListener('keydown', event => this.keyHandler(event))
window.addEventListener('popstate', event => this.popState(event)) window.addEventListener('popstate', event => this.popState(event))
document.getElementById('notes2').addEventListener('click', event => { document.getElementById('notes2').addEventListener('click', event => {
@ -61,6 +76,9 @@ export class App {
document.getElementById('node-content')?.focus() 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' }) _mbus.dispatch('SHOW_PAGE', { page: 'node' })
window._sync = new Sync() window._sync = new Sync()
@ -70,65 +88,52 @@ export class App {
// There a slight delay to initiate sync seems reasonable. // There a slight delay to initiate sync seems reasonable.
setTimeout(() => window._sync.run(), 1000) setTimeout(() => window._sync.run(), 1000)
}// }}} }// }}}
keyHandler(event) {//{{{ keyHandler(event) {//{{{
let handled = true 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. // 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. // Thus, the exception is acceptable to consequent use of alt+shift.
if (!(event.shiftKey && event.altKey) && !(event.key.toUpperCase() === 'S' && event.ctrlKey)) const CTRL = !event.shiftKey && event.ctrlKey && !event.altKey
return const SHIFT_ALT = event.shiftKey && !event.ctrlKey && event.altKey
const SHIFT_CTRL_ALT = event.shiftKey && event.ctrlKey && event.altKey
switch (event.key.toUpperCase()) { switch (event.key.toUpperCase()) {
case 'F2':
this.nodeUI.renameNode()
break
case 'T': case 'T':
if (document.activeElement.id === 'tree-nodes') { if (!SHIFT_ALT) { handled = false; break }
if (document.activeElement.id === 'tree-nodes')
this.nodeUI.takeFocus() this.nodeUI.takeFocus()
} else { else
this.sidebar.focus() this.sidebar.focus()
}
break break
case 'F': case 'F':
if (!SHIFT_ALT) { handled = false; break }
_mbus.dispatch('op-search') _mbus.dispatch('op-search')
break break
/*
case 'C':
this.showPage('node')
break
case 'E':
this.showPage('keys')
break
*/
case 'M': case 'M':
if (!SHIFT_ALT) { handled = false; break }
globalThis._mbus.dispatch('MARKDOWN_TOGGLE') globalThis._mbus.dispatch('MARKDOWN_TOGGLE')
break break
case 'N': case 'N':
if (SHIFT_ALT)
this.createNode() this.createNode()
else if (SHIFT_CTRL_ALT) {
this.createNode(this.currentNode?.ParentUUID)
} else {
handled = false
}
break break
/*
case 'P':
this.showPage('node-properties')
break
*/
case 'S': case 'S':
if (!CTRL) { handled = false; break }
this.nodeUI.saveNode() this.nodeUI.saveNode()
break break
/*
case 'U':
this.showPage('upload')
break
case 'F':
this.showPage('search')
break
*/
default: default:
handled = false handled = false
@ -155,17 +160,26 @@ export class App {
async saveNode() {//{{{ async saveNode() {//{{{
}//}}} }//}}}
async createNode() {//{{{ async moveNode(node, targetNodeUUID) {// {{{
let name = prompt("Name") 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) if (!name)
return return
const nn = Node.create(name, this.currentNode.UUID) const nn = Node.create(name, parentUUID)
nn.save() await nn.save()
nodeStore.sendQueue.add(nn)
nodeStore.add([nn])
// Treenode is forcefully rerendered and children refetched to both show the new node
// and to get it resorted.
const parentTreenode = this.sidebar.getTreeNode(parentUUID)
await parentTreenode.render(true, true)
_mbus.dispatch('GO_TO_NODE', { nodeUUID: nn.UUID })
}//}}} }//}}}
async goToNode(nodeUUID, dontPush, dontExpand) {//{{{ async goToNode(nodeUUID, dontPush, dontExpand) {//{{{
if (nodeUUID === null || nodeUUID === undefined) if (nodeUUID === null || nodeUUID === undefined)
@ -199,11 +213,18 @@ export class App {
_mbus.dispatch('NODE_UI_OPEN', node) _mbus.dispatch('NODE_UI_OPEN', node)
_mbus.dispatch('TREE_EXPANSION', { expand: false, when: 'narrow' }) _mbus.dispatch('TREE_EXPANSION', { expand: false, when: 'narrow' })
_mbus.dispatch('NODE_UNMODIFIED') _mbus.dispatch('NODE_UNMODIFIED')
_mbus.dispatch('SHOW_PAGE', { page: 'node' })
}//}}} }//}}}
pageIsVisible(page) {// {{{ pageIsVisible(page) {// {{{
let classList = document.querySelector('#main-page').classList let classList = document.querySelector('#main-page').classList
return classList.contains(page) 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 { class N2Crumbs extends CustomHTMLElement {
@ -237,7 +258,6 @@ class N2Crumbs extends CustomHTMLElement {
return this return this
}// }}} }// }}}
} }
customElements.define('n2-crumbs', N2Crumbs)
class N2Crumb extends CustomHTMLElement { class N2Crumb extends CustomHTMLElement {
static {// {{{ static {// {{{
@ -268,7 +288,6 @@ class N2Crumb extends CustomHTMLElement {
this.elLink.addEventListener('click', () => _mbus.dispatch("GO_TO_NODE", { nodeUUID: this.uuid, dontPush: false, dontExpand: true })) this.elLink.addEventListener('click', () => _mbus.dispatch("GO_TO_NODE", { nodeUUID: this.uuid, dontPush: false, dontExpand: true }))
}// }}} }// }}}
} }
customElements.define('n2-crumb', N2Crumb)
function tmpl(html) {// {{{ function tmpl(html) {// {{{
const el = document.createElement('template') const el = document.createElement('template')
@ -342,4 +361,52 @@ class OpSearch extends Op {
}// }}} }// }}}
} }
class N2DragIcon extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<style>
:host {
display: none;
position: absolute;
z-index: 16384;
pointer-events: none;
}
</style>
<img data-el="icon" src="/images/${_VERSION}/icon_drag.svg">
`
}// }}}
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 // vim: foldmethod=marker

View file

@ -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 { export class CustomHTMLElement extends HTMLElement {
constructor(useShadow) {// {{{ constructor(useShadow) {// {{{
super() super()
this._fields = new Map()
const workOn = useShadow ? this.attachShadow({ mode: 'open' }) : this const workOn = useShadow ? this.attachShadow({ mode: 'open' }) : this
workOn.appendChild(this.constructor.tmpl.content.cloneNode(true)) workOn.appendChild(this.constructor.tmpl.content.cloneNode(true))
workOn.querySelectorAll('*').forEach(el => { workOn.querySelectorAll('*').forEach(el => {
@ -9,6 +19,7 @@ export class CustomHTMLElement extends HTMLElement {
if (field !== undefined) { if (field !== undefined) {
const fieldName = this.toElementName('field', field) const fieldName = this.toElementName('field', field)
this[fieldName] = el this[fieldName] = el
this._fields.set(this.toElementName('', field), el)
} }
const name = el.dataset.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) {// {{{ toElementName(prefix, str) {// {{{
str = prefix + '-' + str str = prefix + '-' + str
return str.replace(/-(id|[a-z])/g, match => match.toUpperCase().replace('-', '')) 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)
}// }}}
}

View file

@ -92,7 +92,9 @@ function escapeHtmlEntities(html, encode) {// {{{
export class MarkedPosition { export class MarkedPosition {
constructor() {// {{{ 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() this.render()
}// }}} }// }}}
setpos(event) {// {{{ 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() {// {{{ render() {// {{{
const markedObject = this
this.marked = new Marked() this.marked = new Marked()
this.marked.use(markedTokenPosition()) this.marked.use(markedTokenPosition())
this.marked.use({ this.marked.use({
renderer: { renderer: {
heading(token) { heading(token) {
const content = this.parser.parseInline(token.tokens) const content = this.parser.parseInline(token.tokens)
return `<h${token.depth} ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${content}</h${token.depth}>\n` return `
<div class="heading-container" data-heading="${token.depth}">
<h${token.depth} ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${content}</h${token.depth}>\n
<div class="line"></div>\n
</div>
`
}, },
paragraph(token) { paragraph(token) {
const content = this.parser.parseInline(token.tokens) const content = this.parser.parseInline(token.tokens)
return `<p ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${content}</p>\n` return `<p ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${content}</p>\n`
}, },
list(token) { list(token) {
@ -138,7 +181,7 @@ export class MarkedPosition {
}, },
listitem(token) { listitem(token) {
return `<li ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${this.parser.parse(token.tokens)}</li>\n` return `<li ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${this.parser.parse(token.tokens)}</li>\n`
}, },
code(token) { code(token) {
@ -147,12 +190,12 @@ export class MarkedPosition {
const code = token.text.replace(other.endingNewline, '') + '\n' const code = token.text.replace(other.endingNewline, '') + '\n'
if (!langString) { if (!langString) {
return `<pre ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"><code>` return `<pre ondblclick="marked_setpos(event)" onmousedown="marked_copy_to_clipboard(event, 'pre')" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"><code>`
+ (token.escaped ? code : escapeHtmlEntities(code, true)) + (token.escaped ? code : escapeHtmlEntities(code, true))
+ '</code></pre>\n' + '</code></pre>\n'
} }
return `<pre ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"><code class="language-` return `<pre ondblclick="marked_setpos(event)" onmousedown="marked_copy_to_clipboard(event, 'pre')" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"><code class="language-`
+ escapeHtmlEntities(langString) + escapeHtmlEntities(langString)
+ '">' + '">'
+ (token.escaped ? code : escapeHtmlEntities(code, true)) + (token.escaped ? code : escapeHtmlEntities(code, true))
@ -161,7 +204,7 @@ export class MarkedPosition {
blockquote(token) { blockquote(token) {
const body = this.parser.parse(token.tokens) const body = this.parser.parse(token.tokens)
return `<blockquote ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">\n${body}</blockquote>\n` return `<blockquote ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">\n${body}</blockquote>\n`
}, },
html(token) { html(token) {
@ -173,13 +216,13 @@ export class MarkedPosition {
}, },
hr(token) { hr(token) {
return `<hr ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">\n` return `<hr ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">\n`
}, },
checkbox(token) { checkbox(token) {
return `<input ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"` return `<input ondblclick="marked_setpos(event)" onchange="marked_changecheckbox(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"`
+ (token.checked ? 'checked="" ' : '') + (token.checked ? 'checked="" ' : '')
+ 'disabled="" type="checkbox"> ' + 'type="checkbox"> '
}, },
table(token) { table(token) {
@ -222,7 +265,7 @@ export class MarkedPosition {
if (token.tokens.length > 0) { if (token.tokens.length > 0) {
const start = token.tokens[0].position.start.offset const start = token.tokens[0].position.start.offset
const end = token.tokens[0].position.end.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); const content = this.parser.parseInline(token.tokens);
@ -234,23 +277,23 @@ export class MarkedPosition {
}, },
strong(token) { strong(token) {
return `<strong ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${this.parser.parseInline(token.tokens)}</strong>` return `<strong ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${this.parser.parseInline(token.tokens)}</strong>`
}, },
em(token) { em(token) {
return `<em ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${this.parser.parseInline(token.tokens)}</em>` return `<em ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${this.parser.parseInline(token.tokens)}</em>`
}, },
codespan(token) { codespan(token) {
return `<code ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${escapeHtmlEntities(token.text, true)}</code>` return `<code ondblclick="marked_setpos(event)" onmousedown="marked_copy_to_clipboard(event, 'code')" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${escapeHtmlEntities(token.text, true)}</code>`
}, },
br(token) { br(token) {
return `<br ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">` return `<br ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">`
}, },
del(token) { del(token) {
return `<del ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${this.parser.parseInline(token.tokens)}</del>` return `<del ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${this.parser.parseInline(token.tokens)}</del>`
}, },
link(token) { link(token) {
@ -260,7 +303,7 @@ export class MarkedPosition {
return text return text
} }
token.href = cleanHref token.href = cleanHref
let out = '<a ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}" href="' + token.href + '"' let out = '<a ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}" href="' + token.href + '"'
if (token.title) { if (token.title) {
out += ' title="' + (escapeHtmlEntities(token.title)) + '"' out += ' title="' + (escapeHtmlEntities(token.title)) + '"'
} }
@ -277,7 +320,7 @@ export class MarkedPosition {
return escapeHtmlEntities(token.text) return escapeHtmlEntities(token.text)
} }
token.href = cleanHref token.href = cleanHref
let out = `<n2-file ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}" src="${token.href}" alt="${escapeHtmlEntities(token.text)}"` let out = `<n2-file ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}" src="${token.href}" alt="${escapeHtmlEntities(token.text)}"`
if (token.title) { if (token.title) {
out += ` title="${escapeHtmlEntities(token.title)}"` out += ` title="${escapeHtmlEntities(token.title)}"`
} }

View file

@ -1,6 +1,8 @@
import { Node } from 'node' import { Node } from 'node'
export const ROOT_NODE = '00000000-0000-0000-0000-000000000000' export const ROOT_NODE = '00000000-0000-0000-0000-000000000000'
export const ORPHANED_NODE = '00000000-0000-0000-0000-000000000001'
export const DELETED_NODE = '00000000-0000-0000-0000-000000000002'
export class NodeStore { export class NodeStore {
constructor() {//{{{ constructor() {//{{{
@ -13,6 +15,8 @@ export class NodeStore {
this.sendQueue = null this.sendQueue = null
this.nodesHistory = null this.nodesHistory = null
this.files = null this.files = null
this.initializeSpecialNodes()
}//}}} }//}}}
initializeDB() {//{{{ initializeDB() {//{{{
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -57,7 +61,7 @@ export class NodeStore {
break break
case 6: case 6:
nodesHistory = db.createObjectStore('nodes_history', { keyPath: ['UUID', 'Updated'] }) nodesHistory = db.createObjectStore('nodes_history', { keyPath: ['UUID', 'HistoryUUID'] })
break break
case 7: case 7:
@ -76,8 +80,7 @@ export class NodeStore {
this.sendQueue = new SimpleNodeStore(this.db, 'send_queue') this.sendQueue = new SimpleNodeStore(this.db, 'send_queue')
this.nodesHistory = new NodeHistoryStore(this.db, 'nodes_history') this.nodesHistory = new NodeHistoryStore(this.db, 'nodes_history')
this.files = new SimpleNodeStore(this.db, 'files') this.files = new SimpleNodeStore(this.db, 'files')
this.initializeRootNode() resolve()
.then(() => resolve())
} }
req.onerror = (event) => { req.onerror = (event) => {
@ -85,40 +88,11 @@ export class NodeStore {
} }
}) })
}//}}} }//}}}
initializeRootNode() {//{{{ initializeSpecialNodes() {// {{{
return new Promise((resolve, reject) => { this.nodes[ROOT_NODE] = new Node({ UUID: ROOT_NODE, Name: 'Start', Special: true }, -1)
// The root node is a magical node which displays as the first node if none is specified. this.nodes[DELETED_NODE] = new Node({ UUID: DELETED_NODE, Name: 'Deleted nodes', Special: true }, -1)
// If not already existing, it will be created. this.nodes[ORPHANED_NODE] = new Node({ UUID: ORPHANED_NODE, Name: 'Orphaned nodes', Special: true }, -1)
const trx = this.db.transaction('nodes', 'readwrite') }// }}}
const nodes = trx.objectStore('nodes')
const getRequest = nodes.get(ROOT_NODE)
getRequest.onsuccess = (event) => {
// Root node exists - nice!
if (event.target.result !== undefined) {
resolve(event.target.result)
return
}
const putRequest = nodes.put({
UUID: ROOT_NODE,
Name: 'Notes2',
Content: 'Hello, World!',
Updated: new Date().toISOString(),
ParentUUID: '',
})
putRequest.onsuccess = (event) => {
resolve(event.target.result)
}
putRequest.onerror = (event) => {
reject(event.target.error)
}
}
getRequest.onerror = (event) => reject(event.target.error)
})
}//}}}
purgeCache() {//{{{
this.nodes = {}
}//}}}
node(uuid, dataIfUndefined, newLevel) {//{{{ node(uuid, dataIfUndefined, newLevel) {//{{{
let n = this.nodes[uuid] let n = this.nodes[uuid]
@ -247,6 +221,7 @@ export class NodeStore {
nodeStore = t.objectStore('nodes') nodeStore = t.objectStore('nodes')
t.oncomplete = (_event) => { t.oncomplete = (_event) => {
console.log('complete')
resolve() resolve()
} }
@ -271,6 +246,14 @@ export class NodeStore {
}//}}} }//}}}
get(uuid, suppliedNodestore) {//{{{ get(uuid, suppliedNodestore) {//{{{
return new Promise((resolve, reject) => { 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 // A nodestore can be provided in order to
// avoid creating new transactions. // avoid creating new transactions.
let trx let trx
@ -308,6 +291,16 @@ export class NodeStore {
return 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) const getRequest = nodeParentIndex.get(node.ParentUUID)
getRequest.onsuccess = (event) => { getRequest.onsuccess = (event) => {
// Node not found in IndexedDB. // Node not found in IndexedDB.
@ -358,6 +351,7 @@ class SimpleNodeStore {
// Node to be moved is first stored in the new queue. // Node to be moved is first stored in the new queue.
const req = store.put(node.data) const req = store.put(node.data)
req.onsuccess = () => { req.onsuccess = () => {
console.log('here')
resolve() resolve()
} }
req.onerror = (event) => { req.onerror = (event) => {
@ -473,11 +467,15 @@ class NodeHistoryStore extends SimpleNodeStore {
}// }}} }// }}}
retrievePage(uuid, perPage, page) {// {{{ retrievePage(uuid, perPage, page) {// {{{
return new Promise((resolve, _reject) => { 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 const cursor = this.db
.transaction(['nodes', this.storeName], 'readonly') .transaction(['nodes', this.storeName], 'readonly')
.objectStore(this.storeName) .objectStore(this.storeName)
.index('byUUID') .openCursor(range, 'prev')
.openCursor(uuid, 'prev')
let retrieved = 0 let retrieved = 0
let first = true let first = true

View file

@ -2,21 +2,86 @@ import { ROOT_NODE, uuidv7 } from 'node_store'
import { CustomHTMLElement } from './lib/custom_html_element.mjs' import { CustomHTMLElement } from './lib/custom_html_element.mjs'
import { MarkedPosition } from './marked_position.mjs' import { MarkedPosition } from './marked_position.mjs'
class N2NodeMenu extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<style>
n2-nodemenu {
margin: 8px 0;
padding: 0;
position-anchor: --node-menu;
box-shadow: rgba(0, 0, 0, 0.05) 0px 6px 24px 0px, rgba(0, 0, 0, 0.08) 0px 0px 0px 1px;
top: anchor(bottom);
right: anchor(right);
left: auto;
white-space: nowrap;
.menu-item {
padding: 8px 16px 8px 8px;
border-bottom: 1px solid var(--line-color);
display: grid;
grid-template-columns: min-content 1fr;
grid-gap: 16px;
align-items: center;
&:last-child {
border-bottom: unset;
}
&:hover {
background-color: var(--menu-item-hover-color);
}
}
}
</style>
<div class="node-menu">
<div class="menu-item" data-el="format-tables">
<img class="colorize" src="/images/${_VERSION}/icon_table.svg">
<div>Format tables</div>
</div>
<div class="menu-item" data-el="history">
<img class="colorize" src="/images/${_VERSION}/icon_history.svg">
<div>History</div>
</div>
</div>
`
}// }}}
constructor() {// {{{
super()
}// }}}
}
customElements.define('n2-nodemenu', N2NodeMenu)
export class N2PageNodeUI extends CustomHTMLElement { export class N2PageNodeUI extends CustomHTMLElement {
static {// {{{ static {// {{{
this.tmpl = document.createElement('template') this.tmpl = document.createElement('template')
this.tmpl.innerHTML = ` this.tmpl.innerHTML = `
<style> <style>
.el-functions { n2-nodeui > .el-functions {
display: grid; display: grid;
grid-template-columns: 1fr repeat(3, min-content); grid-template-columns: 1fr repeat(3, min-content);
grid-gap: 8px; grid-gap: 8px;
align-items: center; align-items: center;
justify-items: end; justify-items: end;
cursor: pointer;
img { img {
height: 24px; height: 24px;
} }
.el-menu {
anchor-name: --node-menu;
border: 0;
padding: 0;
background-color: unset;
}
} }
</style> </style>
<div data-el="name"></div> <div data-el="name"></div>
@ -27,9 +92,13 @@ export class N2PageNodeUI extends CustomHTMLElement {
<div data-el="functions"> <div data-el="functions">
<img data-el="icon-save" src="/images/${_VERSION}/icon_save_disabled.svg"> <img data-el="icon-save" src="/images/${_VERSION}/icon_save_disabled.svg">
<img data-el="icon-markdown"> <img data-el="icon-markdown">
<img data-el="icon-table-format" class="colorize" src="/images/${_VERSION}/icon_table.svg"> <img data-el="icon-new-document" class="colorize" src="/images/${_VERSION}/icon_new_document.svg">
<img data-el="icon-history" class="colorize" src="/images/${_VERSION}/icon_history.svg"> <button data-el="menu" popovertarget="node-functions-menu">
<img data-el="icon-menu" class="colorize" src="/images/${_VERSION}/icon_menu.svg">
</button>
<n2-nodemenu data-el="node-menu" id="node-functions-menu" popover></n2-nodemenu>
</div> </div>
` `
}// }}} }// }}}
@ -38,11 +107,13 @@ export class N2PageNodeUI extends CustomHTMLElement {
this.node = null this.node = null
this.style.display = 'contents' this.style.display = 'contents'
this.classList.add('show-markdown') // TODO Should probably be moved to settings.
this.marked = new MarkedPosition() this.marked = new MarkedPosition()
_mbus.subscribe('NODE_UI_OPEN', event => { _mbus.subscribe('NODE_UI_OPEN', event => {
this.node = event.detail.data this.node = event.detail.data
if (!this.node.isSpecial())
this.showMarkdown(true) this.showMarkdown(true)
this.render() this.render()
}) })
@ -62,23 +133,29 @@ export class N2PageNodeUI extends CustomHTMLElement {
_mbus.subscribe('MARKDOWN_TOGGLE', () => this.showMarkdown(!this.showMarkdown())) _mbus.subscribe('MARKDOWN_TOGGLE', () => this.showMarkdown(!this.showMarkdown()))
_mbus.subscribe('MARKDOWN_EDIT', ({ detail }) => this.editMarkdown(detail.data)) _mbus.subscribe('MARKDOWN_EDIT', ({ detail }) => this.editMarkdown(detail.data))
_mbus.subscribe('MARKDOWN_CHANGE_CHECKBOX', ({ detail }) => this.checkboxUpdated(detail.data))
this.elName.addEventListener('click', () => { // Binding the node rename handler.
const name = prompt('Change title', this.node.data.Name) this.elName.addEventListener('click', async () => this.renameNode())
if (name === null)
return
try { // Bind handlers for content keyboard input and paste.
this.node.setName(name)
} catch (err) {
console.error(err)
alert(err)
}
})
this.elNodeContent.addEventListener('input', event => this.contentChanged(event)) this.elNodeContent.addEventListener('input', event => this.contentChanged(event))
this.elNodeContent.addEventListener('paste', async (event) => this.pasteHandler(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.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) if (!event.shiftKey)
this.elNodeContent.value = this.formatAllTables(this.elNodeContent.value) this.elNodeContent.value = this.formatAllTables(this.elNodeContent.value)
else { else {
@ -92,9 +169,12 @@ export class N2PageNodeUI extends CustomHTMLElement {
this.node.setContent(this.elNodeContent.value) this.node.setContent(this.elNodeContent.value)
}) })
this.elIconHistory.addEventListener('click', () => _mbus.dispatch('SHOW_PAGE', { page: 'history' })) this.elNodeMenu.elHistory.addEventListener('click', () => {
this.elIconSave.addEventListener('click', ()=>this.saveNode()) _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) this.showMarkdown(true)
}// }}} }// }}}
renderName() {// {{{ renderName() {// {{{
@ -111,35 +191,37 @@ export class N2PageNodeUI extends CustomHTMLElement {
} else } else
this.elNodeContent.focus({ preventScroll: true }) 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() {// {{{ async saveNode() {// {{{
if (!this.node.isModified()) if (!this.node.isModified())
return return
/* The node history is a local store for node history. // node.save takes care of both "nodes" and "nodes_history" stores, also adds it to send queue.
* This could be provisioned from the server or cleared if // Sets "Updated" value to current date and time and generates a new history UUID.
* deemed unnecessary.
*
* The send queue is what will be sent back to the server
* to have a recorded history of the notes.
*
* A setting to be implemented in the future could be to
* not save the history locally at all. */
// The node is still in its old state and will present
// the unmodified content to the node store.
const history = nodeStore.nodesHistory.add(this.node)
// Prepares the node object for saving.
// Sets Updated value to current date and time.
await this.node.save() await this.node.save()
// Updated node is added to the send queue to be stored on server.
const sendQueue = nodeStore.sendQueue.add(this.node)
// Updated node is saved to the primary node store.
const nodeStoreAdding = nodeStore.add([this.node])
await Promise.all([history, sendQueue, nodeStoreAdding])
}// }}} }// }}}
contentChanged(event) {//{{{ contentChanged(event) {//{{{
@ -289,6 +371,30 @@ export class N2PageNodeUI extends CustomHTMLElement {
return lines 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) customElements.define('n2-nodeui', N2PageNodeUI)
@ -306,15 +412,20 @@ export class Node {
return 0 return 0
}//}}} }//}}}
static create(name, parentUUID) {// {{{ static create(name, parentUUID) {// {{{
return new Node({ const node = new Node({
UUID: uuidv7(), UUID: uuidv7(),
Created: (new Date()).toISOString(), Created: (new Date()).toISOString(),
Content: '', Content: '',
Name: name, Name: name,
ParentUUID: parentUUID, ParentUUID: parentUUID,
Markdown: false, 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) {//{{{ constructor(nodeData, level) {//{{{
@ -393,12 +504,23 @@ export class Node {
getParent() {//{{{ getParent() {//{{{
return this._parent 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() {//{{{ isLastSibling() {//{{{
return this._sibling_after === null return this._sibling_after === null
}//}}} }//}}}
isFirstSibling() {//{{{ isFirstSibling() {//{{{
return this._sibling_before === null return this._sibling_before === null
}//}}} }//}}}
isSpecial() {// {{{
return this.data.Special
}// }}}
content() {//{{{ content() {//{{{
/* TODO - implement crypto /* TODO - implement crypto
if (this.CryptoKeyID != 0 && !this._decrypted) if (this.CryptoKeyID != 0 && !this._decrypted)
@ -420,17 +542,52 @@ export class Node {
_mbus.dispatch('NODE_MODIFIED', { node: this }) _mbus.dispatch('NODE_MODIFIED', { node: this })
}// }}} }// }}}
async save() {//{{{ 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.Content = this._content
this.data.Updated = new Date().toISOString() 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 this._modified = false
_mbus.dispatch('NODE_UNMODIFIED') _mbus.dispatch('NODE_UNMODIFIED')
// When stored into database and ancestry was changed, // When stored into database and ancestry was changed,
// the ancestry path could be interesting. // the ancestry path could be interesting.
/*
const ancestors = await nodeStore.getNodeAncestry(this) const ancestors = await nodeStore.getNodeAncestry(this)
this.data.Ancestors = ancestors.map(a => a.get('Name')).reverse() 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 // vim: foldmethod=marker

View file

@ -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 = `
<style>
.el-sets {
display: grid;
grid-template-columns: min-content;
grid-gap: 32px;
}
:host > div {
margin-bottom: 32px;
}
.dev-pref-set {
display: grid;
grid-template-columns: min-content min-content;
grid-gap: 16px;
align-items: center;
white-space: nowrap;
}
</style>
<h1>Preferences</h1>
<div>Changes preferences to not download images or files on the device doesn't remove the already downloaded data.</div>
<div class="dev-pref-set">
<div>Device preference set</div>
<select data-el="dev-preference-set"></select>
</div>
<div data-el="sets"></div>
<button data-el="new-set">New set</button>
<button data-el="save" disabled>Save</button>
`
}// }}}
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 = `
<style>
:host {
border: 1px solid var(--line-color);
padding: 16px;
display: grid;
grid-template-columns: min-content 1fr;
justify-items: start;
align-items: center;
grid-gap: 8px 16px;
white-space: nowrap;
user-select: none;
.header {
grid-column: 1 / -1;
width: 100%;
display: grid;
grid-template-columns: 1fr min-content;
.el-name {
font-weight: bold;
margin-bottom: 32px;
cursor: pointer;
color: var(--color1);
}
.el-delete {
cursor: pointer;
}
}
}
</style>
<div class="header">
<div data-el="name"></div>
<div data-el="delete"></div>
</div>
<div><label for="download-images">Download images on device</label></div>
<input data-field="download-images" type="checkbox" id="download-images">
<div><label for="download-files">Download files on device</label></div>
<input data-field="download-files" type="checkbox" id="download-files">
`
}// }}}
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)

View file

@ -13,7 +13,10 @@ export class N2PageStorage extends CustomHTMLElement {
constructor() { constructor() {
super() super()
window._mbus.subscribe('SHOW_PAGE', () => this.render()) window._mbus.subscribe('SHOW_PAGE', event => {
if (event.detail.data?.page == 'storage')
this.render()
})
} }
async render() { async render() {
const countNodes = await globalThis.nodeStore.nodeCount() const countNodes = await globalThis.nodeStore.nodeCount()

View file

@ -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 { CustomHTMLElement } from './lib/custom_html_element.mjs'
import { Color, Solver } from './lib/css_colorize.mjs' import { Color, Solver } from './lib/css_colorize.mjs'
@ -117,7 +118,6 @@ export class N2Sidebar extends CustomHTMLElement {
this.tabIndex = 0 this.tabIndex = 0
this.treeNodeComponents = {} this.treeNodeComponents = {}
this.treeTrunk = []
this.expandedNodes = {} // keyed on UUID this.expandedNodes = {} // keyed on UUID
this.selectedNode = null this.selectedNode = null
this.rendered = false this.rendered = false
@ -128,6 +128,7 @@ export class N2Sidebar extends CustomHTMLElement {
this.elSearch.addEventListener('click', () => _mbus.dispatch('op-search')) this.elSearch.addEventListener('click', () => _mbus.dispatch('op-search'))
this.elSync.addEventListener('click', () => _sync.run()) this.elSync.addEventListener('click', () => _sync.run())
this.elLogo.addEventListener('click', () => _app.goToNode(ROOT_NODE, false, false)) this.elLogo.addEventListener('click', () => _app.goToNode(ROOT_NODE, false, false))
this.elSettings.addEventListener('click', ()=> _mbus.dispatch('SHOW_PAGE', { page: 'preferences' }))
this.elHideTree.addEventListener('click', event => { this.elHideTree.addEventListener('click', event => {
event.stopPropagation() event.stopPropagation()
_mbus.dispatch('TREE_EXPANSION', { expand: false }) _mbus.dispatch('TREE_EXPANSION', { expand: false })
@ -157,8 +158,26 @@ export class N2Sidebar extends CustomHTMLElement {
this.expandedNodes[ROOT_NODE] = true this.expandedNodes[ROOT_NODE] = true
const startnode = await nodeStore.get(ROOT_NODE) const startnode = await nodeStore.get(ROOT_NODE)
const starttreenode = new N2TreeNode(this, startnode, null) const starttreenode = new N2TreeNode(this, startnode, null)
const deletednode = await nodeStore.get(DELETED_NODE)
const deletedtreenode = new SpecialNodeDeleted(this, deletednode, null)
const orphanednode = await nodeStore.get(ORPHANED_NODE)
const orphanedtreenode = new SpecialNodeOrphaned(this, orphanednode, null)
startnode._sibling_after = deletednode
deletednode._sibling_before = startnode
deletednode._sibling_after = orphanednode
orphanednode._sibling_before = deletednode
this.treeNodeComponents[startnode.UUID] = starttreenode this.treeNodeComponents[startnode.UUID] = starttreenode
this.treeNodeComponents[deletednode.UUID] = deletedtreenode
this.treeNodeComponents[orphanednode.UUID] = orphanedtreenode
this.elTreenodes.appendChild(await starttreenode.render()) this.elTreenodes.appendChild(await starttreenode.render())
this.elTreenodes.appendChild(await deletedtreenode.render())
this.elTreenodes.appendChild(await orphanedtreenode.render())
// Notify the application that the initial tree is rendered (with children) // Notify the application that the initial tree is rendered (with children)
// and that initial node selection can take place. App will check URL to // and that initial node selection can take place. App will check URL to
@ -170,19 +189,17 @@ export class N2Sidebar extends CustomHTMLElement {
}// }}} }// }}}
reset() {// {{{ reset() {// {{{
this.treeNodeComponents = {} this.treeNodeComponents = {}
this.treeTrunk = []
this.rendered = false this.rendered = false
this.elTreenodes.replaceChildren() this.elTreenodes.replaceChildren()
this.populateFirstLevel() this.render()
}// }}} }// }}}
getNodeExpanded(UUID) {//{{{ getNodeExpanded(UUID) {//{{{
if (this.expandedNodes[UUID] === undefined) if (this.expandedNodes[UUID] === undefined)
this.expandedNodes[UUID] = false this.expandedNodes[UUID] = false
return this.expandedNodes[UUID] return this.expandedNodes[UUID]
}//}}} }//}}}
setNodeExpanded(node, value) {//{{{ async setNodeExpanded(node, value) {//{{{
let expanded = this.expandedNodes[node.UUID] let expanded = this.expandedNodes[node.UUID]
if (expanded === undefined) { if (expanded === undefined) {
this.expandedNodes[node.UUID] = false this.expandedNodes[node.UUID] = false
expanded = false expanded = false
@ -214,6 +231,9 @@ export class N2Sidebar extends CustomHTMLElement {
isSelected(node) {//{{{ isSelected(node) {//{{{
return this.selectedNode?.UUID === node.UUID return this.selectedNode?.UUID === node.UUID
}//}}} }//}}}
getTreeNode(uuid) {// {{{
return this.treeNodeComponents[uuid]
}// }}}
async keyHandler(event) {//{{{ async keyHandler(event) {//{{{
let handled = true let handled = true
@ -229,8 +249,6 @@ export class N2Sidebar extends CustomHTMLElement {
// Holding shift down does it recursively. // Holding shift down does it recursively.
case Space: case Space:
case 'Enter': case 'Enter':
if (n.UUID === ROOT_NODE)
return
const expanded = this.getNodeExpanded(n.UUID) const expanded = this.getNodeExpanded(n.UUID)
if (event.shiftKey) { if (event.shiftKey) {
this.recursiveExpand(n, !expanded) this.recursiveExpand(n, !expanded)
@ -239,38 +257,31 @@ export class N2Sidebar extends CustomHTMLElement {
} }
break break
case 'g':
case 'Home': case 'Home':
this.navigateTop() this.navigateTop()
break break
case 'G':
case 'End': case 'End':
this.navigateBottom() this.navigateBottom()
break break
case 'j':
case 'ArrowDown': case 'ArrowDown':
await this.navigateDown(this.selectedNode) await this.navigateDown(this.selectedNode)
break break
case 'k':
case 'ArrowUp': case 'ArrowUp':
await this.navigateUp(this.selectedNode) await this.navigateUp(this.selectedNode)
break break
case 'h':
case 'ArrowLeft': case 'ArrowLeft':
await this.navigateLeft(this.selectedNode) await this.navigateLeft(this.selectedNode)
break break
case 'l':
case 'ArrowRight': case 'ArrowRight':
await this.navigateRight(this.selectedNode) await this.navigateRight(this.selectedNode)
break break
default: default:
// nonsole.log(event.key)
handled = false handled = false
} }
@ -280,7 +291,7 @@ export class N2Sidebar extends CustomHTMLElement {
} }
}//}}} }//}}}
async navigateLeft(n) {//{{{ async navigateLeft(n) {//{{{
if (n === null || n === undefined) if (n === null || n === undefined || n.UUID == ROOT_NODE)
return return
const expanded = this.getNodeExpanded(n.UUID) const expanded = this.getNodeExpanded(n.UUID)
@ -330,7 +341,7 @@ export class N2Sidebar extends CustomHTMLElement {
_mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingAfter()?.UUID, dontPush: false, dontExpand: true }) _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingAfter()?.UUID, dontPush: false, dontExpand: true })
}//}}} }//}}}
async navigateUp(n) {//{{{ async navigateUp(n) {//{{{
if (n === null || n === undefined) if (n === null || n === undefined || n.UUID == ROOT_NODE)
return return
let parent = null let parent = null
@ -346,7 +357,8 @@ export class N2Sidebar extends CustomHTMLElement {
} }
if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) { if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) {
_mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingBefore.Children[siblingBefore.Children.length - 1]?.UUID, dontPush: false, dontExpand: true }) const nodeVisuallyAbove = this.getLastExpandedNode(siblingBefore)
_mbus.dispatch("GO_TO_NODE", { nodeUUID: nodeVisuallyAbove.UUID, dontPush: false, dontExpand: true })
return return
} }
@ -391,23 +403,26 @@ export class N2Sidebar extends CustomHTMLElement {
}//}}} }//}}}
async navigateTop() {//{{{ async navigateTop() {//{{{
const root = await nodeStore.get(ROOT_NODE) const root = await nodeStore.get(ROOT_NODE)
if (root.Children.length === 0) _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.UUID, dontPush: false, dontExpand: true })
return
_mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[0]?.UUID, dontPush: false, dontExpand: true })
}//}}} }//}}}
async navigateBottom() {//{{{ async navigateBottom() {//{{{
const root = await nodeStore.get(ROOT_NODE) const orphaned = await nodeStore.get(ORPHANED_NODE)
if (root.Children.length === 0)
return
const toplevel = root.Children[root.Children.length - 1] if (!orphaned.hasChildren() || this.getNodeExpanded(orphaned.UUID)) {
_mbus.dispatch("GO_TO_NODE", { nodeUUID: orphaned.UUID, dontPush: false, dontExpand: true })
return
}
/* TODO - fix this when orphaned nodes are implemented.
const toplevel = orphaned.Children[orphaned.Children.length - 1]
const toplevelExpanded = this.getNodeExpanded(toplevel?.UUID) const toplevelExpanded = this.getNodeExpanded(toplevel?.UUID)
if (toplevelExpanded) { if (toplevelExpanded) {
const lastnode = this.getLastExpandedNode(toplevel) const lastnode = this.getLastExpandedNode(toplevel)
_mbus.dispatch("GO_TO_NODE", { nodeUUID: lastnode?.UUID, dontPush: false, dontExpand: true }) _mbus.dispatch("GO_TO_NODE", { nodeUUID: lastnode?.UUID, dontPush: false, dontExpand: true })
} else } else
_mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[root.Children.length - 1]?.UUID, dontPush: false, dontExpand: true }) _mbus.dispatch("GO_TO_NODE", { nodeUUID: orphaned.Children[orphaned.Children.length - 1]?.UUID, dontPush: false, dontExpand: true })
*/
}//}}} }//}}}
getParentWithNextSibling(node) {//{{{ getParentWithNextSibling(node) {//{{{
@ -428,6 +443,10 @@ export class N2Sidebar extends CustomHTMLElement {
if (state) if (state)
await this.setNodeExpanded(node, true) await this.setNodeExpanded(node, true)
// An expanded node needs to have its children fetched.
if (!node.hasFetchedChildren())
await node.fetchChildren()
for (const child of node.Children) for (const child of node.Children)
await this.recursiveExpand(child, state) await this.recursiveExpand(child, state)
@ -447,15 +466,22 @@ export class N2Sidebar extends CustomHTMLElement {
treenode?.scrollIntoView({ block: 'nearest' }) treenode?.scrollIntoView({ block: 'nearest' })
}// }}} }// }}}
} }
customElements.define('n2-sidebar', N2Sidebar)
export class N2TreeNode extends CustomHTMLElement { export class N2TreeNode extends CustomHTMLElement {
static DRAG_ICON = new Image()
static DRAG_ICON_OK = new Image()
static {// {{{ static {// {{{
N2TreeNode.DRAG_ICON.src = `/images/${_VERSION}/leaf.svg`
N2TreeNode.DRAG_ICON_OK.src = `/images/${_VERSION}/expanded.svg`
this.tmpl = document.createElement('template') this.tmpl = document.createElement('template')
this.tmpl.innerHTML = ` this.tmpl.innerHTML = `
<style> <style>
n2-sidebar:focus-within { n2-sidebar:focus-within {
.el-name { n2-specialnodedeleted > .el-name,
n2-specialnodeorphaned > .el-name,
n2-treenode > .el-name {
&.selected { &.selected {
span { span {
position:relative; position:relative;
@ -476,10 +502,60 @@ export class N2TreeNode extends CustomHTMLElement {
} }
} }
} }
n2-treenode {
& > .el-name {
white-space: nowrap;
width: min-content;
}
&.drag-source {
& > .el-name {
position: relative;
}
& > .el-name:after {
position: absolute;
content: url('/images/${_VERSION}/icon_drag_source.svg');
filter: var(--colorize);
top: -1px;
right: -24px;
}
}
&.drag-target {
position: relative;
& > .el-name {
anchor-name: --name;
}
& > .el-name:after {
content: '';
position: absolute;
border: 2px dashed #888;
top: calc(anchor(--name top) - 12px);
right: calc(anchor(--name right) - 8px);
bottom: calc(anchor(--name bottom) - 8px);
left: calc(anchor(--name left) - 40px);
pointer-events: none;
}
& > .el-drag-icon {
display: block;
top: 0px;
left: 0px;
z-index: 16384;
}
}
}
</style> </style>
<div data-el="expand-toggle" class="expand-toggle"> <div data-el="expand-toggle" class="expand-toggle">
<img data-el="expand"> <img data-el="expand" draggable="false">
</div> </div>
<div data-el="name" class="name"><span></span></div> <div data-el="name" class="name"><span></span></div>
<div data-el="children" class="children"></div> <div data-el="children" class="children"></div>
@ -488,6 +564,7 @@ export class N2TreeNode extends CustomHTMLElement {
constructor(sidebar, node, parent) {//{{{ constructor(sidebar, node, parent) {//{{{
super() super()
this.setAttribute('draggable', 'true')
this.classList.add('node') this.classList.add('node')
this.sidebar = sidebar this.sidebar = sidebar
@ -496,13 +573,100 @@ export class N2TreeNode extends CustomHTMLElement {
this.children_populated = false this.children_populated = false
this.rendered = false this.rendered = false
this.dragNode = null
this.elExpandToggle.addEventListener('click', () => this.sidebar.setNodeExpanded(this.node, !this.sidebar.getNodeExpanded(this.node.UUID))) this.elExpandToggle.addEventListener('click', event => {
if (this.node.hasChildren())
this.expandNode(event)
else
_mbus.dispatch('TREE_NODE_SELECTED', this.node)
})
this.elName.addEventListener('click', () => _mbus.dispatch('TREE_NODE_SELECTED', this.node)) this.elName.addEventListener('click', () => _mbus.dispatch('TREE_NODE_SELECTED', this.node))
_mbus.subscribe(`NODE_EXPAND_${node.UUID}`, _state => { _mbus.subscribe(`NODE_EXPAND_${node.UUID}`, _state => {
this.render(true) this.render(true)
}) })
// Drag-and-dropping of nodes
this.addEventListener('dragstart', event => this.dragStart(event))
this.addEventListener('dragend', event => this.dragEnd(event))
this.addEventListener('dragover', event => this.dragOver(event))
this.addEventListener('drop', event => this.dragDrop(event))
this.elName.addEventListener('dragenter', event => this.dragEnter(event))
this.elName.addEventListener('dragleave', event => this.dragLeave(event))
}// }}}
dragStart(e) {// {{{
if (this.node.isModified()) {
alert('Save note before moving it.')
e.stopPropagation()
e.preventDefault()
return
}
this.classList.add('drag-source')
const blankPixel = new Image()
blankPixel.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
e.dataTransfer.setDragImage(blankPixel, 0, 0)
e.dataTransfer.allowedEffects = 'none'
e.stopPropagation()
_app.dragIcon.setSource(this)
_app.dragIcon.start()
}// }}}
dragEnd(e) {// {{{
this.classList.remove('drag-source')
_app.dragIcon.end()
e.stopPropagation()
}// }}}
dragOver(e) {// {{{
e.dataTransfer.dropEffect = 'move'
e.preventDefault()
}// }}}
async dragDrop(e) {// {{{
try {
e.stopPropagation()
const sourceNode = _app.dragIcon.getSource()
// Abort if user drops the node back on itself.
if (sourceNode.node.UUID === this.node.UUID)
return
await _app.moveNode(sourceNode.node, this.node.UUID)
_app.sidebar.setNodeExpanded(this, true)
await this.render(true, true)
await sourceNode.render(true, true)
} catch (e) {
console.error(e)
alert(e)
} finally {
this.dragLeave(e)
}
}// }}}
dragEnter(e) {// {{{
const targetNode = e.target.closest('n2-treenode')
if (targetNode.classList.contains('drag-source'))
return
e.stopPropagation()
_app.dragIcon.icon('ok')
this.classList.add('drag-target')
}// }}}
dragLeave(e) {// {{{
e.stopPropagation()
e.dataTransfer.dropEffect = 'none'
e.dataTransfer.setDragImage(N2TreeNode.DRAG_ICON, -16, 8)
_app.dragIcon.icon('')
this.classList.remove('drag-target')
}// }}}
async expandNode(event) {// {{{
const expanded = _app.sidebar.getNodeExpanded(this.node.UUID)
if (event.shiftKey) {
_app.sidebar.recursiveExpand(this.node, !expanded)
} else {
_app.sidebar.setNodeExpanded(this.node, !expanded)
}
}// }}} }// }}}
async fetchChildren(force_fetch) {//{{{ async fetchChildren(force_fetch) {//{{{
if (this.children_populated && !force_fetch) if (this.children_populated && !force_fetch)
@ -515,8 +679,8 @@ export class N2TreeNode extends CustomHTMLElement {
if (this.rendered && force_update !== true) if (this.rendered && force_update !== true)
return this return this
if (this.sidebar.getNodeExpanded(this.node.UUID)) if (this.sidebar.getNodeExpanded(this.node.UUID) || force_refetch_children)
await this.fetchChildren() await this.fetchChildren(force_refetch_children)
// Update the name and selected status. // Update the name and selected status.
this.elName.querySelector('span').innerText = this.node.get('Name') this.elName.querySelector('span').innerText = this.node.get('Name')
@ -539,6 +703,17 @@ export class N2TreeNode extends CustomHTMLElement {
// The expand icon <img> is only changed to not get a flickering when re-rendering. // The expand icon <img> is only changed to not get a flickering when re-rendering.
if (this.node.UUID === ROOT_NODE) if (this.node.UUID === ROOT_NODE)
this.setImgSrc(this.elExpand, `/images/${window._VERSION}/icon_home.svg`) 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()) else if (!this.node.hasChildren())
this.setImgSrc(this.elExpand, `/images/${window._VERSION}/leaf.svg`) this.setImgSrc(this.elExpand, `/images/${window._VERSION}/leaf.svg`)
else if (this.sidebar.getNodeExpanded(this.node.UUID)) else if (this.sidebar.getNodeExpanded(this.node.UUID))
@ -573,6 +748,24 @@ export class N2TreeNode extends CustomHTMLElement {
img.setAttribute('src', newSrc) 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-treenode', N2TreeNode)
customElements.define('n2-specialnodedeleted', SpecialNodeDeleted)
customElements.define('n2-specialnodeorphaned', SpecialNodeOrphaned)
// vim: foldmethod=marker // vim: foldmethod=marker

View file

@ -17,10 +17,12 @@ export class Sync {
const state = await nodeStore.getAppState('latest_sync_node') const state = await nodeStore.getAppState('latest_sync_node')
const oldMax = (state?.value ? state.value : 0) const oldMax = (state?.value ? state.value : 0)
let nodeCount = await this.getNodeCount(oldMax) let nodeCountDownload = await this.getNodeCount(oldMax)
nodeCount += await nodeStore.sendQueue.count() 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) await this.nodesFromServer(oldMax)
.then(durationNodes => { .then(durationNodes => {
@ -28,6 +30,7 @@ export class Sync {
console.log(`Total time: ${Math.round(1000 * durationNodes) / 1000}s`) console.log(`Total time: ${Math.round(1000 * durationNodes) / 1000}s`)
}) })
// Uploads of modified nodes to server.
await this.nodesToServer() await this.nodesToServer()
} finally { } finally {
_mbus.dispatch('SYNC_DONE') _mbus.dispatch('SYNC_DONE')
@ -78,15 +81,16 @@ export class Sync {
handled++ handled++
if (handled % 100 === 0) if (handled % 100 === 0)
_mbus.dispatch('SYNC_HANDLED', { handled }) _mbus.dispatch('SYNC_DOWNLOADED', { handled })
} }
} while (res.Continue) } while (res.Continue)
_mbus.dispatch('SYNC_HANDLED', { handled }) _mbus.dispatch('SYNC_DOWNLOADED', { handled })
nodeStore.setAppState('latest_sync_node', currMax) nodeStore.setAppState('latest_sync_node', currMax)
} catch (e) { } catch (e) {
console.log('sync node tree', e) console.error('sync node tree', e)
alert(e.message)
} finally { } finally {
syncEnd = Date.now() syncEnd = Date.now()
const duration = (syncEnd - syncStart) / 1000 const duration = (syncEnd - syncStart) / 1000
@ -154,8 +158,8 @@ export class Sync {
_mbus.dispatch('SYNC_UPLOADED', { count: nodesToSend.length }) _mbus.dispatch('SYNC_UPLOADED', { count: nodesToSend.length })
} catch (e) { } catch (e) {
console.trace(e) console.error(e)
alert(e) alert(e.message)
return return
} }
} }
@ -166,59 +170,80 @@ export class N2SyncProgress extends CustomHTMLElement {
static {// {{{ static {// {{{
this.tmpl = document.createElement('template') this.tmpl = document.createElement('template')
this.tmpl.innerHTML = ` this.tmpl.innerHTML = `
<progress data-el="progress" min=0 max=137 value=0></progress> <img src="/images/${_VERSION}/icon_transfer.svg">
<div data-el="count" class="count">0 / 0</div> <div data-el="download-transferred" class="count">0</div> <div>/</div> <div data-el="download-total">0</div>
<div data-el="upload-transferred" class="count">0</div> <div>/</div> <div data-el="upload-total">0</div>
` `
}// }}} }// }}}
constructor() {//{{{ constructor() {//{{{
super() super()
this.reset() this.reset()
_mbus.subscribe('SYNC_COUNT', event => this.progressHandler(event)) _mbus.subscribe('SYNC_START', () => this.reset())
_mbus.subscribe('SYNC_HANDLED', event => this.progressHandler(event)) _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)) _mbus.subscribe('SYNC_DONE', event => this.progressHandler(event))
}//}}} }//}}}
reset() {//{{{ reset() {//{{{
this.classList.remove('ok')
this.state = { this.state = {
nodesToSync: 0, nodesToDownload: 0,
nodesSynced: 0, nodesToUpload: 0,
nodesDowloaded: 0,
nodesUploaded: 0,
} }
this.render()
}//}}} }//}}}
progressHandler(event) {//{{{ progressHandler(event) {//{{{
const eventData = event.detail.data const eventData = event.detail.data
switch (event.type) { switch (event.type) {
case 'SYNC_COUNT': case 'SYNC_DOWNLOAD_COUNT':
this.state.nodesToSync = eventData.count this.state.nodesToDownload = eventData.count
this.setSyncState(true) this.setSyncState(true)
break break
case 'SYNC_HANDLED': case 'SYNC_UPLOAD_COUNT':
this.state.nodesSynced = eventData.handled 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 break
case 'SYNC_DONE': case 'SYNC_DONE':
this.classList.add('ok')
// Hides the progress bar. // Hides the progress bar.
this.setSyncState(false) this.setSyncState(false)
// Don't update anything if nothing was synced. // Don't update anything if nothing was synced.
if (this.state.nodesSynced === 0) if (this.state.nodesDowloaded === 0)
break break
// Reload the tree nodes to reflect the new/updated nodes. // Reload the tree nodes to reflect the new/updated nodes.
window._app.tree.reset() window._app.sidebar.reset()
break break
} }
this.render() this.render()
}//}}} }//}}}
render() {//{{{ render() {//{{{
this.elProgress.max = this.state.nodesToSync this.elDownloadTransferred.innerText = this.state.nodesDowloaded
this.elProgress.value = this.state.nodesSynced this.elDownloadTotal.innerText = this.state.nodesToDownload
this.elCount.innerText = `${this.state.nodesSynced} / ${this.state.nodesToSync}`
this.elUploadTransferred.innerText = this.state.nodesUploaded
this.elUploadTotal.innerText = this.state.nodesToUpload
}//}}} }//}}}
setSyncState(state) {// {{{ setSyncState(state) {// {{{
if (state) if (state)
this.classList.add('show') this.classList.add('show')
else else
// Give the user a chance to see what it ended on.
setTimeout(() => this.classList.remove('show'), 1500) setTimeout(() => this.classList.remove('show'), 1500)
}// }}} }// }}}
} }

27
user.go
View file

@ -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
}

63
user/pkg.go Normal file
View file

@ -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
}

View file

@ -1,6 +1,12 @@
{{ define "page" }} {{ define "page" }}
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/notes2.css">
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/page_history.css">
<!-- Drag and drop elements -->
<!-- page-node --> <!-- page-node -->
<div id="notes2" class="page-history"> <div id="notes2" class="page-node">
<div id="tree-expander" onclick="window._mbus.dispatch('TREE_EXPANSION', { expand: true })">&gt;</div> <div id="tree-expander" onclick="window._mbus.dispatch('TREE_EXPANSION', { expand: true })">&gt;</div>
<div id="tree" tabindex=0></div> <div id="tree" tabindex=0></div>
@ -10,22 +16,30 @@
<n2-pagestorage></n2-pagestorage> <n2-pagestorage></n2-pagestorage>
</div> </div>
<div id="page-root">
<div>
<img src="/images/{{ .VERSION }}/logo.svg">
<div> {{ .VERSION }}</div>
<div class="create">Create note</div>
</div>
</div>
<!-- Node editing --> <!-- Node editing -->
<div id="page-node"> <div id="page-node">
<div id="crumbs"></div> <div id="crumbs"></div>
<!--n2-syncprogress></n2-syncprogress-->
<n2-nodeui id="note"></n2-nodeui> <n2-nodeui id="note"></n2-nodeui>
</div> </div>
<!-- History --> <!-- History -->
<div id="page-history"> <n2-pagehistory id="page-history"></n2-pagehistory>
<n2-pagehistory></n2-pagehistory>
</div>
</div>
</div>
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/notes2.css"> <!-- Preferences -->
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/page_history.css"> <n2-pagepreferences id="page-preferences"></n2-pagepreferences>
</div>
<n2-syncprogress></n2-syncprogress>
</div>
<script type="module"> <script type="module">
import {NodeStore} from '/js/{{ .VERSION }}/node_store.mjs' import {NodeStore} from '/js/{{ .VERSION }}/node_store.mjs'
@ -33,6 +47,7 @@
import {App} from "/js/{{ .VERSION }}/app.mjs" import {App} from "/js/{{ .VERSION }}/app.mjs"
import {API} from 'api' import {API} from 'api'
import {Sync} from 'sync' import {Sync} from 'sync'
import { } from '/js/{{ .VERSION }}/page_preferences.mjs'
import { } from '/js/{{ .VERSION }}/page_storage.mjs' import { } from '/js/{{ .VERSION }}/page_storage.mjs'
import { } from '/js/{{ .VERSION }}/page_history.mjs' import { } from '/js/{{ .VERSION }}/page_history.mjs'
import { } from '/js/{{ .VERSION }}/file.mjs' import { } from '/js/{{ .VERSION }}/file.mjs'