Compare commits

..

46 commits
v19 ... 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
35 changed files with 2444 additions and 422 deletions

View file

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

75
main.go
View file

@ -4,6 +4,7 @@ import (
// Internal
"notes2/authentication"
"notes2/html_template"
appUser "notes2/user"
"os"
// Standard
@ -23,7 +24,7 @@ import (
"text/template"
)
const VERSION = "v19"
const VERSION = "v29"
const CONTEXT_USER = 1
const SYNC_PAGINATION = 200
@ -134,6 +135,8 @@ func main() { // {{{
http.HandleFunc("/offline", pageOffline)
http.HandleFunc("/user/authenticate", AuthManager.AuthenticationHandler)
http.HandleFunc("GET /user/preferences", authenticated(actionUserGetPreferences))
http.HandleFunc("POST /user/preferences", authenticated(actionUserSetPreferences))
http.HandleFunc("/sync/from_server/count/{sequence}", authenticated(actionSyncFromServerCount))
http.HandleFunc("/sync/from_server/{sequence}/{offset}", authenticated(actionSyncFromServer))
@ -178,7 +181,7 @@ func authenticated(fn func(http.ResponseWriter, *http.Request)) func(http.Respon
}
// User object is added to the context for the next handler.
user := NewUser(claims)
user := appUser.NewUser(claims)
r = r.WithContext(context.WithValue(r.Context(), CONTEXT_USER, user))
Log.Debug("webserver", "op", "request", "method", r.Method, "url", r.URL.String(), "username", user.Username, "client", user.ClientUUID)
@ -266,7 +269,7 @@ func actionSyncFromServer(w http.ResponseWriter, r *http.Request) { // {{{
// The purpose of the Client UUID is to avoid
// sending nodes back once again to a client that
// just created or modified it.
user := getUser(r)
user := getUserSession(r)
changedFrom, _ := strconv.Atoi(r.PathValue("sequence"))
offset, _ := strconv.Atoi(r.PathValue("offset"))
@ -277,12 +280,6 @@ func actionSyncFromServer(w http.ResponseWriter, r *http.Request) { // {{{
return
}
/*
Log.Debug("/sync/from_server", "num_nodes", len(nodes), "maxSeq", maxSeq)
foo, _ := json.Marshal(nodes)
os.WriteFile(fmt.Sprintf("/tmp/nodes-%d.json", offset), foo, 0644)
*/
j, _ := json.Marshal(struct {
OK bool
Nodes []Node
@ -295,7 +292,7 @@ func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{
// The purpose of the Client UUID is to avoid
// sending nodes back once again to a client that
// just created or modified it.
user := getUser(r)
user := getUserSession(r)
changedFrom, _ := strconv.Atoi(r.PathValue("sequence"))
count, err := NodesCount(user.UserID, uint64(changedFrom), user.ClientUUID)
@ -315,7 +312,7 @@ func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{
w.Write(j)
} // }}}
func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
user := getUser(r)
user := getUserSession(r)
var err error
uuid := r.PathValue("uuid")
@ -331,7 +328,7 @@ func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
})
} // }}}
func actionNodeHistoryRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
user := getUser(r)
user := getUserSession(r)
var err error
uuid := r.PathValue("uuid")
@ -354,7 +351,7 @@ func actionNodeHistoryRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
})
} // }}}
func actionNodeHistoryCount(w http.ResponseWriter, r *http.Request) { // {{{
user := getUser(r)
user := getUserSession(r)
var err error
uuid := r.PathValue("uuid")
@ -371,7 +368,7 @@ func actionNodeHistoryCount(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)
var request struct {
@ -383,9 +380,50 @@ func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{
return
}
_, err = db.Exec(`CALL add_nodes($1, $2, $3::jsonb)`, user.UserID, user.ClientUUID, request.NodeData)
_, err = db.Exec(`CALL add_nodes($1, $2::uuid, $3::jsonb)`, user.UserID, user.ClientUUID, request.NodeData)
if err != nil {
Log.Error("sync", "error", err, "user_id", user.UserID, "client_uuid", user.ClientUUID, "node_data", request.NodeData)
httpError(w, err)
return
}
responseData(w, map[string]any{
"OK": true,
})
} // }}}
func actionUserGetPreferences(w http.ResponseWriter, r *http.Request) { // {{{
user := getUserSession(r)
prefs, err := user.Preferences()
if err != nil {
httpError(w, err)
return
}
responseData(w, map[string]any{
"OK": true,
"Preferences": prefs,
})
} // }}}
func actionUserSetPreferences(w http.ResponseWriter, r *http.Request) { // {{{
session := getUserSession(r)
// Verify the "default" profile is still there.
var newPrefs map[string]appUser.UserPreferences
body, _ := io.ReadAll(r.Body)
err := json.Unmarshal(body, &newPrefs)
if err != nil {
httpError(w, err)
return
}
if _, found := newPrefs["default"]; !found {
httpError(w, fmt.Errorf("'default' profile missing."))
return
}
err = session.SetPreferences(newPrefs)
if err != nil {
Log.Error("sync", "error", err)
httpError(w, err)
return
}
@ -437,7 +475,8 @@ func changePassword(username string) { // {{{
fmt.Printf("\nPassword changed\n")
} // }}}
func getUser(r *http.Request) UserSession { // {{{
user, _ := r.Context().Value(CONTEXT_USER).(UserSession)
func getUserSession(r *http.Request) appUser.UserSession { // {{{
user, _ := r.Context().Value(CONTEXT_USER).(appUser.UserSession)
user.Db = db
return user
} // }}}

22
node.go
View file

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

View file

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

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

@ -4,19 +4,38 @@
.heading-container {
display: grid;
grid-template-columns: min-content 1fr;
grid-gap: 16px;
grid-gap: 12px;
white-space: nowrap;
align-items: center;
margin-top: 32px;
margin-bottom: 8px;
margin-bottom: 16px;
&:first-child {
margin-top: 32px;
margin-top: 32px !important;
.line {
display: none !important;
}
}
.line {
border-bottom: 1px solid var(--line-color);
}
&[data-heading="1"] {
margin-top: 64px;
margin-bottom: 32px;
}
&[data-heading="2"],
&[data-heading="3"] {
margin-top: 16px;
.line {
display: none;
}
}
h1, h2, h3 {
margin: 0;
}
h1 {
@ -25,33 +44,28 @@
display: inline-block;
font-size: 1.25em;
border-radius: 8px;
clip-path: polygon(0 0, 100% 0, calc(100% - 16px) 100%, 0 100%);
color: #fff;
background-color: var(--color1);
padding: 4px 12px;
margin-top: 0px;
margin-bottom: 0px;
padding: 4px 24px 4px 16px;
}
h2 {
font-size: 1.25em;
margin-top: 0px;
margin-bottom: 0px;
color: var(--color1);
}
h2+.line,
h3+.line {
border-bottom: none !important;
}
h3:before {
h3 {
&:before {
font-size: 1.0em;
content: "> ";
color: var(--color1);
}
}
}
a {
color: var(--color1);
@ -69,7 +83,7 @@
table {
border: 1px solid #ccc;
border-collapse: collapse;
margin-top: 14px;
margin-top: 16px;
th {
text-align: left;
@ -88,6 +102,11 @@
border: 1px solid #ccc;
padding: 2px 4px;
border-radius: 4px;
&.copy {
border: var(--markdown-copy-border);
background-color: var(--markdown-copy-background);
}
}
pre {
@ -97,6 +116,14 @@
border-radius: 4px;
white-space: pre-wrap;
&.copy {
border: var(--markdown-copy-border);
background-color: var(--markdown-copy-background);
code {
background-color: inherit !important;
}
}
code {
border: unset;
padding: unset;

View file

@ -10,6 +10,14 @@
--line-color: #ccc;
--tree-expander: 0px;
--functions-width: 150px;
--menu-color: #fff;
--menu-item-hover-color: #f4f4f4;
--font-monospace: "Liberation Mono", monospace;
--markdown-copy-border: 1px solid #0a0;
--markdown-copy-background: #e3f4d7;
}
html {
@ -20,6 +28,10 @@ html {
filter: var(--colorize);
}
textarea {
font-family: var(--font-monospace);
}
button {
font-size: 1em;
padding: 4px 8px;
@ -61,9 +73,10 @@ button {
1fr;
}
&.page-history {
/* The other pages just gets the whole page without dividing it up. */
&:not(.page-node) {
grid-template-areas:
"tree-expander tree pad1 n2-pagehistory pad2"
"tree-expander tree pad1 n2-page pad2"
;
grid-template-columns:
@ -186,6 +199,11 @@ button {
img {
width: auto;
height: 18px;
&.deleted {
height: 24px;
transform: translateX(3px) translateY(3px);
}
}
}
@ -218,10 +236,61 @@ button {
}
}
/* =============== *
* PAGE MANAGEMENT *
* =============== */
[id^="page-"] {
display: none;
}
#notes2 {
&.page-node {
#page-root {
display: none;
}
#page-node {
display: contents;
}
}
&.page-storage {
#page-storage {
display: contents;
n2-pagestorage {
grid-area: n2-page;
}
}
}
&.page-history {
#page-history {
display: grid;
grid-area: n2-page;
}
}
&.page-preferences {
#page-preferences {
display: block;
grid-area: n2-page;
}
}
&.root-node-override {
[id^="page-"] {
display: none !important;
}
#page-root {
display: contents !important;
}
}
}
#main-page {
display: contents;
@ -229,30 +298,6 @@ button {
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 {
@ -307,7 +352,8 @@ button {
}
n2-syncprogress {
position: absolute;
display: grid;
position: fixed;
top: 8px;
right: 8px;
padding: 8px 16px;
@ -316,8 +362,19 @@ n2-syncprogress {
font-weight: bold;
background-color: var(--color1);
color: #fff;
box-shadow: rgba(0, 0, 0, 0.1) 0px 10px 15px -3px, rgba(0, 0, 0, 0.05) 0px 4px 6px -2px;
opacity: 0;
transition: opacity 250ms;
&.show {
opacity: 1;
}
&.ok {
background-color: #5aa02c;
}
display: grid;
grid-template-columns: min-content repeat(3, min-content);
grid-gap: 8px 8px;
white-space: nowrap;
@ -331,6 +388,32 @@ n2-syncprogress {
}
}
#page-root {
&>div {
grid-area: content;
align-self: start;
margin-top: 64px;
display: grid;
justify-items: center;
/* logo */
img {
margin-bottom: 16px;
height: 32px;
}
.create {
border: 2px solid #529b00;
padding: 16px 32px;
margin-top: 64px;
background-color: #d9ffc9;
cursor: pointer;
}
}
}
/* ============================================================= */
n2-nodeui {
@ -356,6 +439,8 @@ n2-nodeui {
font-size: 1.75em;
margin-top: 8px;
margin-bottom: 0px;
white-space: nowrap;
width: min-content;
}
.el-functions {
@ -369,7 +454,6 @@ n2-nodeui {
grid-area: content;
justify-self: center;
word-wrap: break-word;
font-family: monospace;
font-size: 1em;
color: #333;
@ -393,6 +477,10 @@ n2-nodeui {
grid-area: content;
display: none;
font-family: var(--font-monospace);
font-size: 1em;
font-weight: 400;
border-top: 1px solid #e0e0e0;
margin-top: 8px;
margin-bottom: 32px;

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

@ -24,12 +24,12 @@
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="31.614857"
inkscape:cx="5.0609117"
inkscape:cy="9.5524708"
inkscape:cx="5.0450964"
inkscape:cy="9.5682862"
inkscape:window-width="2190"
inkscape:window-height="1401"
inkscape:window-x="1463"
inkscape:window-y="0"
inkscape:window-y="18"
inkscape:window-maximized="1"
inkscape:current-layer="layer1"
showgrid="false" /><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 {
// query resolves into the JSON data produced by the application, or an exception with 'type' and 'error' properties.
static async query(method, path, request) {
return new Promise((resolve, reject) => {
try {
const body = JSON.stringify(request)
const headers = {}
@ -12,33 +12,22 @@ export class API {
headers.Authorization = `Bearer ${token}`
}
fetch(path, { method, headers, body })
.then(response => {
const res = await fetch(path, { method, headers, body })
// An HTTP communication level error occured.
if (!response.ok || response.status != 200)
return reject({
type: 'http',
error: response,
})
return response.json()
})
.then(json => {
if (!res.ok || res.status != 200)
throw new Error('HTTP error', { cause: { type: 'http', error: res, }})
// Application level response are handled here.
const json = await res.json()
if (!json.OK)
return reject({
type: 'application',
error: json.Error,
application: json,
})
resolve(json)
})
.catch(err =>
throw new Error(json.Error, { cause: { type: 'application', application: json, }})
return json
} catch (err) {
// Catch any other errors from fetch.
reject({
type: 'http',
error: err,
}))
})
throw new Error(err.message, { cause: { type: 'http', error: err, }})
}
}
static hasAuthenticationToken() {//{{{

View file

@ -2,51 +2,61 @@ import { ROOT_NODE } from 'node_store'
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
import { N2Sidebar } from 'sidebar'
import { Node } from 'node'
import { N2PreferenceSet } from './page_preferences.mjs'
export class App {
static PAGES = ['node', 'history', 'storage']
constructor() {// {{{
this.currentNode = null
this.sidebar = new N2Sidebar()
this.crumbs = new N2Crumbs()
this.crumbsElement = document.getElementById('crumbs')
this.nodeUI = document.getElementById('note')
this.dragIcon = new N2DragIcon()
this.preferences = this.getPreferences()
this.sidebar.render().then(sidebar => {
document.getElementById('tree').append(sidebar)
document.getElementById('tree-nodes')?.focus()
})
// Start node shows a system-wide page instead of node editing
// since the start node is kind of magic and doesn't fit into
// the syncing system.
const determineNodePage = uuid => {
const el = document.getElementById('notes2')
if (uuid == ROOT_NODE)
el.classList.add('root-node-override')
else
el.classList.remove('root-node-override')
}
_mbus.subscribe('TREE_RENDERED', async () => {
// Subscribing to the start node existing after the tree trunk is
// fetched since the NODE_COMPONENT_EXIST message isn't sent for the
// root node itself, and the root node should be selected in the tree
// after it is rendered when the site is shown without UUID in the URL.
const startNode = await this.getStartNode()
if (startNode.UUID == ROOT_NODE)
this.goToNode(startNode.UUID, false, false)
else
determineNodePage(startNode.UUID)
this.goToNode(startNode.UUID, false, false)
})
_mbus.subscribe('TREE_NODE_SELECTED', event => {
const node = event.detail.data
determineNodePage(node.UUID)
this.goToNode(node.UUID, false, false)
})
_mbus.subscribe('GO_TO_NODE', event => {
const node = event.detail.data
determineNodePage(node.nodeUUID)
this.goToNode(node.nodeUUID, node.dontPush, node.dontExpand)
})
_mbus.subscribe('SHOW_PAGE', ({ detail: { data: { page } } }) => {
let classList = document.querySelector('#main-page').classList
classList.forEach(e =>
classList.remove(e)
)
classList.add(page)
classList = document.querySelector('#notes2').classList
const classList = document.getElementById('notes2').classList
classList.forEach(e => {
if (e.startsWith('page-'))
classList.remove(e)
@ -54,6 +64,11 @@ export class App {
classList.add('page-' + page)
})
_mbus.subscribe('DEVICE_PREFERENCE_SET_UPDATED', ()=>{
this.preferences = this.getPreferences()
console.log(this.preferences.data)
})
window.addEventListener('keydown', event => this.keyHandler(event))
window.addEventListener('popstate', event => this.popState(event))
document.getElementById('notes2').addEventListener('click', event => {
@ -61,6 +76,9 @@ export class App {
document.getElementById('node-content')?.focus()
})
document.querySelector('#page-root .create').addEventListener('click', () => this.createNode())
document.body.append(this.dragIcon)
_mbus.dispatch('SHOW_PAGE', { page: 'node' })
window._sync = new Sync()
@ -70,65 +88,52 @@ export class App {
// There a slight delay to initiate sync seems reasonable.
setTimeout(() => window._sync.run(), 1000)
}// }}}
keyHandler(event) {//{{{
let handled = true
// All keybindings is Alt+Shift, since the popular browsers at the time (2023) allows to override thees.
// Most keybindings is Alt+Shift, since the popular browsers at the time (2023) allows to override thees.
// Ctrl+S is the exception to using Alt+Shift, since it is overridable and in such widespread use for saving.
// Thus, the exception is acceptable to consequent use of alt+shift.
if (!(event.shiftKey && event.altKey) && !(event.key.toUpperCase() === 'S' && event.ctrlKey))
return
const CTRL = !event.shiftKey && event.ctrlKey && !event.altKey
const SHIFT_ALT = event.shiftKey && !event.ctrlKey && event.altKey
const SHIFT_CTRL_ALT = event.shiftKey && event.ctrlKey && event.altKey
switch (event.key.toUpperCase()) {
case 'F2':
this.nodeUI.renameNode()
break
case 'T':
if (document.activeElement.id === 'tree-nodes') {
if (!SHIFT_ALT) { handled = false; break }
if (document.activeElement.id === 'tree-nodes')
this.nodeUI.takeFocus()
} else {
else
this.sidebar.focus()
}
break
case 'F':
if (!SHIFT_ALT) { handled = false; break }
_mbus.dispatch('op-search')
break
/*
case 'C':
this.showPage('node')
break
case 'E':
this.showPage('keys')
break
*/
case 'M':
if (!SHIFT_ALT) { handled = false; break }
globalThis._mbus.dispatch('MARKDOWN_TOGGLE')
break
case 'N':
if (SHIFT_ALT)
this.createNode()
else if (SHIFT_CTRL_ALT) {
this.createNode(this.currentNode?.ParentUUID)
} else {
handled = false
}
break
/*
case 'P':
this.showPage('node-properties')
break
*/
case 'S':
if (!CTRL) { handled = false; break }
this.nodeUI.saveNode()
break
/*
case 'U':
this.showPage('upload')
break
case 'F':
this.showPage('search')
break
*/
default:
handled = false
@ -155,17 +160,26 @@ export class App {
async saveNode() {//{{{
}//}}}
async createNode() {//{{{
let name = prompt("Name")
async moveNode(node, targetNodeUUID) {// {{{
node.moveToParent(targetNodeUUID)
await node.save()
}// }}}
async createNode(createUnderUUID) {//{{{
const parentUUID = createUnderUUID ? createUnderUUID : this.currentNode.UUID
const p = createUnderUUID ? 'Name for sibling document' : 'Name for sub-document'
let name = prompt(p)
if (!name)
return
const nn = Node.create(name, this.currentNode.UUID)
nn.save()
nodeStore.sendQueue.add(nn)
nodeStore.add([nn])
const nn = Node.create(name, parentUUID)
await nn.save()
// Treenode is forcefully rerendered and children refetched to both show the new node
// and to get it resorted.
const parentTreenode = this.sidebar.getTreeNode(parentUUID)
await parentTreenode.render(true, true)
_mbus.dispatch('GO_TO_NODE', { nodeUUID: nn.UUID })
}//}}}
async goToNode(nodeUUID, dontPush, dontExpand) {//{{{
if (nodeUUID === null || nodeUUID === undefined)
@ -205,6 +219,12 @@ export class App {
let classList = document.querySelector('#main-page').classList
return classList.contains(page)
}// }}}
getPreferences() {// {{{
const devPrefSet = localStorage.getItem('device_preference_set') || 'default'
const userData = localStorage.getItem('user') || '{"default": {}}'
const user = JSON.parse(userData)
return new N2PreferenceSet(devPrefSet, user.Preferences[devPrefSet])
}// }}}
}
class N2Crumbs extends CustomHTMLElement {
@ -238,7 +258,6 @@ class N2Crumbs extends CustomHTMLElement {
return this
}// }}}
}
customElements.define('n2-crumbs', N2Crumbs)
class N2Crumb extends CustomHTMLElement {
static {// {{{
@ -269,7 +288,6 @@ class N2Crumb extends CustomHTMLElement {
this.elLink.addEventListener('click', () => _mbus.dispatch("GO_TO_NODE", { nodeUUID: this.uuid, dontPush: false, dontExpand: true }))
}// }}}
}
customElements.define('n2-crumb', N2Crumb)
function tmpl(html) {// {{{
const el = document.createElement('template')
@ -343,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

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 {
constructor(useShadow) {// {{{
super()
this._fields = new Map()
const workOn = useShadow ? this.attachShadow({ mode: 'open' }) : this
workOn.appendChild(this.constructor.tmpl.content.cloneNode(true))
workOn.querySelectorAll('*').forEach(el => {
@ -9,6 +19,7 @@ export class CustomHTMLElement extends HTMLElement {
if (field !== undefined) {
const fieldName = this.toElementName('field', field)
this[fieldName] = el
this._fields.set(this.toElementName('', field), el)
}
const name = el.dataset.el
@ -19,39 +30,22 @@ export class CustomHTMLElement extends HTMLElement {
}
})
}// }}}
allFields() {// {{{
return this._fields
}// }}}
fieldValues() {// {{{
const state = {}
for (const [name, field] of this._fields) {
if (field.tagName.toLowerCase() == 'input' && field.getAttribute('type').toLowerCase() == 'checkbox')
state[name] = field.checked
else
state[name] = field.value
}
return state
}// }}}
toElementName(prefix, str) {// {{{
str = prefix + '-' + str
return str.replace(/-(id|[a-z])/g, match => match.toUpperCase().replace('-', ''))
}// }}}
}
export class StupidPreactCustomHTMLElement extends HTMLElement {
constructor() {// {{{
super()
// Stupid stuff because of Preact.
this.clonedNodes = this.constructor.tmpl.content.cloneNode(true)
this.clonedNodes.querySelectorAll('*').forEach(el => {
const field = el.dataset.field
if (field !== undefined) {
const fieldName = this.toElementName('field', field)
this[fieldName] = el
}
const name = el.dataset.el
if (name !== undefined) {
const elName = this.toElementName('el', name)
this[elName] = el
el.classList.add('el-' + name)
}
})
}// }}}
toElementName(prefix, str) {// {{{
str = prefix + '-' + str
return str.replace(/-(id|[a-z])/g, match => match.toUpperCase().replace('-', ''))
}// }}}
connectedCallback() {// {{{
// Stupid stuff because of Preact.
this.appendChild(this.clonedNodes)
}// }}}
}

View file

@ -92,7 +92,9 @@ function escapeHtmlEntities(html, encode) {// {{{
export class MarkedPosition {
constructor() {// {{{
window.setpos = (event) => this.setpos(event)
window.marked_setpos = (event) => this.setpos(event)
window.marked_changecheckbox = (event) => this.changecheckbox(event)
window.marked_copy_to_clipboard = (event, tagname) => this.copy_to_clipboard(event, tagname)
this.render()
}// }}}
setpos(event) {// {{{
@ -106,8 +108,44 @@ export class MarkedPosition {
}
})
}// }}}
changecheckbox(event) {// {{{
event.stopPropagation()
event.preventDefault()
_mbus.dispatch('MARKDOWN_CHANGE_CHECKBOX', {
checkbox: event.target,
position: {
start: event.target.closest('[data-offset-start]').dataset.offsetStart,
end: event.target.closest('[data-offset-start]').dataset.offsetEnd,
}
})
}// }}}
async copy_to_clipboard(event, tagname) {// {{{
if (!event.shiftKey)
return
try {
// Stop text selections on the page to the mouse pointer.
// Old selections are remove as well to give a cleaner view
// of the copied text/highlighting.
event.preventDefault()
event.stopPropagation()
window.getSelection().removeAllRanges()
const text = event.target.innerText
await navigator.clipboard.writeText(text)
const tagClasslist = event.target.closest(tagname).classList
tagClasslist.add('copy')
setTimeout(()=>tagClasslist.remove('copy'), 250)
} catch (err) {
console.error('Failed to copy: ', err)
alert('Failed to copy: ', err)
}
}// }}}
render() {// {{{
const markedObject = this
this.marked = new Marked()
this.marked.use(markedTokenPosition())
this.marked.use({
@ -115,8 +153,8 @@ export class MarkedPosition {
heading(token) {
const content = this.parser.parseInline(token.tokens)
return `
<div class="heading-container">
<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
<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>
`
@ -124,7 +162,7 @@ export class MarkedPosition {
paragraph(token) {
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) {
@ -143,7 +181,7 @@ export class MarkedPosition {
},
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) {
@ -152,12 +190,12 @@ export class MarkedPosition {
const code = token.text.replace(other.endingNewline, '') + '\n'
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))
+ '</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)
+ '">'
+ (token.escaped ? code : escapeHtmlEntities(code, true))
@ -166,7 +204,7 @@ export class MarkedPosition {
blockquote(token) {
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) {
@ -178,13 +216,13 @@ export class MarkedPosition {
},
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) {
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="" ' : '')
+ 'disabled="" type="checkbox"> '
+ 'type="checkbox"> '
},
table(token) {
@ -227,7 +265,7 @@ export class MarkedPosition {
if (token.tokens.length > 0) {
const start = token.tokens[0].position.start.offset
const end = token.tokens[0].position.end.offset
ofs = `ondblclick="setpos(event)" data-offset-start="${start}" data-offset-end="${end}"`
ofs = `ondblclick="marked_setpos(event)" data-offset-start="${start}" data-offset-end="${end}"`
}
const content = this.parser.parseInline(token.tokens);
@ -239,23 +277,23 @@ export class MarkedPosition {
},
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) {
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) {
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) {
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) {
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) {
@ -265,7 +303,7 @@ export class MarkedPosition {
return text
}
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) {
out += ' title="' + (escapeHtmlEntities(token.title)) + '"'
}
@ -282,7 +320,7 @@ export class MarkedPosition {
return escapeHtmlEntities(token.text)
}
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) {
out += ` title="${escapeHtmlEntities(token.title)}"`
}

View file

@ -1,6 +1,8 @@
import { Node } from 'node'
export const ROOT_NODE = '00000000-0000-0000-0000-000000000000'
export const ORPHANED_NODE = '00000000-0000-0000-0000-000000000001'
export const DELETED_NODE = '00000000-0000-0000-0000-000000000002'
export class NodeStore {
constructor() {//{{{
@ -13,6 +15,8 @@ export class NodeStore {
this.sendQueue = null
this.nodesHistory = null
this.files = null
this.initializeSpecialNodes()
}//}}}
initializeDB() {//{{{
return new Promise((resolve, reject) => {
@ -57,7 +61,7 @@ export class NodeStore {
break
case 6:
nodesHistory = db.createObjectStore('nodes_history', { keyPath: ['UUID', 'Updated'] })
nodesHistory = db.createObjectStore('nodes_history', { keyPath: ['UUID', 'HistoryUUID'] })
break
case 7:
@ -76,8 +80,7 @@ export class NodeStore {
this.sendQueue = new SimpleNodeStore(this.db, 'send_queue')
this.nodesHistory = new NodeHistoryStore(this.db, 'nodes_history')
this.files = new SimpleNodeStore(this.db, 'files')
this.initializeRootNode()
.then(() => resolve())
resolve()
}
req.onerror = (event) => {
@ -85,40 +88,11 @@ export class NodeStore {
}
})
}//}}}
initializeRootNode() {//{{{
return new Promise((resolve, reject) => {
// The root node is a magical node which displays as the first node if none is specified.
// If not already existing, it will be created.
const trx = this.db.transaction('nodes', 'readwrite')
const nodes = trx.objectStore('nodes')
const getRequest = nodes.get(ROOT_NODE)
getRequest.onsuccess = (event) => {
// Root node exists - nice!
if (event.target.result !== undefined) {
resolve(event.target.result)
return
}
const putRequest = nodes.put({
UUID: ROOT_NODE,
Name: 'Notes2',
Content: 'Hello, World!',
Updated: new Date().toISOString(),
ParentUUID: '',
})
putRequest.onsuccess = (event) => {
resolve(event.target.result)
}
putRequest.onerror = (event) => {
reject(event.target.error)
}
}
getRequest.onerror = (event) => reject(event.target.error)
})
}//}}}
purgeCache() {//{{{
this.nodes = {}
}//}}}
initializeSpecialNodes() {// {{{
this.nodes[ROOT_NODE] = new Node({ UUID: ROOT_NODE, Name: 'Start', Special: true }, -1)
this.nodes[DELETED_NODE] = new Node({ UUID: DELETED_NODE, Name: 'Deleted nodes', Special: true }, -1)
this.nodes[ORPHANED_NODE] = new Node({ UUID: ORPHANED_NODE, Name: 'Orphaned nodes', Special: true }, -1)
}// }}}
node(uuid, dataIfUndefined, newLevel) {//{{{
let n = this.nodes[uuid]
@ -247,6 +221,7 @@ export class NodeStore {
nodeStore = t.objectStore('nodes')
t.oncomplete = (_event) => {
console.log('complete')
resolve()
}
@ -271,6 +246,14 @@ export class NodeStore {
}//}}}
get(uuid, suppliedNodestore) {//{{{
return new Promise((resolve, reject) => {
switch (uuid) {
case ROOT_NODE:
case DELETED_NODE:
case ORPHANED_NODE:
resolve(this.nodes[uuid])
return
}
// A nodestore can be provided in order to
// avoid creating new transactions.
let trx
@ -308,6 +291,16 @@ export class NodeStore {
return
}
if (node.UUID === DELETED_NODE || node.ParentUUID === DELETED_NODE) {
resolve(accumulated)
return
}
if (node.UUID === ORPHANED_NODE || node.ParentUUID === ORPHANED_NODE) {
resolve(accumulated)
return
}
const getRequest = nodeParentIndex.get(node.ParentUUID)
getRequest.onsuccess = (event) => {
// Node not found in IndexedDB.
@ -358,6 +351,7 @@ class SimpleNodeStore {
// Node to be moved is first stored in the new queue.
const req = store.put(node.data)
req.onsuccess = () => {
console.log('here')
resolve()
}
req.onerror = (event) => {
@ -473,11 +467,15 @@ class NodeHistoryStore extends SimpleNodeStore {
}// }}}
retrievePage(uuid, perPage, page) {// {{{
return new Promise((resolve, _reject) => {
const lowerBound = [uuid, '00000000-0000-0000-0000-000000000000']
const upperBound = [uuid, 'ffffffff-ffff-ffff-ffff-ffffffffffff']
const range = IDBKeyRange.bound(lowerBound, upperBound)
const cursor = this.db
.transaction(['nodes', this.storeName], 'readonly')
.objectStore(this.storeName)
.index('byUUID')
.openCursor(uuid, 'prev')
.openCursor(range, 'prev')
let retrieved = 0
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 { 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 {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<style>
.el-functions {
n2-nodeui > .el-functions {
display: grid;
grid-template-columns: 1fr repeat(3, min-content);
grid-gap: 8px;
align-items: center;
justify-items: end;
cursor: pointer;
img {
height: 24px;
}
.el-menu {
anchor-name: --node-menu;
border: 0;
padding: 0;
background-color: unset;
}
}
</style>
<div data-el="name"></div>
@ -27,9 +92,13 @@ export class N2PageNodeUI extends CustomHTMLElement {
<div data-el="functions">
<img data-el="icon-save" src="/images/${_VERSION}/icon_save_disabled.svg">
<img data-el="icon-markdown">
<img data-el="icon-table-format" class="colorize" src="/images/${_VERSION}/icon_table.svg">
<img data-el="icon-history" class="colorize" src="/images/${_VERSION}/icon_history.svg">
<img data-el="icon-new-document" class="colorize" src="/images/${_VERSION}/icon_new_document.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>
`
}// }}}
@ -38,11 +107,13 @@ export class N2PageNodeUI extends CustomHTMLElement {
this.node = null
this.style.display = 'contents'
this.classList.add('show-markdown') // TODO Should probably be moved to settings.
this.marked = new MarkedPosition()
_mbus.subscribe('NODE_UI_OPEN', event => {
this.node = event.detail.data
if (!this.node.isSpecial())
this.showMarkdown(true)
this.render()
})
@ -62,23 +133,29 @@ export class N2PageNodeUI extends CustomHTMLElement {
_mbus.subscribe('MARKDOWN_TOGGLE', () => this.showMarkdown(!this.showMarkdown()))
_mbus.subscribe('MARKDOWN_EDIT', ({ detail }) => this.editMarkdown(detail.data))
_mbus.subscribe('MARKDOWN_CHANGE_CHECKBOX', ({ detail }) => this.checkboxUpdated(detail.data))
this.elName.addEventListener('click', () => {
const name = prompt('Change title', this.node.data.Name)
if (name === null)
return
// Binding the node rename handler.
this.elName.addEventListener('click', async () => this.renameNode())
try {
this.node.setName(name)
} catch (err) {
console.error(err)
alert(err)
}
})
// Bind handlers for content keyboard input and paste.
this.elNodeContent.addEventListener('input', event => this.contentChanged(event))
this.elNodeContent.addEventListener('paste', async (event) => this.pasteHandler(event))
// Bind node icon handlers.
this.elIconSave.addEventListener('click', () => this.saveNode())
this.elIconMarkdown.addEventListener('click', () => this.showMarkdown(!this.showMarkdown()))
this.elIconTableFormat.addEventListener('click', event => {
this.elIconNewDocument.addEventListener('click', event => {
if (event.shiftKey)
_app.createNode(this.node.ParentUUID)
else
_app.createNode()
})
// Bind node menu items to handlers.
this.elNodeMenu.elFormatTables.addEventListener('click', event => {
this.elNodeMenu.hidePopover()
if (!event.shiftKey)
this.elNodeContent.value = this.formatAllTables(this.elNodeContent.value)
else {
@ -92,9 +169,12 @@ export class N2PageNodeUI extends CustomHTMLElement {
this.node.setContent(this.elNodeContent.value)
})
this.elIconHistory.addEventListener('click', () => _mbus.dispatch('SHOW_PAGE', { page: 'history' }))
this.elIconSave.addEventListener('click', ()=>this.saveNode())
this.elNodeMenu.elHistory.addEventListener('click', () => {
_mbus.dispatch('SHOW_PAGE', { page: 'history' })
})
// Default is to always show markdown.
this.classList.add('show-markdown') // TODO Should probably be moved to settings.
this.showMarkdown(true)
}// }}}
renderName() {// {{{
@ -111,35 +191,37 @@ export class N2PageNodeUI extends CustomHTMLElement {
} else
this.elNodeContent.focus({ preventScroll: true })
}// }}}
async renameNode() {// {{{
const name = prompt('Change title', this.node.data.Name)
if (name === null)
return
try {
// Document isn't only renamed, but also saved at once.
// Not really correct, but good enough to not have to implement
// a separate way to only rename the document. Since history is
// preserved it shouldn't be that horrible.
this.node.setName(name)
await this.node.save()
// Re-render the parent treenode forcefully to sort it again.
const parentUUID = this.node.ParentUUID
if (!parentUUID)
return
const parentTreeNode = _app.sidebar.getTreeNode(parentUUID)
parentTreeNode?.render(true, true)
} catch (err) {
console.error(err)
alert(err)
}
}// }}}
async saveNode() {// {{{
if (!this.node.isModified())
return
/* 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. */
// 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.
// node.save takes care of both "nodes" and "nodes_history" stores, also adds it to send queue.
// Sets "Updated" value to current date and time and generates a new history UUID.
await this.node.save()
// 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) {//{{{
@ -289,6 +371,30 @@ export class N2PageNodeUI extends CustomHTMLElement {
return lines
}// }}}
// "marked" sends a messagebus event when checking/unchecking a checkbox.
// Updates node and content textarea.
checkboxUpdated(eventData) {// {{{
const checkbox = eventData.checkbox
const pos = eventData.position
const content = this.node.content()
// Basic validation to verify that Marked does what is known and expected at this writing.
const mdCheckboxStr = content.slice(pos.start, pos.end)
if (!mdCheckboxStr.match(/^\[[ xX]\] $/)) {
alert(`Checkbox string didn't pass validation: '${mdCheckboxStr}'`)
console.error(`Checkbox string didn't pass validation: '${mdCheckboxStr}'`)
}
// Node is modified with the new value. User has to save manually, otherwise other changes could be saved
// when a save wasn't expected.
const newValue = `[${checkbox.checked ? 'x' : ' '}] `
const modifiedContent = this.node.content().slice(0, pos.start) + newValue + this.node.content().slice(pos.end)
this.node.setContent(modifiedContent)
// Also update the textarea since the node model doesn't know about it.
this.elNodeContent.setRangeText(newValue, pos.start, pos.end, 'select')
}// }}}
}
customElements.define('n2-nodeui', N2PageNodeUI)
@ -306,15 +412,20 @@ export class Node {
return 0
}//}}}
static create(name, parentUUID) {// {{{
return new Node({
const node = new Node({
UUID: uuidv7(),
Created: (new Date()).toISOString(),
Content: '',
Name: name,
ParentUUID: parentUUID,
Markdown: false,
History: false,
})
// Newly created node (not constructed from existing data) is considered modified
// since node.save returns early if it isn't modified.
node._modified = true
return node
}// }}}
constructor(nodeData, level) {//{{{
@ -393,12 +504,23 @@ export class Node {
getParent() {//{{{
return this._parent
}//}}}
moveToParent(newParentUUID) {// {{{
if (this.UUID === newParentUUID)
throw new Error("New parent UUID is the same as node UUID. Can't be your own parent.")
this.ParentUUID = newParentUUID
this.data.ParentUUID = newParentUUID
this._modified = true
}// }}}
isLastSibling() {//{{{
return this._sibling_after === null
}//}}}
isFirstSibling() {//{{{
return this._sibling_before === null
}//}}}
isSpecial() {// {{{
return this.data.Special
}// }}}
content() {//{{{
/* TODO - implement crypto
if (this.CryptoKeyID != 0 && !this._decrypted)
@ -420,17 +542,52 @@ export class Node {
_mbus.dispatch('NODE_MODIFIED', { node: this })
}// }}}
async save() {//{{{
// Just safeguarding not using the root node,
// which sort of exist but isn't supposed to communicate to server.
if (this.UUID == ROOT_NODE)
return
this.data.Content = this._content
this.data.Updated = new Date().toISOString()
this.data.HistoryUUID = uuidv7() // every time the node is saved a new history UUID identifies the changed node.
this._modified = false
_mbus.dispatch('NODE_UNMODIFIED')
// When stored into database and ancestry was changed,
// the ancestry path could be interesting.
/*
const ancestors = await nodeStore.getNodeAncestry(this)
this.data.Ancestors = ancestors.map(a => a.get('Name')).reverse()
*/
/* The node history is a local store for node history.
* This could be provisioned from the server or cleared if
* deemed unnecessary.
*
* The send queue is what will be sent back to the server
* to have a recorded history of the notes.
*
* A setting to be implemented in the future could be to
* not save the history locally at all. */
// Current node is added to history. It will be duplicated with the "nodes" store
// for simplicity, to hopefully avoid bugs.
const history = nodeStore.nodesHistory.add(this)
// Updated node is added to the send queue to be stored on server.
const sendQueue = nodeStore.sendQueue.add(this)
// Updated node is saved to the primary node store.
const nodeStoreAdding = nodeStore.add([this])
console.log('waiting')
await Promise.all([history, sendQueue, nodeStoreAdding])
console.log('waiting done')
return
}//}}}
}
// vim: foldmethod=marker

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() {
super()
window._mbus.subscribe('SHOW_PAGE', () => this.render())
window._mbus.subscribe('SHOW_PAGE', event => {
if (event.detail.data?.page == 'storage')
this.render()
})
}
async render() {
const countNodes = await globalThis.nodeStore.nodeCount()

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

View file

@ -19,8 +19,8 @@ export class Sync {
let nodeCountDownload = await this.getNodeCount(oldMax)
let nodeCountUpload = await nodeStore.sendQueue.count()
console.log(nodeCountUpload)
_mbus.dispatch('SYNC_START')
_mbus.dispatch('SYNC_DOWNLOAD_COUNT', { count: nodeCountDownload })
_mbus.dispatch('SYNC_UPLOAD_COUNT', { count: nodeCountUpload })
@ -81,15 +81,16 @@ export class Sync {
handled++
if (handled % 100 === 0)
_mbus.dispatch('SYNC_HANDLED', { handled })
_mbus.dispatch('SYNC_DOWNLOADED', { handled })
}
} while (res.Continue)
_mbus.dispatch('SYNC_HANDLED', { handled })
_mbus.dispatch('SYNC_DOWNLOADED', { handled })
nodeStore.setAppState('latest_sync_node', currMax)
} catch (e) {
console.log('sync node tree', e)
console.error('sync node tree', e)
alert(e.message)
} finally {
syncEnd = Date.now()
const duration = (syncEnd - syncStart) / 1000
@ -157,8 +158,8 @@ export class Sync {
_mbus.dispatch('SYNC_UPLOADED', { count: nodesToSend.length })
} catch (e) {
console.trace(e)
alert(e)
console.error(e)
alert(e.message)
return
}
}
@ -176,21 +177,23 @@ export class N2SyncProgress extends CustomHTMLElement {
}// }}}
constructor() {//{{{
super()
this.reset()
_mbus.subscribe('SYNC_START', () => this.reset())
_mbus.subscribe('SYNC_DOWNLOAD_COUNT', event => this.progressHandler(event))
_mbus.subscribe('SYNC_UPLOAD_COUNT', event => this.progressHandler(event))
_mbus.subscribe('SYNC_HANDLED', event => this.progressHandler(event))
_mbus.subscribe('SYNC_DONE', event => this.progressHandler(event))
_mbus.subscribe('SYNC_DOWNLOADED', event => this.progressHandler(event))
_mbus.subscribe('SYNC_UPLOADED', event => this.progressHandler(event))
_mbus.subscribe('SYNC_DONE', event => this.progressHandler(event))
}//}}}
reset() {//{{{
this.classList.remove('ok')
this.state = {
nodesToDownload: 0,
nodesToUpload: 0,
nodesSynced: 0,
nodesDowloaded: 0,
nodesUploaded: 0,
}
this.render()
}//}}}
progressHandler(event) {//{{{
const eventData = event.detail.data
@ -205,31 +208,32 @@ export class N2SyncProgress extends CustomHTMLElement {
this.setSyncState(true)
break
case 'SYNC_HANDLED':
console.log('SYNC_HANDLED', eventData.handled)
this.state.nodesSynced = eventData.handled
break
case 'SYNC_DONE':
// Hides the progress bar.
this.setSyncState(false)
// Don't update anything if nothing was synced.
if (this.state.nodesSynced === 0)
break
// Reload the tree nodes to reflect the new/updated nodes.
window._app.tree.reset()
case 'SYNC_DOWNLOADED':
this.state.nodesDowloaded = eventData.handled
break
case 'SYNC_UPLOADED':
this.state.nodesUploaded += eventData.count
break
case 'SYNC_DONE':
this.classList.add('ok')
// Hides the progress bar.
this.setSyncState(false)
// Don't update anything if nothing was synced.
if (this.state.nodesDowloaded === 0)
break
// Reload the tree nodes to reflect the new/updated nodes.
window._app.sidebar.reset()
break
}
this.render()
}//}}}
render() {//{{{
this.elDownloadTransferred.innerText = this.state.nodesSynced
this.elDownloadTransferred.innerText = this.state.nodesDowloaded
this.elDownloadTotal.innerText = this.state.nodesToDownload
this.elUploadTransferred.innerText = this.state.nodesUploaded
@ -239,6 +243,7 @@ export class N2SyncProgress extends CustomHTMLElement {
if (state)
this.classList.add('show')
else
// Give the user a chance to see what it ended on.
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" }}
<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 -->
<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" tabindex=0></div>
@ -10,31 +16,38 @@
<n2-pagestorage></n2-pagestorage>
</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 -->
<div id="page-node">
<div id="crumbs"></div>
<!--n2-syncprogress></n2-syncprogress-->
<n2-nodeui id="note"></n2-nodeui>
</div>
<!-- History -->
<div id="page-history">
<n2-pagehistory></n2-pagehistory>
</div>
<n2-pagehistory id="page-history"></n2-pagehistory>
<!-- Preferences -->
<n2-pagepreferences id="page-preferences"></n2-pagepreferences>
</div>
<n2-syncprogress></n2-syncprogress>
</div>
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/notes2.css">
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/page_history.css">
<script type="module">
import {NodeStore} from '/js/{{ .VERSION }}/node_store.mjs'
import {App} from "/js/{{ .VERSION }}/app.mjs"
import {API} from 'api'
import {Sync} from 'sync'
import { } from '/js/{{ .VERSION }}/page_preferences.mjs'
import { } from '/js/{{ .VERSION }}/page_storage.mjs'
import { } from '/js/{{ .VERSION }}/page_history.mjs'
import { } from '/js/{{ .VERSION }}/file.mjs'