Compare commits

..

No commits in common. "main" and "v6" have entirely different histories.
main ... v6

50 changed files with 1061 additions and 4518 deletions

View file

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

113
main.go
View file

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

89
node.go
View file

@ -3,8 +3,8 @@ package main
import ( import (
// External // External
werr "git.gibonuddevalla.se/go/wrappederror" werr "git.gibonuddevalla.se/go/wrappederror"
"github.com/derektata/lorem/ipsum"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/derektata/lorem/ipsum"
// Standard // Standard
"database/sql" "database/sql"
@ -44,7 +44,6 @@ type Node struct {
UUID string UUID string
UserID int `db:"user_id"` UserID int `db:"user_id"`
ParentUUID string `db:"parent_uuid"` ParentUUID string `db:"parent_uuid"`
HistoryUUID string `db:"history_uuid"`
Name string Name string
Created time.Time Created time.Time
Updated time.Time Updated time.Time
@ -54,7 +53,11 @@ type Node struct {
DeletedSeq sql.NullInt64 `db:"deleted_seq"` DeletedSeq sql.NullInt64 `db:"deleted_seq"`
Content string Content string
ContentEncrypted string `db:"content_encrypted" json:"-"` ContentEncrypted string `db:"content_encrypted" json:"-"`
Special bool Markdown bool
// CryptoKeyID int `db:"crypto_key_id"`
//Files []File
//ChecklistGroups []ChecklistGroup
} }
func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint64, moreRowsExist bool, err error) { // {{{ func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint64, moreRowsExist bool, err error) { // {{{
@ -75,7 +78,7 @@ func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint6
public.node public.node
WHERE WHERE
user_id = $1 AND user_id = $1 AND
( NOT history AND (
created_seq > $4 OR created_seq > $4 OR
updated_seq > $4 OR updated_seq > $4 OR
deleted_seq > $4 deleted_seq > $4
@ -123,7 +126,7 @@ func Nodes(userID, offset int, synced uint64, clientUUID string) (nodes []Node,
rows, err = db.Queryx(` rows, err = db.Queryx(`
SELECT SELECT
uuid, uuid,
COALESCE(parent_uuid, '00000000-0000-0000-0000-000000000000'::uuid) AS parent_uuid, COALESCE(parent_uuid, '') AS parent_uuid,
name, name,
created, created,
updated, updated,
@ -132,14 +135,14 @@ func Nodes(userID, offset int, synced uint64, clientUUID string) (nodes []Node,
updated_seq, updated_seq,
deleted_seq, deleted_seq,
content, content,
content_encrypted content_encrypted,
markdown
FROM FROM
public.node public.node
WHERE WHERE
NOT special AND
user_id = $1 AND user_id = $1 AND
client != $5::uuid AND client != $5 AND
( NOT history AND (
created_seq > $4 OR created_seq > $4 OR
updated_seq > $4 OR updated_seq > $4 OR
deleted_seq > $4 deleted_seq > $4
@ -192,7 +195,7 @@ func NodesCount(userID int, synced uint64, clientUUID string) (count int, err er
WHERE WHERE
user_id = $1 AND user_id = $1 AND
client != $3 AND client != $3 AND
( NOT history AND (
created_seq > $2 OR created_seq > $2 OR
updated_seq > $2 OR updated_seq > $2 OR
deleted_seq > $2 deleted_seq > $2
@ -245,72 +248,6 @@ func RetrieveNode(userID int, nodeUUID string) (node Node, err error) { // {{{
return return
} // }}} } // }}}
func RetrieveNodeHistory(userID int, nodeUUID string, offset int) (nodes []Node, hasMore bool, err error) { // {{{
nodes = []Node{}
var rows *sqlx.Rows
rows, err = db.Queryx(`
SELECT
uuid,
history_uuid,
user_id,
name,
created,
updated,
content,
content_encrypted
FROM node_history
WHERE
user_id = $1 AND
uuid = $2
LIMIT $3 OFFSET $4
`,
userID,
nodeUUID,
SYNC_PAGINATION+1,
offset,
)
if err != nil {
err = werr.Wrap(err)
return
}
defer rows.Close()
for rows.Next() {
node := Node{}
if err = rows.StructScan(&node); err != nil {
err = werr.Wrap(err)
return
}
nodes = append(nodes, node)
}
if len(nodes) > SYNC_PAGINATION {
hasMore = true
nodes = nodes[0 : len(nodes)-1]
}
return
} // }}}
func RetrieveNodeHistoryCount(userID int, nodeUUID string) (count int, err error) { // {{{
var row *sql.Row
row = db.QueryRow(`
SELECT
COUNT(*)
FROM node_history
WHERE
user_id = $1 AND
uuid = $2
`,
userID,
nodeUUID,
)
if err = row.Scan(&count); err != nil {
err = werr.Wrap(err)
return
}
return
} // }}}
func NodeCrumbs(nodeUUID string) (nodes []Node, err error) { // {{{ func NodeCrumbs(nodeUUID string) (nodes []Node, err error) { // {{{
var rows *sqlx.Rows var rows *sqlx.Rows
rows, err = db.Queryx(` rows, err = db.Queryx(`

View file

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

View file

@ -1,168 +0,0 @@
CREATE OR REPLACE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid character varying, IN p_nodes jsonb)
LANGUAGE plpgsql
AS $procedure$
DECLARE
node_data jsonb;
node_updated timestamptz;
db_updated timestamptz;
db_uuid bpchar;
db_client bpchar;
db_client_seq int;
node_uuid bpchar;
parent_uuid bpchar;
BEGIN
RAISE NOTICE '--------------------------';
FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes)
LOOP
node_uuid = (node_data->>'UUID')::bpchar;
node_updated = (node_data->>'Updated')::timestamptz;
IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' THEN
parent_uuid = NULL;
ELSE
parent_uuid = node_data->>'ParentUUID';
END IF;
/* Retrieve the current modified timestamp for this node from the database. */
SELECT
uuid, updated, client, client_sequence
INTO
db_uuid, db_updated, db_client, db_client_seq
FROM public."node"
WHERE
user_id = p_user_id AND
uuid = node_uuid;
/* Is the node not in database? It needs to be created. */
IF db_uuid IS NULL THEN
RAISE NOTICE '01 New node %', node_uuid;
INSERT INTO public."node" (
user_id, "uuid", parent_uuid, created, updated,
"name", "content", markdown, "content_encrypted",
client, client_sequence
)
VALUES(
p_user_id,
node_uuid,
parent_uuid,
(node_data->>'Created')::timestamptz,
(node_data->>'Updated')::timestamptz,
(node_data->>'Name')::varchar,
(node_data->>'Content')::text,
(node_data->>'Markdown')::bool,
'', /* content_encrypted */
p_client_uuid,
(node_data->>'ClientSequence')::int
);
CONTINUE;
END IF;
/* The client could send a specific node again if it didn't receive the OK from this procedure before. */
IF db_updated = node_updated AND db_client = p_client_uuid AND db_client_seq = (node_data->>'ClientSequence')::int THEN
RAISE NOTICE '04, already recorded, %, %', db_client, db_client_seq;
CONTINUE;
END IF;
/* Determine if the incoming node data is to go into history or replace the current node. */
IF db_updated > node_updated THEN
RAISE NOTICE '02 DB newer, % > % (%))', db_updated, node_updated, node_uuid;
/* Incoming node is going straight to history since it is older than the current node. */
INSERT INTO node_history(
user_id, "uuid", parents, created, updated,
"name", "content", markdown, "content_encrypted",
client, client_sequence
)
VALUES(
p_user_id,
node_uuid,
(jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors",
(node_data->>'Created')::timestamptz,
(node_data->>'Updated')::timestamptz,
(node_data->>'Name')::varchar,
(node_data->>'Content')::text,
(node_data->>'Markdown')::bool,
'', /* content_encrypted */
p_client_uuid,
(node_data->>'ClientSequence')::int
)
ON CONFLICT (client, client_sequence)
DO NOTHING;
ELSE
RAISE NOTICE '03 Client newer, % > % (%, %)', node_updated, db_updated, node_uuid, (node_data->>'ClientSequence');
/* Incoming node is newer and will replace the current node.
*
* The current node is copied to the node_history table and then modified in place
* with the incoming data. */
INSERT INTO node_history(
user_id, "uuid", parents,
created, updated, "name", "content", markdown, "content_encrypted",
client, client_sequence
)
SELECT
user_id,
"uuid",
(
WITH RECURSIVE nodes AS (
SELECT
uuid,
COALESCE(node.parent_uuid, '') AS parent_uuid,
name,
0 AS depth
FROM node
WHERE
uuid = node_uuid
UNION
SELECT
n.uuid,
COALESCE(n.parent_uuid, '') AS parent_uuid,
n.name,
nr.depth+1 AS depth
FROM node n
INNER JOIN nodes nr ON n.uuid = nr.parent_uuid
)
SELECT ARRAY (
SELECT name
FROM nodes
ORDER BY depth DESC
OFFSET 1 /* discard itself */
)
),
created,
updated,
name,
content,
markdown,
content_encrypted,
client,
client_sequence
FROM public."node"
WHERE
user_id = p_user_id AND
uuid = node_uuid
ON CONFLICT (client, client_sequence)
DO NOTHING;
/* Current node in database is updated with incoming data. */
UPDATE public."node"
SET
updated = (node_data->>'Updated')::timestamptz,
updated_seq = nextval('node_updates'),
name = (node_data->>'Name')::varchar,
content = (node_data->>'Content')::text,
markdown = (node_data->>'Markdown')::bool,
client = p_client_uuid,
client_sequence = (node_data->>'ClientSequence')::int
WHERE
user_id = p_user_id AND
uuid = node_uuid;
END IF;
END LOOP;
END
$procedure$
;

View file

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

View file

@ -1,135 +0,0 @@
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$
;

View file

@ -1,129 +0,0 @@
-- 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$
;

View file

@ -1,119 +0,0 @@
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$
;

View file

@ -1,119 +0,0 @@
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$
;

View file

@ -1,123 +0,0 @@
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$
;

View file

@ -1,35 +0,0 @@
-- 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();

View file

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

View file

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

View file

@ -4,169 +4,50 @@
--content-width: 900px; --content-width: 900px;
--thumbnail-width: 300px; --thumbnail-width: 300px;
--thumbnail-height: 100px; --thumbnail-height: 100px;
--colorize: invert(59%) sepia(71%) saturate(3270%) hue-rotate(327deg) brightness(100%) contrast(99%);
--line-color: #ccc;
--tree-expander: 0px;
--functions-width: 150px;
--menu-color: #fff;
--menu-item-hover-color: #f4f4f4;
--font-monospace: "Liberation Mono", monospace;
--markdown-copy-border: 1px solid #0a0;
--markdown-copy-background: #e3f4d7;
} }
html { html {
background-color: #fff; background-color: #fff;
} }
.colorize {
filter: var(--colorize);
}
textarea {
font-family: var(--font-monospace);
}
button {
font-size: 1em;
padding: 4px 8px;
}
/* ------------------------------------- *
* Default application grid in wide mode *
* ------------------------------------- */
#notes2 { #notes2 {
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
&.page-node {
grid-template-areas: grid-template-areas:
"tree-expander tree pad1 crumbs crumbs pad2" "tree hum crumbs crumbs ding"
"tree-expander tree pad1 name functions pad2" "tree hum name name ding"
"tree-expander tree pad1 content content pad2" "tree hum sync functions ding"
"tree hum content content ding"
"tree hum blank blank ding"
; ;
grid-template-columns: min-content minmax(16px, 1fr) minmax(min-content, calc(900px - 120px)) 120px minmax(16px, 1fr);
grid-template-columns:
/* Tree-expander */
var(--tree-expander)
/* Tree */
min-content minmax(32px, 1fr)
/* Sync */
minmax(min-content, calc(var(--content-width) - var(--functions-width)))
/* Functions */
var(--functions-width)
/* Content */
minmax(32px, 1fr);
grid-template-rows: grid-template-rows:
/* Crumbs */ min-content min-content 48px 1fr;
min-content
/* Name */
min-content
/* Content */
1fr;
}
/* The other pages just gets the whole page without dividing it up. */
&:not(.page-node) { @media only screen and (max-width: 600px) {
grid-template-areas: grid-template-areas:
"tree-expander tree pad1 n2-page pad2" "crumbs"
"sync"
"name"
"content"
"blank"
; ;
grid-template-columns: 1fr;
grid-template-columns:
/* Tree-expander */
var(--tree-expander)
/* Tree */
min-content
/* pad1 */
32px
/* Content */
1fr
/* pad2 */
32px;
grid-template-rows: 1fr;
}
/* Tree expander is collapsed as default */
--tree-expander: 0px;
&.hide-tree {
--tree-expander: 32px;
#tree {
border-right: none;
}
n2-sidebar {
display: none;
}
}
}
/* ------------------------------- *
* Application grid in narrow mode *
* ------------------------------- */
@media only screen and (max-width: 800px) {
#notes2 {
grid-template-areas:
"tree-expander pad1 crumbs crumbs pad2"
"tree-expander pad1 name functions pad2"
"tree-expander pad1 content content pad2"
;
grid-template-columns: 32px 16px 1fr var(--functions-width) 16px;
&.show-tree {
grid-template-areas: "tree";
grid-template-columns: 100%;
grid-template-rows: 1fr;
#tree {
display: grid;
width: 100%;
}
#main-page,
#show-tree {
display: none;
}
}
#tree { #tree {
display: none; display: none;
} }
n2-syncprogress {
.el-count {
top: 4px;
}
} }
}
#tree-expander {
grid-area: tree-expander;
color: #333;
background-color: #eee;
font-weight: bold;
border-right: 1px solid var(--line-color);
display: grid;
justify-items: center;
align-items: start;
padding-top: 8px;
font-size: 1.25em;
div div {
display: inline-block;
writing-mode: vertical-rl;
transform: rotate(180deg);
} }
}
}
#tree { #tree {
grid-area: tree; grid-area: tree;
@ -175,14 +56,47 @@ button {
color: #444; color: #444;
z-index: 100; z-index: 100;
border-right: 1px solid var(--line-color); border-right: 2px solid #ddd;
n2-sidebar { #logo {
display: grid;
grid-template-columns: min-content 1fr min-content;
align-items: center;
justify-items: start;
cursor: pointer;
padding: 16px;
border-bottom: 1px solid #ccc;
.el-search {
justify-self: end;
}
img:first-child {
height: 24px;
margin-right: 8px;
}
}
.icons {
display: flex;
justify-content: center;
margin: 16px 0px 32px 0px;
gap: 8px;
}
n2-tree {
.el-treenodes { .el-treenodes {
margin: 24px 32px 32px 32px; margin: 32px;
} }
} }
&:focus-within {
n2-tree {
}
}
.node { .node {
display: grid; display: grid;
grid-template-columns: 40px min-content; grid-template-columns: 40px min-content;
@ -199,11 +113,6 @@ button {
img { img {
width: auto; width: auto;
height: 18px; height: 18px;
&.deleted {
height: 24px;
transform: translateX(3px) translateY(3px);
}
} }
} }
@ -236,87 +145,56 @@ button {
} }
} }
/* =============== *
* PAGE MANAGEMENT *
* =============== */
[id^="page-"] { [id^="page-"] {
display: none; display: none;
} }
#notes2 {
&.page-node {
#page-root {
display: none;
}
#page-node {
display: contents;
}
}
&.page-storage {
#page-storage {
display: contents;
n2-pagestorage {
grid-area: n2-page;
}
}
}
&.page-history {
#page-history {
display: grid;
grid-area: n2-page;
}
}
&.page-preferences {
#page-preferences {
display: block;
grid-area: n2-page;
}
}
&.root-node-override {
[id^="page-"] {
display: none !important;
}
#page-root {
display: contents !important;
}
}
}
#main-page { #main-page {
display: contents; display: contents;
&:focus-within { &.node {
background-color: #faf; #page-node {
display: contents;
}
} }
&.storage {
#page-storage {
display: contents;
n2-pagestorage {
grid-area: content;
}
}
}
} }
#crumbs { #crumbs {
grid-area: crumbs; grid-area: crumbs;
display: grid; display: grid;
align-items: start; align-items: start;
justify-items: start; justify-items: center;
height: min-content; height: min-content;
margin: 0 16px 16px 0px; margin: 0 16px 16px 16px;
n2-crumbs { n2-crumbs {
background: #e4e4e4;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; padding: 8px 16px;
padding: 16px 0px; background: #e4e4e4;
color: #333; color: #333;
border-bottom-left-radius: 5px; border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px; border-bottom-right-radius: 5px;
&.node-modified {
background-color: var(--color1);
color: var(--color2);
.crumb:after {
color: var(--color2);
}
}
n2-crumb { n2-crumb {
margin-right: 8px; margin-right: 8px;
cursor: pointer; cursor: pointer;
@ -329,89 +207,85 @@ button {
} }
} }
n2-crumb:after {
n2-crumb:before {
content: ">"; content: ">";
font-weight: bold; font-weight: bold;
color: var(--color1) color: var(--color1)
} }
n2-crumb:last-child {
margin-right: 0;
}
n2-crumb.home { n2-crumb:last-child:after {
&:before {
content: ''; content: '';
margin-left: 0px; margin-left: 0px;
} }
img {
height: 24px;
}
}
} }
} }
n2-syncprogress { n2-syncprogress {
--radius: 8px;
display: grid; display: grid;
position: fixed; grid-area: sync;
top: 8px; display: grid;
right: 8px; justify-items: center;
padding: 8px 16px; align-items: center;
z-index: 16384;
border-radius: 6px; position: relative;
font-weight: bold;
background-color: var(--color1);
color: #fff;
box-shadow: rgba(0, 0, 0, 0.1) 0px 10px 15px -3px, rgba(0, 0, 0, 0.05) 0px 4px 6px -2px;
opacity: 0; opacity: 0;
transition: opacity 250ms; transition: height 0s 500ms, opacity 500ms linear, visibility 0s 500ms;
&.show { &.show {
opacity: 1; opacity: 1;
transition: visibility, height 0s, opacity 500ms linear;
} }
&.ok { progress {
background-color: #5aa02c; width: 100%;
height: 24px;
border-radius: 8px;
} }
grid-template-columns: min-content repeat(3, min-content); .count {
grid-gap: 8px 8px; position: absolute;
top: 16px;
width: 100%;
white-space: nowrap; white-space: nowrap;
align-items: center; color: #888;
justify-items: end; text-align: center;
font-size: 12pt;
img { font-weight: bold;
grid-row: 1/3;
height: 34px;
margin-right: 8px;
}
}
#page-root {
&>div {
grid-area: content;
align-self: start;
margin-top: 64px;
display: grid;
justify-items: center;
/* logo */
img {
margin-bottom: 16px;
height: 32px;
} }
.create { progress[value]::-webkit-progress-bar {
border: 2px solid #529b00; background-color: #eee;
padding: 16px 32px; box-shadow: 0 2px var(--radius) rgba(0, 0, 0, 0.25) inset;
margin-top: 64px; border-radius: var(--radius);
background-color: #d9ffc9; }
cursor: pointer;
progress[value]::-moz-progress-bar {
background-color: #eee;
box-shadow: 0 2px var(--radius) rgba(0, 0, 0, 0.25) inset;
border-radius: var(--radius);
} }
progress[value]::-webkit-progress-value {
background: rgb(186, 95, 89);
background: linear-gradient(180deg, rgba(186, 95, 89, 1) 0%, rgba(254, 95, 85, 1) 50%, rgba(186, 95, 89, 1) 100%);
border-radius: var(--radius);
} }
progress[value]::-moz-progress-value {
background: rgb(186, 95, 89);
background: linear-gradient(180deg, rgba(186, 95, 89, 1) 0%, rgba(254, 95, 85, 1) 50%, rgba(186, 95, 89, 1) 100%);
border-radius: var(--radius);
}
} }
/* ============================================================= */ /* ============================================================= */
@ -419,42 +293,25 @@ n2-syncprogress {
n2-nodeui { n2-nodeui {
margin-bottom: 32px; margin-bottom: 32px;
&.node-modified:before {
content: 'h';
z-index: 8192;
position: fixed;
top: 0px;
left: 0px;
right: 0px;
height: 4px;
background-color: var(--color1);
color: var(--color2);
}
.el-name { .el-name {
grid-area: name; grid-area: name;
color: #333; color: #333;
font-weight: bold; font-weight: bold;
font-size: 1.75em; text-align: center;
font-size: 1.15em;
margin-top: 8px; margin-top: 8px;
margin-bottom: 0px; margin-bottom: 0px;
white-space: nowrap;
width: min-content;
} }
.el-functions { .el-functions {
grid-area: functions; grid-area: functions;
justify-self: end;
align-self: end;
margin-bottom: 6px;
} }
.el-node-content { .el-node-content {
grid-area: content; grid-area: content;
justify-self: center; justify-self: center;
word-wrap: break-word; word-wrap: break-word;
font-size: 1em; font-family: monospace;
color: #333; color: #333;
width: 100%; width: 100%;
@ -468,24 +325,21 @@ n2-nodeui {
border-left: none; border-left: none;
border-right: none; border-right: none;
border-top: 1px solid #e0e0e0; border-top: 1px solid #e0e0e0;
border-bottom: none; border-bottom: 1px solid #e0e0e0;
margin-top: 8px;
margin-bottom: 32px; margin-bottom: 32px;
&:invalid {
padding-top: 16px;
}
} }
.el-node-markdown { .el-node-markdown {
grid-area: content; grid-area: content;
display: none; display: none;
font-family: var(--font-monospace);
font-size: 1em;
font-weight: 400;
border-top: 1px solid #e0e0e0; border-top: 1px solid #e0e0e0;
margin-top: 8px; border-bottom: 1px solid #e0e0e0;
margin-bottom: 32px; margin-bottom: 32px;
overflow-wrap: anywhere;
} }
&.show-markdown { &.show-markdown {
@ -546,46 +400,3 @@ dialog.op {
} }
} }
} }
/* ------------------------------------------- *
* Whole page is 100vh with scrolling sections *
* ------------------------------------------- */
#app.full-height {
#notes2 {
height: 100vh;
}
#tree {
n2-sidebar {
.el-treenodes {
height: calc(100vh - 64px - 64px);
margin: 0px;
padding: 12px 32px 32px 32px;
overflow-y: auto;
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none;
scrollbar-width: none;
}
}
}
n2-nodeui {
.el-node-markdown {
overflow-y: scroll;
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none;
scrollbar-width: none;
}
}
}

View file

@ -1,210 +0,0 @@
#page-history {
container-type: inline-size;
}
/* View when two columns doesn't fit on screen. */
@container (width < 1100px) {
n2-pagehistory {
grid-template-columns: 1fr minmax(300px, 900px) 1fr !important;
.column-2 {
grid-column: 2 / 3 !important;
}
}
}
/* View when not even one column with well on screen */
/* Node name is placed on a separate row. */
@container (width < 500px) {
.el-nodes {
grid-template-columns: min-content minmax(min-content, max-content) 1fr !important;
background-color: unset !important;
border: unset !important;
gap: unset !important;
.el-index {
border-top-left-radius: 6px;
border-left: 1px solid var(--line-color);
}
.el-index, .el-updated, .el-size {
border-top: 1px solid var(--line-color);
}
.el-size {
text-align: right;
border-right: 1px solid var(--line-color);
border-top-right-radius: 6px;
padding-right: 8px !important;
}
.el-name {
grid-column: 1 / -1;
padding-bottom: 8px;
padding-top: 0px;
border-bottom: 1px solid var(--line-color);
border-left: 1px solid var(--line-color);
border-right: 1px solid var(--line-color);
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
margin-bottom: 16px;
}
n2-pagehistorynode > * {
padding-left: 8px !important;
padding-right: 0px !important;
}
}
}
n2-pagehistory {
display: grid;
grid-template-rows: min-content min-content min-content;
grid-template-columns: 1fr minmax(600px, 800px) minmax(400px, 900px) 1fr;
grid-gap: 0px 32px;
.column-1 {
grid-column: 2 / 3;
}
.column-2 {
grid-column: 3 / 4;
max-width: 900px;
.group {
background-color: #fff;
}
}
.back,
.node-name {
grid-column: 2 / 4;
display: grid;
grid-template-columns: min-content 1fr;
grid-gap: 8px;
align-items: center;
}
.group-label {
font-weight: bold;
background-color: #444;
color: #fff;
padding: 8px 32px;
display: inline-block;
margin-left: 32px;
transform: translateY(14px);
border-radius: 6px;
}
.group {
border: 1px solid #ccc;
padding: 32px;
margin-bottom: 32px;
border-radius: 8px;
background-color: #fafafa;
box-shadow:
rgba(0, 0, 0, 0.4) 0px 2px 4px,
rgba(0, 0, 0, 0.3) 0px 7px 13px -3px,
rgba(0, 0, 0, 0.2) 0px -3px 0px inset;
}
.el-stats {
margin-bottom: 16px;
display: grid;
grid-template-columns: min-content 1fr;
grid-gap: 8px 12px;
white-space: nowrap;
}
.el-fetch-history-progress {
margin-top: 16px;
}
.el-back-image,
.el-back-text {
cursor: pointer;
}
.el-node-name {
margin-left: 8px;
}
.el-nodes {
grid-column: 1 / -1;
display: grid;
grid-template-columns: min-content minmax(min-content, max-content) min-content 1fr;
background-color: var(--line-color);
gap: 1px;
border: 1px solid var(--line-color);
n2-pagehistorynode>* {
padding: 8px 12px;
background-color: #fff;
white-space: nowrap;
}
n2-pagehistorynode {
&.selected .el-index:after {
position: absolute;
left: -20px;
content: '>';
color: var(--color1);
font-weight: bold;
margin-right: 8px;
}
.el-index {
position: relative;
text-align: right;
}
.el-updated {
white-space: initial;
}
.el-date {
white-space: nowrap;
font-weight: bold;
}
.el-time {
white-space: nowrap;
color: #555;
}
.el-name {
white-space: initial;
/*overflow-wrap: anywhere;*/
word-break: break-all;
color: var(--color1);
}
}
}
.el-pagination {
grid-column: 1 / -1;
margin-top: 16px;
display: grid;
grid-template-columns: repeat(3, min-content);
grid-gap: 16px;
align-items: center;
white-space: nowrap;
user-select: none;
.el-prev,
.el-next {
font-weight: bold;
cursor: pointer;
border: 1px solid #aaa;
background-color: #eee;
padding: 8px 16px;
border-radius: 4px;
}
}
}

View file

@ -1,49 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="23.999962"
height="24"
viewBox="0 0 6.3499898 6.35"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)"
sodipodi:docname="icon_back.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.81067621"
inkscape:cx="11.718612"
inkscape:cy="12.335381"
inkscape:window-width="1916"
inkscape:window-height="1161"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-101.82501,-145.32499)">
<title
id="title1">arrow-left-circle</title>
<path
d="m 101.82501,148.49999 a 3.1749998,3.1749998 0 0 1 3.175,-3.175 3.1749998,3.1749998 0 0 1 3.17499,3.175 3.1749998,3.1749998 0 0 1 -3.17499,3.175 3.1749998,3.1749998 0 0 1 -3.175,-3.175 m 5.08,-0.3175 H 104.365 l 1.11125,-1.11125 -0.45084,-0.45085 -1.87961,1.8796 1.87961,1.8796 0.45084,-0.45085 -1.11125,-1.11125 h 2.54001 z"
id="path1"
style="stroke-width:0.3175" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -1,71 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -1,75 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -1,49 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -1,49 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="7.40833mm"
height="6.3499999mm"
viewBox="0 0 7.40833 6.3499999"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)"
sodipodi:docname="icon_history.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="1"
inkscape:cx="10"
inkscape:cy="9"
inkscape:window-width="1916"
inkscape:window-height="1161"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-102.39375,-146.05)">
<title
id="title1">history</title>
<path
d="m 106.80347,147.81389 h -0.52916 v 1.76388 l 1.50988,0.89606 0.254,-0.42686 -1.23472,-0.73377 v -1.49931 M 106.62708,146.05 a 3.175,3.175 0 0 0 -3.175,3.175 h -1.05833 l 1.397,1.42169 1.42523,-1.42169 h -1.05834 a 2.4694444,2.4694444 0 0 1 2.46944,-2.46944 2.4694444,2.4694444 0 0 1 2.46944,2.46944 2.4694444,2.4694444 0 0 1 -2.46944,2.46944 c -0.68086,0 -1.29822,-0.27869 -1.74272,-0.72672 l -0.50094,0.50095 c 0.57502,0.57856 1.36172,0.93133 2.24366,0.93133 a 3.175,3.175 0 0 0 3.175,-3.175 3.175,3.175 0 0 0 -3.175,-3.175"
id="path1"
style="stroke-width:0.352777" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -1,71 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="24"
height="20.000013"
viewBox="0 0 6.3499998 5.2916702"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)"
sodipodi:docname="icon_home.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="34.879392"
inkscape:cx="11.797797"
inkscape:cy="8.3717056"
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="true">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="0.26458333"
spacingy="0.26458333"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="5"
enabled="true"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-102.39375,-146.31458)">
<title
id="title1">home-outline</title>
<path
id="path1"
style="stroke-width:0.264583;stroke-dasharray:none;fill:#666666"
d="m 105.56875,146.31458 -3.175,2.64583 0.79375,0 v 2.64584 l 2.11667,0 v -1.5875 h 0.26458 v -0.52917 H 104.775 v 1.5875 l -1.05833,0 0,-2.64583 1.85208,-1.5875 z"
sodipodi:nodetypes="cccccccccccccc" />
<path
id="path2"
style="stroke-width:0.264583;stroke-dasharray:none;fill:#666666"
d="m 105.56875,146.31458 3.175,2.64583 -0.79375,0 v 2.64584 l -2.11667,0 v -1.5875 h -0.26458 v -0.52917 h 0.79375 v 1.5875 l 1.05833,0 0,-2.64583 -1.85208,-1.5875 z"
sodipodi:nodetypes="cccccccccccccc" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -9,8 +9,8 @@
role="img" role="img"
version="1.1" version="1.1"
id="svg1" id="svg1"
sodipodi:docname="icon_markdown.svg" sodipodi:docname="markdown.svg"
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)" inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -33,10 +33,10 @@
inkscape:zoom="0.70710678" inkscape:zoom="0.70710678"
inkscape:cx="453.96255" inkscape:cx="453.96255"
inkscape:cy="60.811183" inkscape:cy="60.811183"
inkscape:window-width="1916" inkscape:window-width="2190"
inkscape:window-height="1161" inkscape:window-height="1401"
inkscape:window-x="0" inkscape:window-x="1463"
inkscape:window-y="18" inkscape:window-y="0"
inkscape:window-maximized="1" inkscape:window-maximized="1"
inkscape:current-layer="svg1" /> inkscape:current-layer="svg1" />
<title <title
@ -44,7 +44,7 @@
<path <path
d="M 338.73829,224.67957 H 26.316564 A 26.316564,26.316564 0 0 1 0,198.363 V 26.316564 A 26.316564,26.316564 0 0 1 26.316564,0 H 338.73829 a 26.316564,26.316564 0 0 1 26.31657,26.316564 V 198.33258 a 26.316564,26.316564 0 0 1 -26.31657,26.33177 z M 87.742162,172.01601 v -68.45349 l 35.109038,43.8863 35.09382,-43.8863 v 68.45349 h 35.10903 V 52.678763 H 157.94502 L 122.8512,96.565057 87.742162,52.678763 H 52.633128 V 172.04644 Z M 322.94835,112.33978 H 287.83932 V 52.663552 H 252.7455 v 59.676228 h -35.10904 l 52.64834,61.44081 z" d="M 338.73829,224.67957 H 26.316564 A 26.316564,26.316564 0 0 1 0,198.363 V 26.316564 A 26.316564,26.316564 0 0 1 26.316564,0 H 338.73829 a 26.316564,26.316564 0 0 1 26.31657,26.316564 V 198.33258 a 26.316564,26.316564 0 0 1 -26.31657,26.33177 z M 87.742162,172.01601 v -68.45349 l 35.109038,43.8863 35.09382,-43.8863 v 68.45349 h 35.10903 V 52.678763 H 157.94502 L 122.8512,96.565057 87.742162,52.678763 H 52.633128 V 172.04644 Z M 322.94835,112.33978 H 287.83932 V 52.663552 H 252.7455 v 59.676228 h -35.10904 l 52.64834,61.44081 z"
id="path1" id="path1"
style="stroke-width:15.2119;fill:#000000;fill-opacity:1" /> style="stroke-width:15.2119;fill:#fe5f55;fill-opacity:1" />
<metadata <metadata
id="metadata1"> id="metadata1">
<rdf:RDF> <rdf:RDF>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Before After
Before After

View file

@ -1,56 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -1,49 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 2 KiB

View file

@ -25,11 +25,11 @@
inkscape:document-units="mm" inkscape:document-units="mm"
inkscape:zoom="23.548693" inkscape:zoom="23.548693"
inkscape:cx="6.9218279" inkscape:cx="6.9218279"
inkscape:cy="12.612165" inkscape:cy="12.5697"
inkscape:window-width="1916" inkscape:window-width="1916"
inkscape:window-height="1161" inkscape:window-height="1161"
inkscape:window-x="0" inkscape:window-x="0"
inkscape:window-y="18" inkscape:window-y="0"
inkscape:window-maximized="1" inkscape:window-maximized="1"
inkscape:current-layer="layer1" inkscape:current-layer="layer1"
showgrid="false" /> showgrid="false" />
@ -45,6 +45,6 @@
<path <path
d="m 104.775,150.01875 a 1.5875,1.5875 0 0 1 -1.5875,-1.5875 c 0,-0.26458 0.0661,-0.52123 0.18521,-0.74083 l -0.38629,-0.3863 c -0.20638,0.32544 -0.32809,0.71173 -0.32809,1.12713 a 2.1166667,2.1166667 0 0 0 2.11667,2.11667 v 0.79375 l 1.05833,-1.05834 -1.05833,-1.05833 m 0,-2.91042 v -0.79375 l -1.05833,1.05834 1.05833,1.05833 v -0.79375 a 1.5875,1.5875 0 0 1 1.5875,1.5875 c 0,0.26458 -0.0661,0.52123 -0.18521,0.74083 l 0.38629,0.38629 c 0.20638,-0.32543 0.32809,-0.71172 0.32809,-1.12712 a 2.1166667,2.1166667 0 0 0 -2.11667,-2.11667 z" d="m 104.775,150.01875 a 1.5875,1.5875 0 0 1 -1.5875,-1.5875 c 0,-0.26458 0.0661,-0.52123 0.18521,-0.74083 l -0.38629,-0.3863 c -0.20638,0.32544 -0.32809,0.71173 -0.32809,1.12713 a 2.1166667,2.1166667 0 0 0 2.11667,2.11667 v 0.79375 l 1.05833,-1.05834 -1.05833,-1.05833 m 0,-2.91042 v -0.79375 l -1.05833,1.05834 1.05833,1.05833 v -0.79375 a 1.5875,1.5875 0 0 1 1.5875,1.5875 c 0,0.26458 -0.0661,0.52123 -0.18521,0.74083 l 0.38629,0.38629 c 0.20638,-0.32543 0.32809,-0.71172 0.32809,-1.12712 a 2.1166667,2.1166667 0 0 0 -2.11667,-2.11667 z"
id="path1" id="path1"
style="stroke-width:0.264583;fill:#000000;fill-opacity:1" /> style="stroke-width:0.264583;fill:#fe5f55;fill-opacity:1" />
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Before After
Before After

View file

@ -7,7 +7,7 @@
viewBox="0 0 4.7624998 4.7624998" viewBox="0 0 4.7624998 4.7624998"
version="1.1" version="1.1"
id="svg1" id="svg1"
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)" inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
sodipodi:docname="icon_save.svg" sodipodi:docname="icon_save.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
@ -24,12 +24,12 @@
inkscape:deskcolor="#d1d1d1" inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" inkscape:document-units="mm"
inkscape:zoom="5.6568542" inkscape:zoom="5.6568542"
inkscape:cx="41.365747" inkscape:cx="41.454135"
inkscape:cy="-3.7123106" inkscape:cy="-3.8890873"
inkscape:window-width="1916" inkscape:window-width="1093"
inkscape:window-height="1161" inkscape:window-height="1401"
inkscape:window-x="0" inkscape:window-x="2560"
inkscape:window-y="18" inkscape:window-y="0"
inkscape:window-maximized="1" inkscape:window-maximized="1"
inkscape:current-layer="layer1" /> inkscape:current-layer="layer1" />
<defs <defs
@ -44,6 +44,6 @@
<path <path
d="m 43.65625,209.81458 h -2.645833 v -1.05833 h 2.645833 m -0.79375,3.70417 a 0.79375,0.79375 0 0 1 -0.79375,-0.79375 0.79375,0.79375 0 0 1 0.79375,-0.79375 0.79375,0.79375 0 0 1 0.79375,0.79375 0.79375,0.79375 0 0 1 -0.79375,0.79375 m 1.322917,-4.23334 h -3.175 c -0.293688,0 -0.529167,0.23813 -0.529167,0.52917 v 3.70417 a 0.52916667,0.52916667 0 0 0 0.529167,0.52916 h 3.704166 a 0.52916667,0.52916667 0 0 0 0.529167,-0.52916 v -3.175 z" d="m 43.65625,209.81458 h -2.645833 v -1.05833 h 2.645833 m -0.79375,3.70417 a 0.79375,0.79375 0 0 1 -0.79375,-0.79375 0.79375,0.79375 0 0 1 0.79375,-0.79375 0.79375,0.79375 0 0 1 0.79375,0.79375 0.79375,0.79375 0 0 1 -0.79375,0.79375 m 1.322917,-4.23334 h -3.175 c -0.293688,0 -0.529167,0.23813 -0.529167,0.52917 v 3.70417 a 0.52916667,0.52916667 0 0 0 0.529167,0.52916 h 3.704166 a 0.52916667,0.52916667 0 0 0 0.529167,-0.52916 v -3.175 z"
id="path1" id="path1"
style="stroke-width:0.264583;fill:#000000;fill-opacity:1" /> style="stroke-width:0.264583;fill:#fe5f55;fill-opacity:1" />
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Before After
Before After

View file

@ -35,7 +35,7 @@
inkscape:window-width="1916" inkscape:window-width="1916"
inkscape:window-height="1161" inkscape:window-height="1161"
inkscape:window-x="0" inkscape:window-x="0"
inkscape:window-y="0" inkscape:window-y="18"
inkscape:window-maximized="1" inkscape:window-maximized="1"
inkscape:showpageshadow="true" inkscape:showpageshadow="true"
inkscape:pagecheckerboard="0" inkscape:pagecheckerboard="0"
@ -62,6 +62,6 @@
<path <path
d="m 78.736803,96.575592 a 40.634474,40.634474 0 0 1 40.634477,40.634438 c 0,10.06486 -3.68838,19.31694 -9.75227,26.44372 l 1.68795,1.68701 h 4.93863 l 31.2573,31.25736 -9.37719,9.37731 -31.25729,-31.25737 v -4.93863 l -1.68795,-1.68701 c -7.126666,6.06378 -16.378815,9.75204 -26.443681,9.75204 A 40.634474,40.634474 0 0 1 38.102322,137.21003 40.634474,40.634474 0 0 1 78.736803,96.575592 m 0,12.502758 c -15.628636,0 -28.131559,12.50299 -28.131559,28.13168 0,15.62868 12.502923,28.13144 28.131559,28.13144 15.628635,0 28.131557,-12.50276 28.131557,-28.13144 0,-15.62869 -12.502922,-28.13168 -28.131557,-28.13168 z" d="m 78.736803,96.575592 a 40.634474,40.634474 0 0 1 40.634477,40.634438 c 0,10.06486 -3.68838,19.31694 -9.75227,26.44372 l 1.68795,1.68701 h 4.93863 l 31.2573,31.25736 -9.37719,9.37731 -31.25729,-31.25737 v -4.93863 l -1.68795,-1.68701 c -7.126666,6.06378 -16.378815,9.75204 -26.443681,9.75204 A 40.634474,40.634474 0 0 1 38.102322,137.21003 40.634474,40.634474 0 0 1 78.736803,96.575592 m 0,12.502758 c -15.628636,0 -28.131559,12.50299 -28.131559,28.13168 0,15.62868 12.502923,28.13144 28.131559,28.13144 15.628635,0 28.131557,-12.50276 28.131557,-28.13144 0,-15.62869 -12.502922,-28.13168 -28.131557,-28.13168 z"
id="path1" id="path1"
style="stroke-width:6.25145;fill:#000000;fill-opacity:1" /> style="stroke-width:6.25145;fill:#fe5f55;fill-opacity:1" />
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Before After
Before After

View file

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

Before

Width:  |  Height:  |  Size: 2 KiB

View file

@ -8,7 +8,7 @@
version="1.1" version="1.1"
id="svg1" id="svg1"
sodipodi:docname="leaf.svg" sodipodi:docname="leaf.svg"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)" inkscape:version="1.4.2 (ebf0e94, 2025-05-08)"
xml:space="preserve" xml:space="preserve"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
@ -23,12 +23,12 @@
inkscape:pagecheckerboard="0" inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1" inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px" inkscape:document-units="px"
inkscape:zoom="31.614857" inkscape:zoom="11.17754"
inkscape:cx="5.0450964" inkscape:cx="8.0965937"
inkscape:cy="9.5682862" inkscape:cy="22.903072"
inkscape:window-width="2190" inkscape:window-width="1916"
inkscape:window-height="1401" inkscape:window-height="1161"
inkscape:window-x="1463" inkscape:window-x="0"
inkscape:window-y="18" inkscape:window-y="18"
inkscape:window-maximized="1" inkscape:window-maximized="1"
inkscape:current-layer="layer1" inkscape:current-layer="layer1"
@ -42,10 +42,6 @@
id="title1-1">folder-open-outline</title><title id="title1-1">folder-open-outline</title><title
id="title1-5">notebook-outline</title><title id="title1-5">notebook-outline</title><title
id="title1-8">text-box-outline</title><path 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" 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" id="path1"
style="fill:#ababab;fill-opacity:1;stroke-width:0.264583" style="fill:#ababab;fill-opacity:1;stroke-width:0.264583"

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

View file

@ -1,68 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -1,61 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -7,7 +7,7 @@
viewBox="0 0 7.5652731 5.2916666" viewBox="0 0 7.5652731 5.2916666"
version="1.1" version="1.1"
id="svg1" id="svg1"
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)" inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
sodipodi:docname="logo_small.svg" sodipodi:docname="logo_small.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
@ -23,13 +23,13 @@
inkscape:pagecheckerboard="0" inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1" inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px" inkscape:document-units="px"
inkscape:zoom="8.2386085" inkscape:zoom="16.477217"
inkscape:cx="48.491198" inkscape:cx="48.460855"
inkscape:cy="5.219328" inkscape:cy="5.2193281"
inkscape:window-width="1916" inkscape:window-width="2190"
inkscape:window-height="1161" inkscape:window-height="1401"
inkscape:window-x="0" inkscape:window-x="1463"
inkscape:window-y="0" inkscape:window-y="18"
inkscape:window-maximized="1" inkscape:window-maximized="1"
inkscape:current-layer="layer1" /> inkscape:current-layer="layer1" />
<defs <defs
@ -47,23 +47,17 @@
x="126.73541" x="126.73541"
y="178.59375" y="178.59375"
ry="1.0060203" /> ry="1.0060203" />
<path
d="m 114.29355,207.99469 h -0.57869 l -0.72335,-1.79391 v 1.79391 h -0.49188 v -3.03326 h 0.50153 l 0.79568,1.99645 v -1.99645 h 0.49671 z m 2.34365,-0.50635 v 0.50635 h -1.85178 v -0.99823 q 0,-0.78122 0.72817,-0.78122 h 0.48224 q 0.16396,-0.005 0.16396,-0.18325 v -0.40507 q 0,-0.17361 -0.13985,-0.17361 h -0.64137 q -0.0964,0 -0.0964,0.10609 v 0.21701 h -0.4967 v -0.14949 q 0,-0.67996 0.59797,-0.67996 h 0.59797 q 0.65584,0 0.65584,0.63655 v 0.43402 q 0,0.33756 -0.15431,0.52081 h 0.005 q -0.15432,0.18325 -0.6028,0.18325 h -0.40507 q -0.19772,0 -0.19772,0.25076 v 0.51599 z"
id="text5"
style="font-weight:bold;font-size:4.82235px;font-family:'Forgotten Futurist';-inkscape-font-specification:'Forgotten Futurist, Bold';fill:#ffffff;stroke-width:0.40186"
transform="scale(1.1392149,0.87779751)"
aria-label="N2" />
<text <text
xml:space="preserve" xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:4.82235px;font-family:'Forgotten Futurist';-inkscape-font-specification:'Forgotten Futurist, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;writing-mode:lr-tb;direction:ltr;fill:#ffffff;stroke:none;stroke-width:0.40186" style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:4.82235px;font-family:'Forgotten Futurist';-inkscape-font-specification:'Forgotten Futurist, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;writing-mode:lr-tb;direction:ltr;fill:#ffffff;stroke:none;stroke-width:0.40186"
x="112.25369" x="112.25369"
y="213.81186" y="207.99469"
id="text1" id="text5"
transform="scale(1.1392149,0.87779751)"><tspan transform="scale(1.1392149,0.87779751)"><tspan
sodipodi:role="line" sodipodi:role="line"
id="tspan1" id="tspan5"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:4.82235px;font-family:'Forgotten Futurist';-inkscape-font-specification:'Forgotten Futurist, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ffffff;stroke:none;stroke-width:0.40186" style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:4.82235px;font-family:'Forgotten Futurist';-inkscape-font-specification:'Forgotten Futurist, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ffffff;stroke:none;stroke-width:0.40186"
x="112.25369" x="112.25369"
y="213.81186">N2</tspan></text> y="207.99469">N2</tspan></text>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Before After
Before After

View file

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

View file

@ -1,72 +1,40 @@
import { ROOT_NODE } from 'node_store' import { ROOT_NODE } from 'node_store'
import { CustomHTMLElement } from './lib/custom_html_element.mjs' import { CustomHTMLElement } from './lib/custom_html_element.mjs'
import { N2Sidebar } from 'sidebar' import { N2Tree } from 'tree'
import { Node } from 'node' import { Node } from 'node'
import { N2PreferenceSet } from './page_preferences.mjs'
export class App { export class App {
static PAGES = ['node', 'history', 'storage']
constructor() {// {{{ constructor() {// {{{
this.currentNode = null this.currentNode = null
this.sidebar = new N2Sidebar() this.tree = new N2Tree()
this.crumbs = new N2Crumbs() this.crumbs = new N2Crumbs()
this.crumbsElement = document.getElementById('crumbs') this.crumbsElement = document.getElementById('crumbs')
this.nodeUI = document.getElementById('note') this.nodeUI = document.getElementById('note')
this.dragIcon = new N2DragIcon()
this.preferences = this.getPreferences() _mbus.subscribe('TREE_TRUNK_FETCHED', async () => {
document.getElementById('tree').append(this.tree.render())
this.sidebar.render().then(sidebar => {
document.getElementById('tree').append(sidebar)
document.getElementById('tree-nodes')?.focus() document.getElementById('tree-nodes')?.focus()
})
// Start node shows a system-wide page instead of node editing
// since the start node is kind of magic and doesn't fit into
// the syncing system.
const determineNodePage = uuid => {
const el = document.getElementById('notes2')
if (uuid == ROOT_NODE)
el.classList.add('root-node-override')
else
el.classList.remove('root-node-override')
}
_mbus.subscribe('TREE_RENDERED', async () => {
// 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() const startNode = await this.getStartNode()
determineNodePage(startNode.UUID)
this.goToNode(startNode.UUID, false, false) this.goToNode(startNode.UUID, false, false)
}) })
_mbus.subscribe('TREE_NODE_SELECTED', event => { _mbus.subscribe('TREE_NODE_SELECTED', event => {
const node = event.detail.data const node = event.detail.data
determineNodePage(node.UUID)
this.goToNode(node.UUID, false, false) this.goToNode(node.UUID, false, false)
}) })
_mbus.subscribe('GO_TO_NODE', event => { _mbus.subscribe('GO_TO_NODE', event => {
const node = event.detail.data const node = event.detail.data
determineNodePage(node.nodeUUID)
this.goToNode(node.nodeUUID, node.dontPush, node.dontExpand) this.goToNode(node.nodeUUID, node.dontPush, node.dontExpand)
}) })
_mbus.subscribe('SHOW_PAGE', ({ detail: { data: { page } } }) => { _mbus.subscribe('SHOW_PAGE', ({ detail: { data: { page } } }) => {
const classList = document.getElementById('notes2').classList const classList = document.querySelector('#main-page').classList
classList.forEach(e => { classList.forEach(e =>
if (e.startsWith('page-'))
classList.remove(e) classList.remove(e)
}) )
classList.add('page-' + page) classList.add(page)
})
_mbus.subscribe('DEVICE_PREFERENCE_SET_UPDATED', ()=>{
this.preferences = this.getPreferences()
console.log(this.preferences.data)
}) })
window.addEventListener('keydown', event => this.keyHandler(event)) window.addEventListener('keydown', event => this.keyHandler(event))
@ -76,9 +44,6 @@ export class App {
document.getElementById('node-content')?.focus() document.getElementById('node-content')?.focus()
}) })
document.querySelector('#page-root .create').addEventListener('click', () => this.createNode())
document.body.append(this.dragIcon)
_mbus.dispatch('SHOW_PAGE', { page: 'node' }) _mbus.dispatch('SHOW_PAGE', { page: 'node' })
window._sync = new Sync() window._sync = new Sync()
@ -88,53 +53,71 @@ export class App {
// There a slight delay to initiate sync seems reasonable. // There a slight delay to initiate sync seems reasonable.
setTimeout(() => window._sync.run(), 1000) setTimeout(() => window._sync.run(), 1000)
}// }}} }// }}}
keyHandler(event) {//{{{ keyHandler(event) {//{{{
let handled = true let handled = true
// Most keybindings is Alt+Shift, since the popular browsers at the time (2023) allows to override thees. // All keybindings is Alt+Shift, since the popular browsers at the time (2023) allows to override thees.
// Ctrl+S is the exception to using Alt+Shift, since it is overridable and in such widespread use for saving. // Ctrl+S is the exception to using Alt+Shift, since it is overridable and in such widespread use for saving.
// Thus, the exception is acceptable to consequent use of alt+shift. // Thus, the exception is acceptable to consequent use of alt+shift.
const CTRL = !event.shiftKey && event.ctrlKey && !event.altKey if (!(event.shiftKey && event.altKey) && !(event.key.toUpperCase() === 'S' && event.ctrlKey))
const SHIFT_ALT = event.shiftKey && !event.ctrlKey && event.altKey return
const SHIFT_CTRL_ALT = event.shiftKey && event.ctrlKey && event.altKey
switch (event.key.toUpperCase()) { switch (event.key.toUpperCase()) {
case 'F2':
this.nodeUI.renameNode()
break
case 'T': case 'T':
if (!SHIFT_ALT) { handled = false; break } if (document.activeElement.id === 'tree-nodes') {
if (document.activeElement.id === 'tree-nodes') console.log('take focus')
this.nodeUI.takeFocus() this.nodeUI.takeFocus()
else } else {
this.sidebar.focus() this.tree.focus()
}
break break
case 'F': case 'F':
if (!SHIFT_ALT) { handled = false; break }
_mbus.dispatch('op-search') _mbus.dispatch('op-search')
break break
/*
case 'C':
this.showPage('node')
break
case 'E':
this.showPage('keys')
break
*/
case 'M': case 'M':
if (!SHIFT_ALT) { handled = false; break }
globalThis._mbus.dispatch('MARKDOWN_TOGGLE') globalThis._mbus.dispatch('MARKDOWN_TOGGLE')
break break
case 'N': case 'N':
if (SHIFT_ALT)
this.createNode() this.createNode()
else if (SHIFT_CTRL_ALT) {
this.createNode(this.currentNode?.ParentUUID)
} else {
handled = false
}
break break
case 'S': /*
if (!CTRL) { handled = false; break } case 'P':
this.nodeUI.saveNode() this.showPage('node-properties')
break break
*/
case 'S':
this.saveNode()
/*
else if (this.page.value === 'node-properties')
this.nodeProperties.current.save()
*/
break
/*
case 'U':
this.showPage('upload')
break
case 'F':
this.showPage('search')
break
*/
default: default:
handled = false handled = false
} }
@ -158,28 +141,47 @@ export class App {
return await nodeStore.get(nodeUUID) return await nodeStore.get(nodeUUID)
}//}}} }//}}}
async saveNode() {//{{{ async saveNode() {//{{{
if (!this.currentNode.isModified())
return
}//}}} /* The node history is a local store for node history.
async moveNode(node, targetNodeUUID) {// {{{ * This could be provisioned from the server or cleared if
node.moveToParent(targetNodeUUID) * deemed unnecessary.
*
* The send queue is what will be sent back to the server
* to have a recorded history of the notes.
*
* A setting to be implemented in the future could be to
* not save the history locally at all. */
const node = this.currentNode
// The node is still in its old state and will present
// the unmodified content to the node store.
const history = nodeStore.nodesHistory.add(node)
// Prepares the node object for saving.
// Sets Updated value to current date and time.
await node.save() 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) // Updated node is added to the send queue to be stored on server.
const sendQueue = nodeStore.sendQueue.add(node)
// Updated node is saved to the primary node store.
const nodeStoreAdding = nodeStore.add([node])
await Promise.all([history, sendQueue, nodeStoreAdding])
}//}}}
async createNode() {//{{{
let name = prompt("Name")
if (!name) if (!name)
return return
const nn = Node.create(name, parentUUID) const nn = Node.create(name, this.currentNode.UUID)
await nn.save() nn.save()
nodeStore.sendQueue.add(nn)
nodeStore.add([nn])
// Treenode is forcefully rerendered and children refetched to both show the new node
// and to get it resorted.
const parentTreenode = this.sidebar.getTreeNode(parentUUID)
await parentTreenode.render(true, true)
_mbus.dispatch('GO_TO_NODE', { nodeUUID: nn.UUID })
}//}}} }//}}}
async goToNode(nodeUUID, dontPush, dontExpand) {//{{{ async goToNode(nodeUUID, dontPush, dontExpand) {//{{{
if (nodeUUID === null || nodeUUID === undefined) if (nodeUUID === null || nodeUUID === undefined)
@ -198,33 +200,16 @@ export class App {
node.reset() // any modifications are discarded. node.reset() // any modifications are discarded.
this.currentNode = node this.currentNode = node
this.sidebar.setSelected(node, dontExpand) this.tree.setSelected(node, dontExpand)
const ancestors = await nodeStore.getNodeAncestry(node) const ancestors = await nodeStore.getNodeAncestry(node)
// Scrolls node into view.
// makeVisible normally expands all ancestor nodes to make the whole chain visible.
// This is a bad idea when quickly navigating the tree, since the arrow navigation
// has collapsed nodes which the event calling goToNode can come to undo, if the
// event processing lags behind.
await this.sidebar.makeVisible(node, ancestors, dontExpand)
_mbus.dispatch('CRUMBS_SET', ancestors, () => this.crumbsElement.replaceChildren(this.crumbs.render())) _mbus.dispatch('CRUMBS_SET', ancestors, () => this.crumbsElement.replaceChildren(this.crumbs.render()))
_mbus.dispatch('NODE_UI_OPEN', node) _mbus.dispatch('NODE_UI_OPEN', node)
_mbus.dispatch('TREE_EXPANSION', { expand: false, when: 'narrow' })
_mbus.dispatch('NODE_UNMODIFIED') _mbus.dispatch('NODE_UNMODIFIED')
_mbus.dispatch('SHOW_PAGE', { page: 'node' })
// Scrolls node into view.
this.tree.makeVisible(node)
}//}}} }//}}}
pageIsVisible(page) {// {{{
let classList = document.querySelector('#main-page').classList
return classList.contains(page)
}// }}}
getPreferences() {// {{{
const devPrefSet = localStorage.getItem('device_preference_set') || 'default'
const userData = localStorage.getItem('user') || '{"default": {}}'
const user = JSON.parse(userData)
return new N2PreferenceSet(devPrefSet, user.Preferences[devPrefSet])
}// }}}
} }
class N2Crumbs extends CustomHTMLElement { class N2Crumbs extends CustomHTMLElement {
@ -251,13 +236,14 @@ class N2Crumbs extends CustomHTMLElement {
) )
) )
const start = new N2Crumb('', ROOT_NODE) const start = new N2Crumb('Start', ROOT_NODE)
crumbs.push(start) crumbs.push(start)
this.replaceChildren(...crumbs.reverse()) this.replaceChildren(...crumbs.reverse())
return this return this
}// }}} }// }}}
} }
customElements.define('n2-crumbs', N2Crumbs)
class N2Crumb extends CustomHTMLElement { class N2Crumb extends CustomHTMLElement {
static {// {{{ static {// {{{
@ -268,18 +254,8 @@ class N2Crumb extends CustomHTMLElement {
}// }}} }// }}}
constructor(label, uuid) {// {{{ constructor(label, uuid) {// {{{
super() super()
// The house makes it a bit more graphical than just a bunch of text.
if (uuid === ROOT_NODE) {
const start = document.createElement('div')
start.innerHTML = `<img src="/images/${_VERSION}/icon_home.svg">`
start.addEventListener('click', () => _mbus.dispatch("GO_TO_NODE", { nodeUUID: ROOT_NODE, dontPush: false, dontExpand: true }))
this.classList.add('home')
this.replaceChildren(start)
return
}
this.classList.add('crumb') this.classList.add('crumb')
this.label = label this.label = label
this.uuid = uuid this.uuid = uuid
@ -288,6 +264,7 @@ class N2Crumb extends CustomHTMLElement {
this.elLink.addEventListener('click', () => _mbus.dispatch("GO_TO_NODE", { nodeUUID: this.uuid, dontPush: false, dontExpand: true })) this.elLink.addEventListener('click', () => _mbus.dispatch("GO_TO_NODE", { nodeUUID: this.uuid, dontPush: false, dontExpand: true }))
}// }}} }// }}}
} }
customElements.define('n2-crumb', N2Crumb)
function tmpl(html) {// {{{ function tmpl(html) {// {{{
const el = document.createElement('template') const el = document.createElement('template')
@ -361,52 +338,4 @@ class OpSearch extends Op {
}// }}} }// }}}
} }
class N2DragIcon extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<style>
:host {
display: none;
position: absolute;
z-index: 16384;
pointer-events: none;
}
</style>
<img data-el="icon" src="/images/${_VERSION}/icon_drag.svg">
`
}// }}}
constructor() {// {{{
super(true)
document.addEventListener('dragover', e => {
this.style.left = `${e.clientX + 8}px`
this.style.top = `${e.clientY}px`
})
this.dragSource = null
}// }}}
start() {// {{{
this.style.display = 'block'
}// }}}
end() {// {{{
this.style.display = 'none'
}// }}}
icon(name) {// {{{
if (name != '')
name = '_' + name
this.elIcon.setAttribute('src', `/images/${_VERSION}/icon_drag${name}.svg`)
}// }}}
setSource(s) {// {{{
this.dragSource = s
}// }}}
getSource() {// {{{
return this.dragSource
}// }}}
}
customElements.define('n2-crumbs', N2Crumbs)
customElements.define('n2-crumb', N2Crumb)
customElements.define('n2-dragicon', N2DragIcon)
// vim: foldmethod=marker // vim: foldmethod=marker

View file

@ -1,17 +1,7 @@
/* Use data-el or data-field attribute.
* Element with data-el="hum-ding" is accessible as this.elHumDing and fields with
* data-field="long-dong" as this.fieldLongDong.
*
* All field values can be retrieved with fieldValues() and uses the data-field attribute
* as LongDong as key.
*/
export class CustomHTMLElement extends HTMLElement { export class CustomHTMLElement extends HTMLElement {
constructor(useShadow) {// {{{ constructor(useShadow) {// {{{
super() super()
this._fields = new Map()
const workOn = useShadow ? this.attachShadow({ mode: 'open' }) : this const workOn = useShadow ? this.attachShadow({ mode: 'open' }) : this
workOn.appendChild(this.constructor.tmpl.content.cloneNode(true)) workOn.appendChild(this.constructor.tmpl.content.cloneNode(true))
workOn.querySelectorAll('*').forEach(el => { workOn.querySelectorAll('*').forEach(el => {
@ -19,7 +9,6 @@ export class CustomHTMLElement extends HTMLElement {
if (field !== undefined) { if (field !== undefined) {
const fieldName = this.toElementName('field', field) const fieldName = this.toElementName('field', field)
this[fieldName] = el this[fieldName] = el
this._fields.set(this.toElementName('', field), el)
} }
const name = el.dataset.el const name = el.dataset.el
@ -30,22 +19,39 @@ export class CustomHTMLElement extends HTMLElement {
} }
}) })
}// }}} }// }}}
allFields() {// {{{
return this._fields
}// }}}
fieldValues() {// {{{
const state = {}
for (const [name, field] of this._fields) {
if (field.tagName.toLowerCase() == 'input' && field.getAttribute('type').toLowerCase() == 'checkbox')
state[name] = field.checked
else
state[name] = field.value
}
return state
}// }}}
toElementName(prefix, str) {// {{{ toElementName(prefix, str) {// {{{
str = prefix + '-' + str str = prefix + '-' + str
return str.replace(/-(id|[a-z])/g, match => match.toUpperCase().replace('-', '')) return str.replace(/-(id|[a-z])/g, match => match.toUpperCase().replace('-', ''))
}// }}} }// }}}
} }
export class StupidPreactCustomHTMLElement extends HTMLElement {
constructor() {// {{{
super()
// Stupid stuff because of Preact.
this.clonedNodes = this.constructor.tmpl.content.cloneNode(true)
this.clonedNodes.querySelectorAll('*').forEach(el => {
const field = el.dataset.field
if (field !== undefined) {
const fieldName = this.toElementName('field', field)
this[fieldName] = el
}
const name = el.dataset.el
if (name !== undefined) {
const elName = this.toElementName('el', name)
this[elName] = el
el.classList.add('el-' + name)
}
})
}// }}}
toElementName(prefix, str) {// {{{
str = prefix + '-' + str
return str.replace(/-(id|[a-z])/g, match => match.toUpperCase().replace('-', ''))
}// }}}
connectedCallback() {// {{{
// Stupid stuff because of Preact.
this.appendChild(this.clonedNodes)
}// }}}
}

View file

@ -92,9 +92,7 @@ function escapeHtmlEntities(html, encode) {// {{{
export class MarkedPosition { export class MarkedPosition {
constructor() {// {{{ constructor() {// {{{
window.marked_setpos = (event) => this.setpos(event) window.setpos = (event) => this.setpos(event)
window.marked_changecheckbox = (event) => this.changecheckbox(event)
window.marked_copy_to_clipboard = (event, tagname) => this.copy_to_clipboard(event, tagname)
this.render() this.render()
}// }}} }// }}}
setpos(event) {// {{{ setpos(event) {// {{{
@ -108,61 +106,20 @@ export class MarkedPosition {
} }
}) })
}// }}} }// }}}
changecheckbox(event) {// {{{
event.stopPropagation()
event.preventDefault()
_mbus.dispatch('MARKDOWN_CHANGE_CHECKBOX', {
checkbox: event.target,
position: {
start: event.target.closest('[data-offset-start]').dataset.offsetStart,
end: event.target.closest('[data-offset-start]').dataset.offsetEnd,
}
})
}// }}}
async copy_to_clipboard(event, tagname) {// {{{
if (!event.shiftKey)
return
try {
// Stop text selections on the page to the mouse pointer.
// Old selections are remove as well to give a cleaner view
// of the copied text/highlighting.
event.preventDefault()
event.stopPropagation()
window.getSelection().removeAllRanges()
const text = event.target.innerText
await navigator.clipboard.writeText(text)
const tagClasslist = event.target.closest(tagname).classList
tagClasslist.add('copy')
setTimeout(()=>tagClasslist.remove('copy'), 250)
} catch (err) {
console.error('Failed to copy: ', err)
alert('Failed to copy: ', err)
}
}// }}}
render() {// {{{ render() {// {{{
const markedObject = this
this.marked = new Marked() this.marked = new Marked()
this.marked.use(markedTokenPosition()) this.marked.use(markedTokenPosition())
this.marked.use({ this.marked.use({
renderer: { renderer: {
heading(token) { heading(token) {
const content = this.parser.parseInline(token.tokens) const content = this.parser.parseInline(token.tokens)
return ` return `<h${token.depth} ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${content}</h${token.depth}>\n`
<div class="heading-container" data-heading="${token.depth}">
<h${token.depth} ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${content}</h${token.depth}>\n
<div class="line"></div>\n
</div>
`
}, },
paragraph(token) { paragraph(token) {
const content = this.parser.parseInline(token.tokens) const content = this.parser.parseInline(token.tokens)
return `<p ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${content}</p>\n` return `<p ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${content}</p>\n`
}, },
list(token) { list(token) {
@ -181,7 +138,7 @@ export class MarkedPosition {
}, },
listitem(token) { listitem(token) {
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` 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`
}, },
code(token) { code(token) {
@ -190,12 +147,12 @@ export class MarkedPosition {
const code = token.text.replace(other.endingNewline, '') + '\n' const code = token.text.replace(other.endingNewline, '') + '\n'
if (!langString) { if (!langString) {
return `<pre ondblclick="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>` return `<pre ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"><code>`
+ (token.escaped ? code : escapeHtmlEntities(code, true)) + (token.escaped ? code : escapeHtmlEntities(code, true))
+ '</code></pre>\n' + '</code></pre>\n'
} }
return `<pre ondblclick="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-` return `<pre ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"><code class="language-`
+ escapeHtmlEntities(langString) + escapeHtmlEntities(langString)
+ '">' + '">'
+ (token.escaped ? code : escapeHtmlEntities(code, true)) + (token.escaped ? code : escapeHtmlEntities(code, true))
@ -204,7 +161,7 @@ export class MarkedPosition {
blockquote(token) { blockquote(token) {
const body = this.parser.parse(token.tokens) const body = this.parser.parse(token.tokens)
return `<blockquote ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">\n${body}</blockquote>\n` return `<blockquote ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">\n${body}</blockquote>\n`
}, },
html(token) { html(token) {
@ -216,13 +173,13 @@ export class MarkedPosition {
}, },
hr(token) { hr(token) {
return `<hr ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">\n` return `<hr ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">\n`
}, },
checkbox(token) { checkbox(token) {
return `<input ondblclick="marked_setpos(event)" onchange="marked_changecheckbox(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"` return `<input ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"`
+ (token.checked ? 'checked="" ' : '') + (token.checked ? 'checked="" ' : '')
+ 'type="checkbox"> ' + 'disabled="" type="checkbox"> '
}, },
table(token) { table(token) {
@ -265,7 +222,7 @@ export class MarkedPosition {
if (token.tokens.length > 0) { if (token.tokens.length > 0) {
const start = token.tokens[0].position.start.offset const start = token.tokens[0].position.start.offset
const end = token.tokens[0].position.end.offset const end = token.tokens[0].position.end.offset
ofs = `ondblclick="marked_setpos(event)" data-offset-start="${start}" data-offset-end="${end}"` ofs = `ondblclick="setpos(event)" data-offset-start="${start}" data-offset-end="${end}"`
} }
const content = this.parser.parseInline(token.tokens); const content = this.parser.parseInline(token.tokens);
@ -277,23 +234,23 @@ export class MarkedPosition {
}, },
strong(token) { strong(token) {
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>` 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>`
}, },
em(token) { em(token) {
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>` 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>`
}, },
codespan(token) { codespan(token) {
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>` return `<code ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${escapeHtmlEntities(token.text, true)}</code>`
}, },
br(token) { br(token) {
return `<br ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">` return `<br ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">`
}, },
del(token) { del(token) {
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>` 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>`
}, },
link(token) { link(token) {
@ -303,7 +260,7 @@ export class MarkedPosition {
return text return text
} }
token.href = cleanHref token.href = cleanHref
let out = '<a ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}" href="' + token.href + '"' let out = '<a ondblclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}" href="' + token.href + '"'
if (token.title) { if (token.title) {
out += ' title="' + (escapeHtmlEntities(token.title)) + '"' out += ' title="' + (escapeHtmlEntities(token.title)) + '"'
} }
@ -320,7 +277,7 @@ export class MarkedPosition {
return escapeHtmlEntities(token.text) return escapeHtmlEntities(token.text)
} }
token.href = cleanHref token.href = cleanHref
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)}"` 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)}"`
if (token.title) { if (token.title) {
out += ` title="${escapeHtmlEntities(token.title)}"` out += ` title="${escapeHtmlEntities(token.title)}"`
} }

View file

@ -1,8 +1,6 @@
import { Node } from 'node' import { Node } from 'node'
export const ROOT_NODE = '00000000-0000-0000-0000-000000000000' export const ROOT_NODE = '00000000-0000-0000-0000-000000000000'
export const ORPHANED_NODE = '00000000-0000-0000-0000-000000000001'
export const DELETED_NODE = '00000000-0000-0000-0000-000000000002'
export class NodeStore { export class NodeStore {
constructor() {//{{{ constructor() {//{{{
@ -15,8 +13,6 @@ export class NodeStore {
this.sendQueue = null this.sendQueue = null
this.nodesHistory = null this.nodesHistory = null
this.files = null this.files = null
this.initializeSpecialNodes()
}//}}} }//}}}
initializeDB() {//{{{ initializeDB() {//{{{
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -61,7 +57,7 @@ export class NodeStore {
break break
case 6: case 6:
nodesHistory = db.createObjectStore('nodes_history', { keyPath: ['UUID', 'HistoryUUID'] }) nodesHistory = db.createObjectStore('nodes_history', { keyPath: ['UUID', 'Updated'] })
break break
case 7: case 7:
@ -78,9 +74,10 @@ export class NodeStore {
req.onsuccess = (event) => { req.onsuccess = (event) => {
this.db = event.target.result this.db = event.target.result
this.sendQueue = new SimpleNodeStore(this.db, 'send_queue') this.sendQueue = new SimpleNodeStore(this.db, 'send_queue')
this.nodesHistory = new NodeHistoryStore(this.db, 'nodes_history') this.nodesHistory = new SimpleNodeStore(this.db, 'nodes_history')
this.files = new SimpleNodeStore(this.db, 'files') this.files = new SimpleNodeStore(this.db, 'files')
resolve() this.initializeRootNode()
.then(() => resolve())
} }
req.onerror = (event) => { req.onerror = (event) => {
@ -88,11 +85,40 @@ export class NodeStore {
} }
}) })
}//}}} }//}}}
initializeSpecialNodes() {// {{{ initializeRootNode() {//{{{
this.nodes[ROOT_NODE] = new Node({ UUID: ROOT_NODE, Name: 'Start', Special: true }, -1) return new Promise((resolve, reject) => {
this.nodes[DELETED_NODE] = new Node({ UUID: DELETED_NODE, Name: 'Deleted nodes', Special: true }, -1) // The root node is a magical node which displays as the first node if none is specified.
this.nodes[ORPHANED_NODE] = new Node({ UUID: ORPHANED_NODE, Name: 'Orphaned nodes', Special: true }, -1) // If not already existing, it will be created.
}// }}} const trx = this.db.transaction('nodes', 'readwrite')
const nodes = trx.objectStore('nodes')
const getRequest = nodes.get(ROOT_NODE)
getRequest.onsuccess = (event) => {
// Root node exists - nice!
if (event.target.result !== undefined) {
resolve(event.target.result)
return
}
const putRequest = nodes.put({
UUID: ROOT_NODE,
Name: 'Notes2',
Content: 'Hello, World!',
Updated: new Date().toISOString(),
ParentUUID: '',
})
putRequest.onsuccess = (event) => {
resolve(event.target.result)
}
putRequest.onerror = (event) => {
reject(event.target.error)
}
}
getRequest.onerror = (event) => reject(event.target.error)
})
}//}}}
purgeCache() {//{{{
this.nodes = {}
}//}}}
node(uuid, dataIfUndefined, newLevel) {//{{{ node(uuid, dataIfUndefined, newLevel) {//{{{
let n = this.nodes[uuid] let n = this.nodes[uuid]
@ -150,30 +176,14 @@ export class NodeStore {
const nodeStore = trx.objectStore('nodes') const nodeStore = trx.objectStore('nodes')
const index = nodeStore.index('byParent') const index = nodeStore.index('byParent')
const req = index.getAll(storeParent) const req = index.getAll(storeParent)
const hasChildrenPromises = []
req.onsuccess = (event) => { req.onsuccess = (event) => {
const nodes = [] const nodes = []
for (const i in event.target.result) { for (const i in event.target.result) {
const nodeData = event.target.result[i] const nodeData = event.target.result[i]
const node = this.node(nodeData.UUID, nodeData, newLevel) const node = this.node(nodeData.UUID, nodeData, newLevel)
// Look for the key of any children, a hopefully fast way
// to tell if any children exists at all and this node is a
// "folder". Needed quite early on for sorting.
const promise = new Promise((resolve, reject) => {
const countReq = index.getKey(nodeData.UUID)
countReq.onsuccess = event => {
node.setHasChildren(event.target.result !== undefined)
resolve()
}
})
hasChildrenPromises.push(promise)
nodes.push(node) nodes.push(node)
} }
resolve(nodes)
Promise.all(hasChildrenPromises)
.then(() => resolve(nodes))
} }
req.onerror = (event) => reject(event.target.error) req.onerror = (event) => reject(event.target.error)
}) })
@ -221,7 +231,6 @@ export class NodeStore {
nodeStore = t.objectStore('nodes') nodeStore = t.objectStore('nodes')
t.oncomplete = (_event) => { t.oncomplete = (_event) => {
console.log('complete')
resolve() resolve()
} }
@ -246,14 +255,6 @@ export class NodeStore {
}//}}} }//}}}
get(uuid, suppliedNodestore) {//{{{ get(uuid, suppliedNodestore) {//{{{
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
switch (uuid) {
case ROOT_NODE:
case DELETED_NODE:
case ORPHANED_NODE:
resolve(this.nodes[uuid])
return
}
// A nodestore can be provided in order to // A nodestore can be provided in order to
// avoid creating new transactions. // avoid creating new transactions.
let trx let trx
@ -291,16 +292,6 @@ export class NodeStore {
return return
} }
if (node.UUID === DELETED_NODE || node.ParentUUID === DELETED_NODE) {
resolve(accumulated)
return
}
if (node.UUID === ORPHANED_NODE || node.ParentUUID === ORPHANED_NODE) {
resolve(accumulated)
return
}
const getRequest = nodeParentIndex.get(node.ParentUUID) const getRequest = nodeParentIndex.get(node.ParentUUID)
getRequest.onsuccess = (event) => { getRequest.onsuccess = (event) => {
// Node not found in IndexedDB. // Node not found in IndexedDB.
@ -351,7 +342,6 @@ class SimpleNodeStore {
// Node to be moved is first stored in the new queue. // Node to be moved is first stored in the new queue.
const req = store.put(node.data) const req = store.put(node.data)
req.onsuccess = () => { req.onsuccess = () => {
console.log('here')
resolve() resolve()
} }
req.onerror = (event) => { req.onerror = (event) => {
@ -429,91 +419,28 @@ class SimpleNodeStore {
}//}}} }//}}}
} }
class NodeHistoryStore extends SimpleNodeStore { export class StoreFile {
constructor(db, storeName) {//{{{ static createFromFileObject(f) {
super(db, storeName) const obj = new StoreFile()
}//}}} obj.name = f.name
count(uuid) {//{{{ obj.size = f.size
if (uuid === undefined) obj.mime = f.type
return super.count() return obj
const index = this.db
.transaction(['nodes', this.storeName], 'readonly')
.objectStore(this.storeName)
.index('byUUID')
return new Promise((resolve, reject) => {
const request = index.count(uuid)
request.onsuccess = (event) => resolve(event.target.result)
request.onerror = (event) => reject(event.target.error)
})
}//}}}
hasNode(uuid, updated) {// {{{
return new Promise((resolve, reject) => {
const req = this.db
.transaction(['nodes', this.storeName], 'readonly')
.objectStore(this.storeName)
.getKey([uuid, updated])
req.onsuccess = (event) => {
resolve(event.target.result !== undefined)
} }
constructor() {
this.name = ''
this.size = 0
this.mime = ''
req.onerror = (event) => { this.objectURL = null // URL.createObjectURL(blob)
console.log(event.target.error)
reject(event.target.error)
} }
}) data() {
}// }}} return {
retrievePage(uuid, perPage, page) {// {{{
return new Promise((resolve, _reject) => {
const lowerBound = [uuid, '00000000-0000-0000-0000-000000000000']
const upperBound = [uuid, 'ffffffff-ffff-ffff-ffff-ffffffffffff']
const range = IDBKeyRange.bound(lowerBound, upperBound)
const cursor = this.db
.transaction(['nodes', this.storeName], 'readonly')
.objectStore(this.storeName)
.openCursor(range, 'prev')
let retrieved = 0
let first = true
const nodes = []
cursor.onsuccess = (event) => {
const cursor = event.target.result
if (!cursor) {
resolve(nodes)
return
}
// openCursor returns the first value which is only useful
// if the first page is requested.
if (page == 1 || !first) {
retrieved++
nodes.push(new Node(cursor.value))
if (retrieved === perPage) {
resolve(nodes)
return
}
cursor.continue()
return
}
// Jump to the start of the requested page.
// Minus one since the first record was already returned.
if (page > 1 && first) {
first = false
cursor.advance((perPage * (page - 1)))
return
} }
} }
})
}// }}}
} }
export function uuidv7() {// {{{ export function uuidv7() {
// random bytes // random bytes
const value = new Uint8Array(16) const value = new Uint8Array(16)
crypto.getRandomValues(value) crypto.getRandomValues(value)
@ -537,6 +464,6 @@ export function uuidv7() {// {{{
.map((b) => b.toString(16).padStart(2, "0")) .map((b) => b.toString(16).padStart(2, "0"))
.join("") .join("")
return `${str.slice(0, 8)}-${str.slice(8, 12)}-${str.slice(12, 16)}-${str.slice(16, 20)}-${str.slice(20)}` return `${str.slice(0, 8)}-${str.slice(8, 12)}-${str.slice(12, 16)}-${str.slice(16, 20)}-${str.slice(20)}`
}// }}} }
// vim: foldmethod=marker // vim: foldmethod=marker

View file

@ -1,319 +0,0 @@
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
import { Node } from './page_node.mjs'
import { MarkedPosition } from './marked_position.mjs'
export class N2PageHistory extends CustomHTMLElement {
static PAGESIZE = 15
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<style>
n2-pagehistory {
margin-top: 32px;
}
</style>
<div class="back">
<img data-el="back-image" src="/images/${_VERSION}/icon_back.svg" class="colorize">
<div data-el="back-text">Back to node</div>
</div>
<div class="node-name">
<img src="/images/${_VERSION}/icon_history.svg" class="colorize">
<h1 data-el="node-name"></h1>
</div>
<div class="column-1">
<div class="group-label">Actions</div>
<div class="group">
<button data-el="download-history">Fetch all history from server</button>
<div data-el="fetch-history-progress"></div>
</div>
<div class="group-label">History</div>
<div class="group">
<div data-el="stats">
<div>History on server:</div>
<div data-el="stats-on-server"></div>
<div>History on client:</div>
<div data-el="stats-on-client"></div>
</div>
<div data-el="nodes"></div>
<div data-el="pagination">
<div data-el="prev">&lt;</div>
<div data-el="page"></div>
<div data-el="next">&gt;</div>
</div>
</div>
</div>
<div class="column-2">
<div class="group-label">Document</div>
<div class="group">
<div data-el="node-markdown"></div>
</div>
</div>
`
}// }}}
constructor() {// {{{
super()
this.selectedNode = null
this.setAttribute('tabindex', '-1')
this.addEventListener('keydown', event => this.keyHandler(event))
// Connect back icon and text to give the user a way back to the node.
this.elBackImage.addEventListener('click', () => _mbus.dispatch('SHOW_PAGE', { page: 'node' }))
this.elBackText.addEventListener('click', () => _mbus.dispatch('SHOW_PAGE', { page: 'node' }))
this.elPrev.addEventListener('click', () => this.prevPage())
this.elNext.addEventListener('click', () => this.nextPage())
this.elDownloadHistory.addEventListener('click', async () => {
await this.downloadHistory()
await this.useNode(this.node)
this.render(true)
})
_mbus.subscribe('SHOW_PAGE', async (event) => {
if(event.detail.data.page != 'history')
return
await this.useNode(_app.nodeUI.node)
this.render()
})
_mbus.subscribe('HISTORY_NODE_SELECTED', (event) => {
this.selectedNode = event.detail.data.historyNode
// Any selected history node is rendered with markdown.
const marked = new MarkedPosition()
this.elNodeMarkdown.innerHTML = marked.parse(this.selectedNode?.node.content())
})
}// }}}
async render(keepFetchHistoryProgress) {// {{{
this.elNodeName.innerText = this.node.get('Name')
this.elPage.innerText = `${this.page} / ${this.pages}`
this.elStatsOnClient.innerText = `${this.nodesTotal}`
this.elStatsOnServer.innerText = `${this.historyOnServerTotal}`
if (this.nodesTotal <= N2PageHistory.PAGESIZE)
this.elPagination.style.display = 'none'
else
this.elPagination.style.display = ''
let nodes = await nodeStore.nodesHistory.retrievePage(this.node.UUID, N2PageHistory.PAGESIZE, this.page)
let i = 0
let divs = nodes.map(n => {
i++
const index = 1 + this.nodesTotal - (N2PageHistory.PAGESIZE * (this.page - 1) + i)
const div = new N2PageHistoryNode(n, index)
div.render()
return div
})
this.elNodes.replaceChildren(...divs)
if (!keepFetchHistoryProgress)
this.elFetchHistoryProgress.innerText = ''
// Select the first node.
if (!this.selectedNode) {
this.elNodes.firstElementChild?.select()
}
}// }}}
async useNode(node) {// {{{
this.node = node
this.page = 1
this.nodesTotal = await nodeStore.nodesHistory.count(this.node.UUID)
this.historyOnServerTotal = await this.getServerTotal()
this.pages = Math.ceil(this.nodesTotal / N2PageHistory.PAGESIZE)
}// }}}
keyHandler(event) {// {{{
let handled = true
switch (event.key) {
case 'ArrowLeft':
this.prevPage()
break
case 'ArrowRight':
this.nextPage()
break
case 'ArrowUp':
const prevNode = this.selectedNode?.previousElementSibling
if (prevNode)
prevNode.select()
break
case 'ArrowDown':
const nextNode = this.selectedNode?.nextElementSibling
if (nextNode)
nextNode.select()
break
default:
handled = false
}
if (handled) {
event.stopPropagation()
event.preventDefault()
}
}// }}}
prevPage() {// {{{
if (this.page == 1)
return
// Selecting a node on another page is wrong.
this.selectedNode = null
this.page--
this.render()
}// }}}
nextPage() {// {{{
if (this.page >= this.pages)
return
// Selecting a node on another page is wrong.
this.selectedNode = null
this.page++
this.render()
}// }}}
async getServerTotal() {// {{{
const res = await fetch(`/node/history/count/${this.node.UUID}`, {
headers: {
"Authorization": 'Bearer ' + localStorage.getItem('token'),
}
})
const json = await res.json()
if (!json.OK) {
alert(json.Error)
return
}
return json.Count
}// }}}
async downloadHistory() {// {{{
try {
const nodes = []
let offset = 0
let hasMore = true
while (hasMore) {
const history = await this.downloadHistoryPage(offset)
hasMore = history.HasMore
for (const nodeData of history.Nodes) {
nodes.push(new Node(nodeData))
}
offset = nodes.length
this.elFetchHistoryProgress.innerText = `${nodes.length} fetched.`
}
let num = 0
for (const node of nodes) {
const ok = await nodeStore.nodesHistory.hasNode(node.UUID, node.get('Updated'))
if (ok) num++
await nodeStore.nodesHistory.add(node)
}
this.elFetchHistoryProgress.innerText = `${nodes.length} fetched - all history fetched.`
} catch (e) {
console.error(e)
alert(e)
}
}// }}}
async downloadHistoryPage(offset) {// {{{
const res = await fetch(`/node/history/retrieve/${this.node.UUID}/${offset}`, {
headers: {
"Authorization": 'Bearer ' + localStorage.getItem('token'),
}
})
const json = await res.json()
if (!json.OK) {
alert(json.Error)
return
}
return json
}// }}}
}
customElements.define('n2-pagehistory', N2PageHistory)
class N2PageHistoryNode extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<div data-el="index"></div>
<div data-el="updated"><span data-el="date"></span> <span data-el="time"></span></div>
<div data-el="size"></div>
<div data-el="name"></div>
`
}// }}}
constructor(node, index) {// {{{
super()
this.node = node
this.index = index
this.style.display = 'contents'
this.selected = false
this.addEventListener('click', () => this.select())
// Another history node has been selected.
_mbus.subscribe('HISTORY_NODE_SELECTED', (event) => {
if (this.node.get('Updated') == event.detail.data.historyNode.node.get('Updated'))
return
this.selected = false
this.render()
})
}// }}}
select() {// {{{
this.selected = true
// Other nodes are told to unselect and rerender.
_mbus.dispatch('HISTORY_NODE_SELECTED', { historyNode: this })
this.render()
}// }}}
render() {// {{{
const date = this.node.get('Updated').slice(0, 10)
const time = this.node.get('Updated').slice(11, 19)
if (this.selected)
this.classList.add('selected')
else
this.classList.remove('selected')
this.elIndex.innerText = this.index
this.elDate.innerText = date
this.elTime.innerText = time
this.elSize.innerText = this.formatSize(this.node.get('Content').length)
this.elName.innerText = this.node.get('Name')
}// }}}
formatSize(s) {// {{{
let div = 1
let unit = 'B'
if (s >= 1048576) {
div = 1048576
unit = 'MB'
} else if (s >= 1024) {
div = 1024
unit = 'kB'
}
return new Intl.NumberFormat(undefined, {
maximumFractionDigits: 0
}).format(Math.round(s / div)) + ' ' + unit
}// }}}
}
customElements.define('n2-pagehistorynode', N2PageHistoryNode)

View file

@ -2,86 +2,21 @@ import { ROOT_NODE, uuidv7 } from 'node_store'
import { CustomHTMLElement } from './lib/custom_html_element.mjs' import { CustomHTMLElement } from './lib/custom_html_element.mjs'
import { MarkedPosition } from './marked_position.mjs' import { MarkedPosition } from './marked_position.mjs'
class N2NodeMenu extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<style>
n2-nodemenu {
margin: 8px 0;
padding: 0;
position-anchor: --node-menu;
box-shadow: rgba(0, 0, 0, 0.05) 0px 6px 24px 0px, rgba(0, 0, 0, 0.08) 0px 0px 0px 1px;
top: anchor(bottom);
right: anchor(right);
left: auto;
white-space: nowrap;
.menu-item {
padding: 8px 16px 8px 8px;
border-bottom: 1px solid var(--line-color);
display: grid;
grid-template-columns: min-content 1fr;
grid-gap: 16px;
align-items: center;
&:last-child {
border-bottom: unset;
}
&:hover {
background-color: var(--menu-item-hover-color);
}
}
}
</style>
<div class="node-menu">
<div class="menu-item" data-el="format-tables">
<img class="colorize" src="/images/${_VERSION}/icon_table.svg">
<div>Format tables</div>
</div>
<div class="menu-item" data-el="history">
<img class="colorize" src="/images/${_VERSION}/icon_history.svg">
<div>History</div>
</div>
</div>
`
}// }}}
constructor() {// {{{
super()
}// }}}
}
customElements.define('n2-nodemenu', N2NodeMenu)
export class N2PageNodeUI extends CustomHTMLElement { export class N2PageNodeUI extends CustomHTMLElement {
static {// {{{ static {// {{{
this.tmpl = document.createElement('template') this.tmpl = document.createElement('template')
this.tmpl.innerHTML = ` this.tmpl.innerHTML = `
<style> <style>
n2-nodeui > .el-functions { .el-functions {
display: grid; display: grid;
grid-template-columns: 1fr repeat(3, min-content); grid-template-columns: 1fr min-content min-content;
grid-gap: 8px; grid-gap: 8px;
align-items: center; align-items: center;
justify-items: end; justify-items: end;
cursor: pointer;
img { img {
height: 24px; height: 24px;
} }
.el-menu {
anchor-name: --node-menu;
border: 0;
padding: 0;
background-color: unset;
}
} }
</style> </style>
<div data-el="name"></div> <div data-el="name"></div>
@ -90,15 +25,10 @@ export class N2PageNodeUI extends CustomHTMLElement {
<div data-el="node-markdown" tabindex=1></div> <div data-el="node-markdown" tabindex=1></div>
<div data-el="functions"> <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-markdown">
<img data-el="icon-new-document" class="colorize" src="/images/${_VERSION}/icon_new_document.svg"> <img data-el="icon-save" src="/images/${_VERSION}/icon_save_disabled.svg">
<button data-el="menu" popovertarget="node-functions-menu"> <img data-el="icon-table-format" src="/images/${_VERSION}/icon_table.svg">
<img data-el="icon-menu" class="colorize" src="/images/${_VERSION}/icon_menu.svg">
</button>
<n2-nodemenu data-el="node-menu" id="node-functions-menu" popover></n2-nodemenu>
</div> </div>
` `
}// }}} }// }}}
@ -107,55 +37,45 @@ export class N2PageNodeUI extends CustomHTMLElement {
this.node = null this.node = null
this.style.display = 'contents' this.style.display = 'contents'
this.classList.add('show-markdown') // TODO Should probably be moved to settings.
this.marked = new MarkedPosition() this.marked = new MarkedPosition()
_mbus.subscribe('NODE_UI_OPEN', event => { _mbus.subscribe('NODE_UI_OPEN', event => {
this.node = event.detail.data this.node = event.detail.data
if (!this.node.isSpecial())
this.showMarkdown(true) this.showMarkdown(true)
this.render() this.render()
}) })
_mbus.subscribe('NODE_MODIFIED', () => { _mbus.subscribe('NODE_MODIFIED', () => {
this.classList.add('node-modified') document.querySelector('#crumbs .crumbs')?.classList.add('node-modified')
this.elIconSave.src = `/images/${_VERSION}/icon_save.svg` this.elIconSave.src = `/images/${_VERSION}/icon_save.svg`
this.elIconSave.classList.add('colorize')
this.renderName() this.renderName()
}) })
_mbus.subscribe('NODE_UNMODIFIED', () => { _mbus.subscribe('NODE_UNMODIFIED', () => {
this.classList.remove('node-modified') document.querySelector('#crumbs .crumbs')?.classList.remove('node-modified')
this.elIconSave.src = `/images/${_VERSION}/icon_save_disabled.svg` this.elIconSave.src = `/images/${_VERSION}/icon_save_disabled.svg`
this.elIconSave.classList.remove('colorize')
}) })
_mbus.subscribe('MARKDOWN_TOGGLE', () => this.showMarkdown(!this.showMarkdown())) _mbus.subscribe('MARKDOWN_TOGGLE', () => this.showMarkdown(!this.showMarkdown()))
_mbus.subscribe('MARKDOWN_EDIT', ({ detail }) => this.editMarkdown(detail.data)) _mbus.subscribe('MARKDOWN_EDIT', ({ detail }) => this.editMarkdown(detail.data))
_mbus.subscribe('MARKDOWN_CHANGE_CHECKBOX', ({ detail }) => this.checkboxUpdated(detail.data))
// Binding the node rename handler. this.elName.addEventListener('click', () => {
this.elName.addEventListener('click', async () => this.renameNode()) const name = prompt('Change title', this.node.data.Name)
if (name === null)
return
// Bind handlers for content keyboard input and paste. try {
this.node.setName(name)
} catch (err) {
console.error(err)
alert(err)
}
})
this.elNodeContent.addEventListener('input', event => this.contentChanged(event)) this.elNodeContent.addEventListener('input', event => this.contentChanged(event))
this.elNodeContent.addEventListener('paste', async (event) => this.pasteHandler(event)) this.elNodeContent.addEventListener('paste', async (event) => this.pasteHandler(event))
// Bind node icon handlers.
this.elIconSave.addEventListener('click', () => this.saveNode())
this.elIconMarkdown.addEventListener('click', () => this.showMarkdown(!this.showMarkdown())) this.elIconMarkdown.addEventListener('click', () => this.showMarkdown(!this.showMarkdown()))
this.elIconNewDocument.addEventListener('click', event => { this.elIconTableFormat.addEventListener('click', event => {
if (event.shiftKey)
_app.createNode(this.node.ParentUUID)
else
_app.createNode()
})
// Bind node menu items to handlers.
this.elNodeMenu.elFormatTables.addEventListener('click', event => {
this.elNodeMenu.hidePopover()
if (!event.shiftKey) if (!event.shiftKey)
this.elNodeContent.value = this.formatAllTables(this.elNodeContent.value) this.elNodeContent.value = this.formatAllTables(this.elNodeContent.value)
else { else {
@ -169,12 +89,7 @@ export class N2PageNodeUI extends CustomHTMLElement {
this.node.setContent(this.elNodeContent.value) this.node.setContent(this.elNodeContent.value)
}) })
this.elNodeMenu.elHistory.addEventListener('click', () => {
_mbus.dispatch('SHOW_PAGE', { page: 'history' })
})
// Default is to always show markdown.
this.classList.add('show-markdown') // TODO Should probably be moved to settings.
this.showMarkdown(true) this.showMarkdown(true)
}// }}} }// }}}
renderName() {// {{{ renderName() {// {{{
@ -187,41 +102,9 @@ export class N2PageNodeUI extends CustomHTMLElement {
}// }}} }// }}}
takeFocus() {// {{{ takeFocus() {// {{{
if (this.showMarkdown()) { if (this.showMarkdown()) {
this.elNodeMarkdown.focus({ preventScroll: true }) this.elNodeMarkdown.focus()
} else } else
this.elNodeContent.focus({ preventScroll: true }) this.elNodeContent.focus()
}// }}}
async renameNode() {// {{{
const name = prompt('Change title', this.node.data.Name)
if (name === null)
return
try {
// Document isn't only renamed, but also saved at once.
// Not really correct, but good enough to not have to implement
// a separate way to only rename the document. Since history is
// preserved it shouldn't be that horrible.
this.node.setName(name)
await this.node.save()
// Re-render the parent treenode forcefully to sort it again.
const parentUUID = this.node.ParentUUID
if (!parentUUID)
return
const parentTreeNode = _app.sidebar.getTreeNode(parentUUID)
parentTreeNode?.render(true, true)
} catch (err) {
console.error(err)
alert(err)
}
}// }}}
async saveNode() {// {{{
if (!this.node.isModified())
return
// node.save takes care of both "nodes" and "nodes_history" stores, also adds it to send queue.
// Sets "Updated" value to current date and time and generates a new history UUID.
await this.node.save()
}// }}} }// }}}
contentChanged(event) {//{{{ contentChanged(event) {//{{{
@ -240,12 +123,10 @@ export class N2PageNodeUI extends CustomHTMLElement {
case true: case true:
this.elNodeMarkdown.innerHTML = this.marked.parse(this.elNodeContent.value) this.elNodeMarkdown.innerHTML = this.marked.parse(this.elNodeContent.value)
this.elIconMarkdown.src = `/images/${_VERSION}/icon_markdown.svg` this.elIconMarkdown.src = `/images/${_VERSION}/icon_markdown.svg`
this.elIconMarkdown.classList.add('colorize')
this.classList.add('show-markdown') this.classList.add('show-markdown')
break break
case false: case false:
this.elIconMarkdown.src = `/images/${_VERSION}/icon_markdown_hollow.svg` this.elIconMarkdown.src = `/images/${_VERSION}/icon_markdown_hollow.svg`
this.elIconMarkdown.classList.remove('colorize')
this.classList.remove('show-markdown') this.classList.remove('show-markdown')
break break
case null: case null:
@ -371,61 +252,25 @@ export class N2PageNodeUI extends CustomHTMLElement {
return lines return lines
}// }}} }// }}}
// "marked" sends a messagebus event when checking/unchecking a checkbox.
// Updates node and content textarea.
checkboxUpdated(eventData) {// {{{
const checkbox = eventData.checkbox
const pos = eventData.position
const content = this.node.content()
// Basic validation to verify that Marked does what is known and expected at this writing.
const mdCheckboxStr = content.slice(pos.start, pos.end)
if (!mdCheckboxStr.match(/^\[[ xX]\] $/)) {
alert(`Checkbox string didn't pass validation: '${mdCheckboxStr}'`)
console.error(`Checkbox string didn't pass validation: '${mdCheckboxStr}'`)
}
// Node is modified with the new value. User has to save manually, otherwise other changes could be saved
// when a save wasn't expected.
const newValue = `[${checkbox.checked ? 'x' : ' '}] `
const modifiedContent = this.node.content().slice(0, pos.start) + newValue + this.node.content().slice(pos.end)
this.node.setContent(modifiedContent)
// Also update the textarea since the node model doesn't know about it.
this.elNodeContent.setRangeText(newValue, pos.start, pos.end, 'select')
}// }}}
} }
customElements.define('n2-nodeui', N2PageNodeUI) customElements.define('n2-nodeui', N2PageNodeUI)
export class Node { export class Node {
static sort(a, b) {//{{{ static sort(a, b) {//{{{
// Nodes with children ("folders") are sorted first. if (a.data.Name < b.data.Name) return -1
if (a._has_children && !b._has_children) return -1 if (a.data.Name > b.data.Name) return 0
if (!a._has_children && b._has_children) return 1
// Otherwise sort by lowercased name.
const an = a.data.Name.toLowerCase()
const bn = b.data.Name.toLowerCase()
if (an < bn) return -1
if (an > bn) return 1
return 0 return 0
}//}}} }//}}}
static create(name, parentUUID) {// {{{ static create(name, parentUUID) {// {{{
const node = new Node({ return new Node({
UUID: uuidv7(), UUID: uuidv7(),
Created: (new Date()).toISOString(), Created: (new Date()).toISOString(),
Content: '', Content: '',
Name: name, Name: name,
ParentUUID: parentUUID, ParentUUID: parentUUID,
Markdown: false, Markdown: false,
History: false,
}) })
// Newly created node (not constructed from existing data) is considered modified
// since node.save returns early if it isn't modified.
node._modified = true
return node
}// }}} }// }}}
constructor(nodeData, level) {//{{{ constructor(nodeData, level) {//{{{
@ -441,7 +286,6 @@ export class Node {
this.ParentUUID = nodeData.ParentUUID this.ParentUUID = nodeData.ParentUUID
this._children_fetched = false this._children_fetched = false
this._has_children = null // this will be set by nodeStore.getTreeNodes
this.Children = [] this.Children = []
this.Ancestors = [] this.Ancestors = []
@ -478,7 +322,6 @@ export class Node {
this.Children.sort(Node.sort) this.Children.sort(Node.sort)
const numChildren = this.Children.length const numChildren = this.Children.length
this.setHasChildren(numChildren > 0)
for (let i = 0; i < numChildren; i++) { for (let i = 0; i < numChildren; i++) {
if (i > 0) if (i > 0)
this.Children[i]._sibling_before = this.Children[i - 1] this.Children[i]._sibling_before = this.Children[i - 1]
@ -487,13 +330,14 @@ export class Node {
this.Children[i]._parent = this this.Children[i]._parent = this
} }
// Notify the tree that all children are fetched and ready to process.
//_notes2.current.tree.fetchChildrenOn(this.UUID)
_mbus.dispatch(`NODE_CHILDREN_FETCHED_${this.UUID}`)
return this.Children return this.Children
}//}}} }//}}}
setHasChildren(v) {// {{{
this._has_children = v
}// }}}
hasChildren() {//{{{ hasChildren() {//{{{
return this._has_children return this.Children.length > 0
}//}}} }//}}}
getSiblingBefore() {// {{{ getSiblingBefore() {// {{{
return this._sibling_before return this._sibling_before
@ -504,23 +348,12 @@ export class Node {
getParent() {//{{{ getParent() {//{{{
return this._parent return this._parent
}//}}} }//}}}
moveToParent(newParentUUID) {// {{{
if (this.UUID === newParentUUID)
throw new Error("New parent UUID is the same as node UUID. Can't be your own parent.")
this.ParentUUID = newParentUUID
this.data.ParentUUID = newParentUUID
this._modified = true
}// }}}
isLastSibling() {//{{{ isLastSibling() {//{{{
return this._sibling_after === null return this._sibling_after === null
}//}}} }//}}}
isFirstSibling() {//{{{ isFirstSibling() {//{{{
return this._sibling_before === null return this._sibling_before === null
}//}}} }//}}}
isSpecial() {// {{{
return this.data.Special
}// }}}
content() {//{{{ content() {//{{{
/* TODO - implement crypto /* TODO - implement crypto
if (this.CryptoKeyID != 0 && !this._decrypted) if (this.CryptoKeyID != 0 && !this._decrypted)
@ -542,52 +375,17 @@ export class Node {
_mbus.dispatch('NODE_MODIFIED', { node: this }) _mbus.dispatch('NODE_MODIFIED', { node: this })
}// }}} }// }}}
async save() {//{{{ async save() {//{{{
// Just safeguarding not using the root node,
// which sort of exist but isn't supposed to communicate to server.
if (this.UUID == ROOT_NODE)
return
this.data.Content = this._content this.data.Content = this._content
this.data.Updated = new Date().toISOString() this.data.Updated = new Date().toISOString()
this.data.HistoryUUID = uuidv7() // every time the node is saved a new history UUID identifies the changed node.
this._modified = false this._modified = false
_mbus.dispatch('NODE_UNMODIFIED') _mbus.dispatch('NODE_UNMODIFIED')
// When stored into database and ancestry was changed, // When stored into database and ancestry was changed,
// the ancestry path could be interesting. // the ancestry path could be interesting.
/*
const ancestors = await nodeStore.getNodeAncestry(this) const ancestors = await nodeStore.getNodeAncestry(this)
this.data.Ancestors = ancestors.map(a => a.get('Name')).reverse() this.data.Ancestors = ancestors.map(a => a.get('Name')).reverse()
*/
/* The node history is a local store for node history.
* This could be provisioned from the server or cleared if
* deemed unnecessary.
*
* The send queue is what will be sent back to the server
* to have a recorded history of the notes.
*
* A setting to be implemented in the future could be to
* not save the history locally at all. */
// Current node is added to history. It will be duplicated with the "nodes" store
// for simplicity, to hopefully avoid bugs.
const history = nodeStore.nodesHistory.add(this)
// Updated node is added to the send queue to be stored on server.
const sendQueue = nodeStore.sendQueue.add(this)
// Updated node is saved to the primary node store.
const nodeStoreAdding = nodeStore.add([this])
console.log('waiting')
await Promise.all([history, sendQueue, nodeStoreAdding])
console.log('waiting done')
return
}//}}} }//}}}
} }
// vim: foldmethod=marker // vim: foldmethod=marker

View file

@ -1,283 +0,0 @@
import { CustomHTMLElement } from "./lib/custom_html_element.mjs"
import { API } from './api.mjs'
export class N2PagePreferences extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<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,10 +13,7 @@ export class N2PageStorage extends CustomHTMLElement {
constructor() { constructor() {
super() super()
window._mbus.subscribe('SHOW_PAGE', event => { window._mbus.subscribe('SHOW_PAGE', () => this.render())
if (event.detail.data?.page == 'storage')
this.render()
})
} }
async render() { async render() {
const countNodes = await globalThis.nodeStore.nodeCount() const countNodes = await globalThis.nodeStore.nodeCount()

View file

@ -1,771 +0,0 @@
import { ROOT_NODE, ORPHANED_NODE, DELETED_NODE } from 'node_store'
import { Node } from 'node'
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
import { Color, Solver } from './lib/css_colorize.mjs'
// TreeExpandedHandler is responsible for collapsing or expanding
// the node tree, wide view or narrow "mobile" view.
class TreeExpansionHandler {// {{{
constructor() {
this.isNarrow = false
this.initializeMediaHandler()
this.initializeBusEvents()
}
initializeBusEvents() {
_mbus.subscribe('TREE_EXPANSION', ({ detail }) => {
// When a node is selected on the screen and the screen
// is narrow the tree is automatically hidden.
//
// Can't always hide the tree automatically when a node
// is selected since the wide mode shows the tree as standard.
if (detail.data?.when == 'narrow' && !this.isNarrow)
return
this.treeExpansion(detail.data?.expand)
})
}
initializeMediaHandler() {
const query = window.matchMedia('(max-width: 800px)')
query.addEventListener('change', event => this.screenNarrowHandler(event))
// Run once to set initial state, instead of needing to toggle state.
this.screenNarrowHandler(query)
}
// When screen becomes narrow, the tree is automatically hidden.
// Primary purpose is to read content, not browse, which is why
// the tree is hidden as standard.
screenNarrowHandler(event) {
this.isNarrow = event.matches
if (this.isNarrow)
this.treeExpansion(false)
else
this.treeExpansion(true)
}
treeExpansion(expanded) {
const notes2 = document.getElementById('notes2')
if (expanded) {
notes2.classList.remove('hide-tree')
notes2.classList.add('show-tree')
} else {
notes2.classList.add('hide-tree')
notes2.classList.remove('show-tree')
}
}
}// }}}
export class N2Sidebar extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<style>
n2-sidebar {
#logo {
display: grid;
grid-template-columns: 1fr min-content;
align-items: center;
justify-items: start;
cursor: pointer;
padding: 16px;
border-bottom: 1px solid var(--line-color);
img:first-child {
height: 24px;
margin-right: 8px;
}
}
.icons {
display: flex;
justify-content: center;
margin-top: 16px;
gap: 8px;
padding-bottom: 16px;
border-bottom: 1px solid var(--line-color);
}
.el-hide-tree {
font-size: 1.25em;
font-weight: bold;
cursor: pointer;
margin-left: 16px;
}
}
</style>
<div id="logo">
<img data-el="logo" src="/images/${_VERSION}/logo.svg" />
<div data-el="hide-tree">&lt;</div>
</div>
<div class="icons">
<img data-el="sync" class='sync colorize' src="/images/${_VERSION}/icon_refresh.svg" />
<img data-el="search" class='search colorize' src="/images/${_VERSION}/icon_search.svg" style="height: 22px" />
<img data-el="settings" class='settings colorize' src="/images/${_VERSION}/icon_settings.svg" />
</div>
<div data-el="treenodes"></div>
`
}// }}}
constructor() {// {{{
super()
this.id = 'tree-nodes'
this.tabIndex = 0
this.treeNodeComponents = {}
this.expandedNodes = {} // keyed on UUID
this.selectedNode = null
this.rendered = false
new TreeExpansionHandler()
this.addEventListener('keydown', event => this.keyHandler(event))
this.elSearch.addEventListener('click', () => _mbus.dispatch('op-search'))
this.elSync.addEventListener('click', () => _sync.run())
this.elLogo.addEventListener('click', () => _app.goToNode(ROOT_NODE, false, false))
this.elSettings.addEventListener('click', ()=> _mbus.dispatch('SHOW_PAGE', { page: 'preferences' }))
this.elHideTree.addEventListener('click', event => {
event.stopPropagation()
_mbus.dispatch('TREE_EXPANSION', { expand: false })
})
_mbus.subscribe('NODE_MODIFIED', ({ detail }) => {
const node = detail.data.node
const treenode = this.treeNodeComponents[node.get('UUID')]
if (!treenode)
return
treenode.node = node
treenode.render(true)
})
/* XXX - set color */
let color = new Color(0x80, 0x00, 0x33)
let solver = new Solver(color)
let result = solver.solve()
console.log(result.filter)
}// }}}
async render() {// {{{
if (this.rendered)
alert('Tree should only be rendered once.')
this.expandedNodes[ROOT_NODE] = true
const startnode = await nodeStore.get(ROOT_NODE)
const starttreenode = new N2TreeNode(this, startnode, null)
const deletednode = await nodeStore.get(DELETED_NODE)
const deletedtreenode = new SpecialNodeDeleted(this, deletednode, null)
const orphanednode = await nodeStore.get(ORPHANED_NODE)
const orphanedtreenode = new SpecialNodeOrphaned(this, orphanednode, null)
startnode._sibling_after = deletednode
deletednode._sibling_before = startnode
deletednode._sibling_after = orphanednode
orphanednode._sibling_before = deletednode
this.treeNodeComponents[startnode.UUID] = starttreenode
this.treeNodeComponents[deletednode.UUID] = deletedtreenode
this.treeNodeComponents[orphanednode.UUID] = orphanedtreenode
this.elTreenodes.appendChild(await starttreenode.render())
this.elTreenodes.appendChild(await deletedtreenode.render())
this.elTreenodes.appendChild(await orphanedtreenode.render())
// Notify the application that the initial tree is rendered (with children)
// and that initial node selection can take place. App will check URL to
// select the correct one.
_mbus.dispatch('TREE_RENDERED')
this.rendered = true
return this
}// }}}
reset() {// {{{
this.treeNodeComponents = {}
this.rendered = false
this.elTreenodes.replaceChildren()
this.render()
}// }}}
getNodeExpanded(UUID) {//{{{
if (this.expandedNodes[UUID] === undefined)
this.expandedNodes[UUID] = false
return this.expandedNodes[UUID]
}//}}}
async setNodeExpanded(node, value) {//{{{
let expanded = this.expandedNodes[node.UUID]
if (expanded === undefined) {
this.expandedNodes[node.UUID] = false
expanded = false
}
if (expanded === value)
return
this.expandedNodes[node.UUID] = value
_mbus.dispatch(`NODE_EXPAND_${node.UUID}`, value)
}//}}}
setSelected(node, dontExpand) {//{{{
if (node === undefined)
return
// The previously selected node, if any, needs to be rerendered
// to not retain its 'selected' class.
const prevUUID = this.selectedNode?.UUID
this.selectedNode = node
if (prevUUID)
this.treeNodeComponents[prevUUID]?.render(true)
// And now the newly selected node is rerendered.
this.treeNodeComponents[node.UUID]?.render(true)
if (!dontExpand)
this.setNodeExpanded(node, true)
}//}}}
isSelected(node) {//{{{
return this.selectedNode?.UUID === node.UUID
}//}}}
getTreeNode(uuid) {// {{{
return this.treeNodeComponents[uuid]
}// }}}
async keyHandler(event) {//{{{
let handled = true
const n = this.selectedNode
const Space = ' '
// This handler would otherwise react to stuff like Ctrl+L.
if (event.ctrlKey || event.altKey)
return
switch (event.key) {
// Space and enter is toggling expansion.
// Holding shift down does it recursively.
case Space:
case 'Enter':
const expanded = this.getNodeExpanded(n.UUID)
if (event.shiftKey) {
this.recursiveExpand(n, !expanded)
} else {
this.setNodeExpanded(n, !expanded)
}
break
case 'Home':
this.navigateTop()
break
case 'End':
this.navigateBottom()
break
case 'ArrowDown':
await this.navigateDown(this.selectedNode)
break
case 'ArrowUp':
await this.navigateUp(this.selectedNode)
break
case 'ArrowLeft':
await this.navigateLeft(this.selectedNode)
break
case 'ArrowRight':
await this.navigateRight(this.selectedNode)
break
default:
handled = false
}
if (handled) {
event.preventDefault()
event.stopPropagation()
}
}//}}}
async navigateLeft(n) {//{{{
if (n === null || n === undefined || n.UUID == ROOT_NODE)
return
const expanded = this.getNodeExpanded(n.UUID)
if (expanded && n.hasChildren() && n.UUID !== ROOT_NODE) {
this.setNodeExpanded(n, false)
return
}
if (n.isFirstSibling()) {
_mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getParent()?.UUID, dontPush: false, dontExpand: true })
return
}
const siblingBefore = n.getSiblingBefore()
const siblingExpanded = this.getNodeExpanded(siblingBefore?.UUID)
if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) {
const siblingAbove = this.getLastExpandedNode(siblingBefore)
_mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingAbove?.UUID, dontPush: false, dontExpand: true })
return
}
_mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingBefore()?.UUID, dontPush: false, dontExpand: true })
}//}}}
async navigateRight(n) {//{{{
if (n === null || n === undefined)
return
const siblingAfter = n.getSiblingAfter()
const expanded = this.getNodeExpanded(n.UUID)
if (!expanded && n.hasChildren()) {
this.setNodeExpanded(n, true)
return
}
if (expanded && n.hasChildren()) {
_mbus.dispatch("GO_TO_NODE", { nodeUUID: n.Children[0]?.UUID, dontPush: false, dontExpand: true })
return
}
if (n.isLastSibling()) {
const nextNode = this.getParentWithNextSibling(n)
_mbus.dispatch("GO_TO_NODE", { nodeUUID: nextNode?.UUID, dontPush: false, dontExpand: true })
return
}
_mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingAfter()?.UUID, dontPush: false, dontExpand: true })
}//}}}
async navigateUp(n) {//{{{
if (n === null || n === undefined || n.UUID == ROOT_NODE)
return
let parent = null
const siblingBefore = n.getSiblingBefore()
let siblingExpanded = false
if (siblingBefore !== null)
siblingExpanded = this.getNodeExpanded(siblingBefore.UUID)
if (n.isFirstSibling()) {
parent = n.getParent()
_mbus.dispatch("GO_TO_NODE", { nodeUUID: parent?.UUID, dontPush: false, dontExpand: true })
return
}
if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) {
const nodeVisuallyAbove = this.getLastExpandedNode(siblingBefore)
_mbus.dispatch("GO_TO_NODE", { nodeUUID: nodeVisuallyAbove.UUID, dontPush: false, dontExpand: true })
return
}
if (siblingBefore) {
_mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingBefore.UUID, dontPush: false, dontExpand: true })
return
}
}//}}}
async navigateDown(n) {//{{{
if (n === null || n === undefined)
return
const nodeExpanded = this.getNodeExpanded(n.UUID)
// Last node, not expanded, so it matters not whether it has children or not.
// Traverse upward to nearest parent with next sibling.
if (!nodeExpanded && n.isLastSibling()) {
const wantedNode = this.getParentWithNextSibling(n)
_mbus.dispatch("GO_TO_NODE", { nodeUUID: wantedNode?.UUID, dontPush: false, dontExpand: true })
return
}
if (nodeExpanded && n.isLastSibling() && !n.hasChildren()) {
const wantedNode = this.getParentWithNextSibling(n)
_mbus.dispatch("GO_TO_NODE", { nodeUUID: wantedNode?.UUID, dontPush: false, dontExpand: true })
return
}
// Node not expanded. Go to this node's next sibling.
// GoToNode will abort if given null.
if (!nodeExpanded || !n.hasChildren()) {
_mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingAfter()?.UUID, dontPush: false, dontExpand: true })
return
}
// Node is expanded.
// Children will be visually beneath this node, if any.
if (nodeExpanded && n.hasChildren()) {
_mbus.dispatch("GO_TO_NODE", { nodeUUID: n.Children[0].UUID, dontPush: false, dontExpand: true })
return
}
}//}}}
async navigateTop() {//{{{
const root = await nodeStore.get(ROOT_NODE)
_mbus.dispatch("GO_TO_NODE", { nodeUUID: root.UUID, dontPush: false, dontExpand: true })
}//}}}
async navigateBottom() {//{{{
const orphaned = await nodeStore.get(ORPHANED_NODE)
if (!orphaned.hasChildren() || this.getNodeExpanded(orphaned.UUID)) {
_mbus.dispatch("GO_TO_NODE", { nodeUUID: orphaned.UUID, dontPush: false, dontExpand: true })
return
}
/* TODO - fix this when orphaned nodes are implemented.
const toplevel = orphaned.Children[orphaned.Children.length - 1]
const toplevelExpanded = this.getNodeExpanded(toplevel?.UUID)
if (toplevelExpanded) {
const lastnode = this.getLastExpandedNode(toplevel)
_mbus.dispatch("GO_TO_NODE", { nodeUUID: lastnode?.UUID, dontPush: false, dontExpand: true })
} else
_mbus.dispatch("GO_TO_NODE", { nodeUUID: orphaned.Children[orphaned.Children.length - 1]?.UUID, dontPush: false, dontExpand: true })
*/
}//}}}
getParentWithNextSibling(node) {//{{{
let currNode = node
while (currNode !== null && currNode.UUID !== ROOT_NODE && currNode.getSiblingAfter() === null) {
currNode = currNode.getParent()
}
return currNode?.getSiblingAfter()
}//}}}
getLastExpandedNode(node) {//{{{
let currNode = node
while (this.getNodeExpanded(currNode.UUID) && currNode.hasChildren()) {
currNode = currNode.Children[currNode.Children.length - 1]
}
return currNode
}//}}}
async recursiveExpand(node, state) {//{{{
if (state)
await this.setNodeExpanded(node, true)
// An expanded node needs to have its children fetched.
if (!node.hasFetchedChildren())
await node.fetchChildren()
for (const child of node.Children)
await this.recursiveExpand(child, state)
if (!state)
await this.setNodeExpanded(node, false)
}//}}}
async makeVisible(node, providedAncestors, dontExpand) {// {{{
const treenode = this.treeNodeComponents[node.UUID]
if (!dontExpand) {
const ancestors = providedAncestors || await nodeStore.getNodeAncestry(node)
for (const ancestor of ancestors.reverse()) {
this.setNodeExpanded(ancestor, true)
}
}
treenode?.scrollIntoView({ block: 'nearest' })
}// }}}
}
export class N2TreeNode extends CustomHTMLElement {
static DRAG_ICON = new Image()
static DRAG_ICON_OK = new Image()
static {// {{{
N2TreeNode.DRAG_ICON.src = `/images/${_VERSION}/leaf.svg`
N2TreeNode.DRAG_ICON_OK.src = `/images/${_VERSION}/expanded.svg`
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<style>
n2-sidebar:focus-within {
n2-specialnodedeleted > .el-name,
n2-specialnodeorphaned > .el-name,
n2-treenode > .el-name {
&.selected {
span {
position:relative;
}
span:before {
position: absolute;
content: '';
top: -8px;
left: -36px;
right: -8px;
bottom: -4px;
z-index: -1;
background-color: #f8f8f8;
border: 1px solid #ddd;
border-radius: 4px;
}
}
}
}
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" draggable="false">
</div>
<div data-el="name" class="name"><span></span></div>
<div data-el="children" class="children"></div>
`
}// }}}
constructor(sidebar, node, parent) {//{{{
super()
this.setAttribute('draggable', 'true')
this.classList.add('node')
this.sidebar = sidebar
this.node = node
this.parent = parent
this.children_populated = false
this.rendered = false
this.dragNode = null
this.elExpandToggle.addEventListener('click', event => {
if (this.node.hasChildren())
this.expandNode(event)
else
_mbus.dispatch('TREE_NODE_SELECTED', this.node)
})
this.elName.addEventListener('click', () => _mbus.dispatch('TREE_NODE_SELECTED', this.node))
_mbus.subscribe(`NODE_EXPAND_${node.UUID}`, _state => {
this.render(true)
})
// Drag-and-dropping of nodes
this.addEventListener('dragstart', event => this.dragStart(event))
this.addEventListener('dragend', event => this.dragEnd(event))
this.addEventListener('dragover', event => this.dragOver(event))
this.addEventListener('drop', event => this.dragDrop(event))
this.elName.addEventListener('dragenter', event => this.dragEnter(event))
this.elName.addEventListener('dragleave', event => this.dragLeave(event))
}// }}}
dragStart(e) {// {{{
if (this.node.isModified()) {
alert('Save note before moving it.')
e.stopPropagation()
e.preventDefault()
return
}
this.classList.add('drag-source')
const blankPixel = new Image()
blankPixel.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
e.dataTransfer.setDragImage(blankPixel, 0, 0)
e.dataTransfer.allowedEffects = 'none'
e.stopPropagation()
_app.dragIcon.setSource(this)
_app.dragIcon.start()
}// }}}
dragEnd(e) {// {{{
this.classList.remove('drag-source')
_app.dragIcon.end()
e.stopPropagation()
}// }}}
dragOver(e) {// {{{
e.dataTransfer.dropEffect = 'move'
e.preventDefault()
}// }}}
async dragDrop(e) {// {{{
try {
e.stopPropagation()
const sourceNode = _app.dragIcon.getSource()
// Abort if user drops the node back on itself.
if (sourceNode.node.UUID === this.node.UUID)
return
await _app.moveNode(sourceNode.node, this.node.UUID)
_app.sidebar.setNodeExpanded(this, true)
await this.render(true, true)
await sourceNode.render(true, true)
} catch (e) {
console.error(e)
alert(e)
} finally {
this.dragLeave(e)
}
}// }}}
dragEnter(e) {// {{{
const targetNode = e.target.closest('n2-treenode')
if (targetNode.classList.contains('drag-source'))
return
e.stopPropagation()
_app.dragIcon.icon('ok')
this.classList.add('drag-target')
}// }}}
dragLeave(e) {// {{{
e.stopPropagation()
e.dataTransfer.dropEffect = 'none'
e.dataTransfer.setDragImage(N2TreeNode.DRAG_ICON, -16, 8)
_app.dragIcon.icon('')
this.classList.remove('drag-target')
}// }}}
async expandNode(event) {// {{{
const expanded = _app.sidebar.getNodeExpanded(this.node.UUID)
if (event.shiftKey) {
_app.sidebar.recursiveExpand(this.node, !expanded)
} else {
_app.sidebar.setNodeExpanded(this.node, !expanded)
}
}// }}}
async fetchChildren(force_fetch) {//{{{
if (this.children_populated && !force_fetch)
return
await this.node.fetchChildren()
this.children_populated = true
}//}}}
async render(force_update, force_refetch_children) {//{{{
if (this.rendered && force_update !== true)
return this
if (this.sidebar.getNodeExpanded(this.node.UUID) || force_refetch_children)
await this.fetchChildren(force_refetch_children)
// Update the name and selected status.
this.elName.querySelector('span').innerText = this.node.get('Name')
if (this.sidebar.isSelected(this.node))
this.elName.classList.add('selected')
else
this.elName.classList.remove('selected')
// Update expansion state
const expanded = this.node.hasChildren() && this.sidebar.getNodeExpanded(this.node.UUID)
if (expanded) {
this.elChildren.classList.add('expanded')
this.elChildren.classList.remove('collapsed')
} else {
this.elChildren.classList.remove('expanded')
this.elChildren.classList.add('collapsed')
}
// The expand icon <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))
this.setImgSrc(this.elExpand, `/images/${window._VERSION}/expanded.svg`)
else
this.setImgSrc(this.elExpand, `/images/${window._VERSION}/collapsed.svg`)
// Should children be rendered?
let children = []
if (expanded)
children = this.node.Children.map(node => {
let treenode = this.sidebar.treeNodeComponents[node.UUID]
if (treenode === undefined) {
treenode = new N2TreeNode(this.sidebar, node, this)
this.sidebar.treeNodeComponents[node.UUID] = treenode
}
return treenode
})
const renderedChildren = []
for (const c of children)
renderedChildren.push(await c.render())
this.elChildren.replaceChildren(...renderedChildren)
this.rendered = true
return this
}//}}}
setImgSrc(img, newSrc) {// {{{
if (img.getAttribute('src') === newSrc)
return
img.setAttribute('src', newSrc)
}// }}}
}
class SpecialNodeDeleted extends N2TreeNode {
constructor(sidebar, node, parent) {//{{{
super(sidebar, node, parent)
this.removeAttribute('draggable')
}//}}}
}
class SpecialNodeOrphaned extends N2TreeNode {
constructor(sidebar, node, parent) {//{{{
super(sidebar, node, parent)
this.removeAttribute('draggable')
}//}}}
}
customElements.define('n2-sidebar', N2Sidebar)
customElements.define('n2-treenode', N2TreeNode)
customElements.define('n2-specialnodedeleted', SpecialNodeDeleted)
customElements.define('n2-specialnodeorphaned', SpecialNodeOrphaned)
// vim: foldmethod=marker

View file

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

473
static/js/tree.mjs Normal file
View file

@ -0,0 +1,473 @@
import { ROOT_NODE } from 'node_store'
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
import { Color, Solver } from './lib/css_colorize.mjs'
export class N2Tree extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<div data-el="logo" id="logo">
<img src="/images/${_VERSION}/logo_small.svg" />
<img src="/images/${_VERSION}/logo.svg" />
<img data-el="search" class='search' src="/images/${_VERSION}/icon_search.svg" style="height: 22px" />
</div>
<div class="icons">
<img data-el="sync" class='sync' src="/images/${_VERSION}/icon_refresh.svg" />
<img data-el="settings" class='settings' src="/images/${_VERSION}/icon_settings.svg" />
</div>
<div data-el="treenodes"></div>
`
}// }}}
constructor() {// {{{
super()
this.id = 'tree-nodes'
this.tabIndex = 0
this.treeNodeComponents = {}
this.treeTrunk = []
this.expandedNodes = {} // keyed on UUID
this.selectedNode = null
this.rendered = false
this.addEventListener('keydown', event => this.keyHandler(event))
this.elSearch.addEventListener('click', () => _mbus.dispatch('op-search'))
this.elSync.addEventListener('click', () => _sync.run())
this.elLogo.addEventListener('click', () => _app.goToNode(ROOT_NODE, false, false))
_mbus.subscribe('NODE_MODIFIED', ({ detail }) => {
const node = detail.data.node
const treenode = this.treeNodeComponents[node.get('UUID')]
if (!treenode)
return
treenode.node = node
treenode.render(true)
})
this.populateFirstLevel()
/* XXX - set color */
let color = new Color(255, 96, 80)
let solver = new Solver(color)
let result = solver.solve()
this.elSettings.style.filter = result.filter
}// }}}
render() {// {{{
if (this.rendered)
alert('Tree should only be rendered once.')
for (const node of this.treeTrunk) {
const treenode = new N2TreeNode(this, node)
this.treeNodeComponents[node.UUID] = treenode
this.elTreenodes.appendChild(treenode.render())
}
this.rendered = true
return this
}// }}}
reset() {// {{{
console.log('tree reset')
this.treeNodeComponents = {}
this.treeTrunk = []
this.rendered = false
this.elTreenodes.replaceChildren()
this.populateFirstLevel()
}// }}}
populateFirstLevel() {//{{{
nodeStore.get(ROOT_NODE)
.then(node => node.fetchChildren())
.then(children => {
this.treeNodeComponents = {}
this.treeTrunk = []
for (const node of children) {
// The root node isn't supposed to be shown in the tree.
if (node.UUID === ROOT_NODE)
continue
if (node.ParentUUID === ROOT_NODE)
this.treeTrunk.push(node)
}
_mbus.dispatch('TREE_TRUNK_FETCHED')
})
.catch(e => { console.log(e); console.log(e.type, e.error); alert(e.error) })
}//}}}
getNodeExpanded(UUID) {//{{{
if (this.expandedNodes[UUID] === undefined)
this.expandedNodes[UUID] = false
return this.expandedNodes[UUID]
}//}}}
setNodeExpanded(node, value) {//{{{
let expanded = this.expandedNodes[node.UUID]
if (expanded === undefined) {
this.expandedNodes[node.UUID] = false
expanded = false
}
if (expanded === value)
return
this.expandedNodes[node.UUID] = value
_mbus.dispatch(`NODE_EXPAND_${node.UUID}`, value)
}//}}}
setSelected(node, dontExpand) {//{{{
if (node === undefined)
return
// The previously selected node, if any, needs to be rerendered
// to not retain its 'selected' class.
const prevUUID = this.selectedNode?.UUID
this.selectedNode = node
if (prevUUID)
this.treeNodeComponents[prevUUID]?.render(true)
// And now the newly selected node is rerendered.
this.treeNodeComponents[node.UUID]?.render(true)
if (!dontExpand)
this.setNodeExpanded(node, true)
}//}}}
isSelected(node) {//{{{
return this.selectedNode?.UUID === node.UUID
}//}}}
async keyHandler(event) {//{{{
let handled = true
const n = this.selectedNode
const Space = ' '
// This handler would otherwise react to stuff like Ctrl+L.
if (event.ctrlKey || event.altKey)
return
switch (event.key) {
// Space and enter is toggling expansion.
// Holding shift down does it recursively.
case Space:
case 'Enter':
const expanded = this.getNodeExpanded(n.UUID)
if (event.shiftKey) {
this.recursiveExpand(n, !expanded)
} else {
this.setNodeExpanded(n, !expanded)
}
break
case 'g':
case 'Home':
this.navigateTop()
break
case 'G':
case 'End':
this.navigateBottom()
break
case 'j':
case 'ArrowDown':
await this.navigateDown(this.selectedNode)
break
case 'k':
case 'ArrowUp':
await this.navigateUp(this.selectedNode)
break
case 'h':
case 'ArrowLeft':
await this.navigateLeft(this.selectedNode)
break
case 'l':
case 'ArrowRight':
await this.navigateRight(this.selectedNode)
break
default:
// nonsole.log(event.key)
handled = false
}
if (handled) {
event.preventDefault()
event.stopPropagation()
}
}//}}}
async navigateLeft(n) {//{{{
if (n === null || n === undefined)
return
const expanded = this.getNodeExpanded(n.UUID)
if (expanded && n.hasChildren()) {
this.setNodeExpanded(n, false)
return
}
if (n.isFirstSibling() && n.getParent().UUID !== ROOT_NODE) {
_mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getParent()?.UUID, dontPush: false, dontExpand: true })
return
}
const siblingBefore = n.getSiblingBefore()
const siblingExpanded = this.getNodeExpanded(siblingBefore?.UUID)
if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) {
const siblingAbove = this.getLastExpandedNode(siblingBefore)
_mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingAbove?.UUID, dontPush: false, dontExpand: true })
return
}
_mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingBefore()?.UUID, dontPush: false, dontExpand: true })
}//}}}
async navigateRight(n) {//{{{
if (n === null || n === undefined)
return
const siblingAfter = n.getSiblingAfter()
const expanded = this.getNodeExpanded(n.UUID)
if (!expanded && n.hasChildren()) {
this.setNodeExpanded(n, true)
return
}
if (expanded && n.hasChildren()) {
_mbus.dispatch("GO_TO_NODE", { nodeUUID: n.Children[0]?.UUID, dontPush: false, dontExpand: true })
return
}
if (n.isLastSibling()) {
const nextNode = this.getParentWithNextSibling(n)
_mbus.dispatch("GO_TO_NODE", { nodeUUID: nextNode?.UUID, dontPush: false, dontExpand: true })
return
}
_mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingAfter()?.UUID, dontPush: false, dontExpand: true })
}//}}}
async navigateUp(n) {//{{{
if (n === null || n === undefined)
return
let parent = null
const siblingBefore = n.getSiblingBefore()
let siblingExpanded = false
if (siblingBefore !== null)
siblingExpanded = this.getNodeExpanded(siblingBefore.UUID)
if (n.isFirstSibling()) {
parent = n.getParent()
if (parent?.UUID === ROOT_NODE)
return
_mbus.dispatch("GO_TO_NODE", { nodeUUID: parent?.UUID, dontPush: false, dontExpand: true })
return
}
if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) {
_mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingBefore.Children[siblingBefore.Children.length - 1]?.UUID, dontPush: false, dontExpand: true })
return
}
if (siblingBefore) {
_mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingBefore.UUID, dontPush: false, dontExpand: true })
return
}
}//}}}
async navigateDown(n) {//{{{
if (n === null || n === undefined)
return
const nodeExpanded = this.getNodeExpanded(n.UUID)
// Last node, not expanded, so it matters not whether it has children or not.
// Traverse upward to nearest parent with next sibling.
if (!nodeExpanded && n.isLastSibling()) {
const wantedNode = this.getParentWithNextSibling(n)
_mbus.dispatch("GO_TO_NODE", { nodeUUID: wantedNode?.UUID, dontPush: false, dontExpand: true })
return
}
if (nodeExpanded && n.isLastSibling() && !n.hasChildren()) {
const wantedNode = this.getParentWithNextSibling(n)
_mbus.dispatch("GO_TO_NODE", { nodeUUID: wantedNode?.UUID, dontPush: false, dontExpand: true })
return
}
// Node not expanded. Go to this node's next sibling.
// GoToNode will abort if given null.
if (!nodeExpanded || !n.hasChildren()) {
_mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingAfter()?.UUID, dontPush: false, dontExpand: true })
return
}
// Node is expanded.
// Children will be visually beneath this node, if any.
if (nodeExpanded && n.hasChildren()) {
_mbus.dispatch("GO_TO_NODE", { nodeUUID: n.Children[0].UUID, dontPush: false, dontExpand: true })
return
}
}//}}}
async navigateTop() {//{{{
const root = await nodeStore.get(ROOT_NODE)
if (root.Children.length === 0)
return
_mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[0]?.UUID, dontPush: false, dontExpand: true })
}//}}}
async navigateBottom() {//{{{
const root = await nodeStore.get(ROOT_NODE)
if (root.Children.length === 0)
return
const toplevel = root.Children[root.Children.length - 1]
const toplevelExpanded = this.getNodeExpanded(toplevel?.UUID)
if (toplevelExpanded) {
const lastnode = this.getLastExpandedNode(toplevel)
_mbus.dispatch("GO_TO_NODE", { nodeUUID: lastnode?.UUID, dontPush: false, dontExpand: true })
} else
_mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[root.Children.length - 1]?.UUID, dontPush: false, dontExpand: true })
}//}}}
getParentWithNextSibling(node) {//{{{
let currNode = node
while (currNode !== null && currNode.UUID !== ROOT_NODE && currNode.getSiblingAfter() === null) {
currNode = currNode.getParent()
}
return currNode?.getSiblingAfter()
}//}}}
getLastExpandedNode(node) {//{{{
let currNode = node
while (this.getNodeExpanded(currNode.UUID) && currNode.hasChildren()) {
currNode = currNode.Children[currNode.Children.length - 1]
}
return currNode
}//}}}
async recursiveExpand(node, state) {//{{{
if (state)
await this.setNodeExpanded(node, true)
for (const child of node.Children)
await this.recursiveExpand(child, state)
if (!state)
await this.setNodeExpanded(node, false)
}//}}}
async makeVisible(node) {// {{{
const treenode = this.treeNodeComponents[node.UUID]
const ancestors = await nodeStore.getNodeAncestry(node)
for (const ancestor of ancestors.reverse()) {
this.setNodeExpanded(ancestor, true)
}
// The ROOT_NODE for example hasn't got a treenode.
treenode?.scrollIntoView({ block: 'nearest' })
}// }}}
}
customElements.define('n2-tree', N2Tree)
export class N2TreeNode extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<div data-el="expand-toggle" class="expand-toggle">
<img data-el="expand">
</div>
<div data-el="name" class="name"></div>
<div data-el="children" class="children"></div>
`
}// }}}
constructor(tree, node, parent) {//{{{
super()
this.classList.add('node')
this.tree = tree
this.node = node
this.parent = parent
this.children_populated = false
this.rendered = false
this.elExpandToggle.addEventListener('click', () => this.tree.setNodeExpanded(this.node, !this.tree.getNodeExpanded(this.node.UUID)))
this.elName.addEventListener('click', () => _mbus.dispatch('TREE_NODE_SELECTED', this.node))
_mbus.subscribe(`NODE_CHILDREN_FETCHED_${node.UUID}`, () => {
this.render(true)
})
_mbus.subscribe(`NODE_EXPAND_${node.UUID}`, state => {
this.render(true)
})
if (this.node.Level === 0 || this.tree.getNodeExpanded(this.node.UUID))
this.fetchChildren()
}// }}}
async fetchChildren() {//{{{
await this.node.fetchChildren()
this.children_populated = true
}//}}}
render(force_update) {//{{{
if (this.rendered && force_update !== true)
return this
// Fetch the next level of children if the parent tree node is expanded and our children thus will be visible.
const expanded = this.node.Children.length > 0 && this.tree.getNodeExpanded(this.node.UUID)
if (!this.children_populated && this.tree.getNodeExpanded(this.parent?.node.UUID)) {
this.node.fetchChildren().then(() => this.children_populated = true)
}
// Update the name and selected status
this.elName.innerText = this.node.get('Name')
if (this.tree.isSelected(this.node))
this.elName.classList.add('selected')
else
this.elName.classList.remove('selected')
// Update expansion state
if (expanded) {
this.elChildren.classList.add('expanded')
this.elChildren.classList.remove('collapsed')
} else {
this.elChildren.classList.remove('expanded')
this.elChildren.classList.add('collapsed')
}
// The expand icon <img> is only changed to not get a flickering when re-rendering.
if (this.node.Children.length === 0)
this.setImgSrc(this.elExpand, `/images/${window._VERSION}/leaf.svg`)
else if (this.tree.getNodeExpanded(this.node.UUID))
this.setImgSrc(this.elExpand, `/images/${window._VERSION}/expanded.svg`)
else
this.setImgSrc(this.elExpand, `/images/${window._VERSION}/collapsed.svg`)
// Should children be rendered?
this.elChildren.innerHTML = ''
let children = []
if (expanded)
children = this.node.Children.map(node => {
let treenode = this.tree.treeNodeComponents[node.UUID]
if (treenode === undefined) {
treenode = new N2TreeNode(this.tree, node, this)
this.tree.treeNodeComponents[node.UUID] = treenode
}
return treenode
})
for (const c of children)
this.elChildren.appendChild(c.render())
this.rendered = true
return this
}//}}}
setImgSrc(img, newSrc) {// {{{
if (img.getAttribute('src') === newSrc)
return
img.setAttribute('src', newSrc)
}// }}}
}
customElements.define('n2-treenode', N2TreeNode)
// vim: foldmethod=marker

View file

@ -6,45 +6,35 @@ const CACHED_ASSETS = [
'/css/{{ .VERSION }}/main.css', '/css/{{ .VERSION }}/main.css',
'/css/{{ .VERSION }}/markdown.css', '/css/{{ .VERSION }}/markdown.css',
'/css/{{ .VERSION }}/notes2.css', '/css/{{ .VERSION }}/notes2.css',
'/css/{{ .VERSION }}/page_history.css',
'/css/{{ .VERSION }}/theme.css', '/css/{{ .VERSION }}/theme.css',
'/images/{{ .VERSION }}/collapsed.svg', '/images/{{ .VERSION }}/collapsed.svg',
'/images/{{ .VERSION }}/expanded.svg', '/images/{{ .VERSION }}/expanded.svg',
'/images/{{ .VERSION }}/icon_history.svg',
'/images/{{ .VERSION }}/icon_home.svg',
'/images/{{ .VERSION }}/icon_markdown_hollow.svg', '/images/{{ .VERSION }}/icon_markdown_hollow.svg',
'/images/{{ .VERSION }}/icon_markdown.svg', '/images/{{ .VERSION }}/icon_markdown.svg',
'/images/{{ .VERSION }}/icon_refresh.svg', '/images/{{ .VERSION }}/icon_refresh.svg',
'/images/{{ .VERSION }}/icon_save_disabled.svg', '/images/{{ .VERSION }}/icon_save_disabled.svg',
'/images/{{ .VERSION }}/icon_save.svg',
'/images/{{ .VERSION }}/icon_search.svg', '/images/{{ .VERSION }}/icon_search.svg',
'/images/{{ .VERSION }}/icon_settings.svg',
'/images/{{ .VERSION }}/icon_table.svg',
'/images/{{ .VERSION }}/leaf.svg', '/images/{{ .VERSION }}/leaf.svg',
'/images/{{ .VERSION }}/logo_small.svg',
'/images/{{ .VERSION }}/logo.svg', '/images/{{ .VERSION }}/logo.svg',
'/js/{{ .VERSION }}/api.mjs', '/js/{{ .VERSION }}/api.mjs',
'/js/{{ .VERSION }}/app.mjs', '/js/{{ .VERSION }}/app.mjs',
'/js/{{ .VERSION }}/checklist.mjs', '/js/{{ .VERSION }}/checklist.mjs',
'/js/{{ .VERSION }}/crypto.mjs', '/js/{{ .VERSION }}/crypto.mjs',
'/js/{{ .VERSION }}/file.mjs',
'/js/{{ .VERSION }}/key.mjs', '/js/{{ .VERSION }}/key.mjs',
'/js/{{ .VERSION }}/lib/css_colorize.mjs',
'/js/{{ .VERSION }}/lib/custom_html_element.mjs', '/js/{{ .VERSION }}/lib/custom_html_element.mjs',
'/js/{{ .VERSION }}/lib/node_modules/marked/lib/marked.esm.js', '/js/{{ .VERSION }}/lib/node_modules/marked/lib/marked.esm.js',
'/js/{{ .VERSION }}/lib/node_modules/marked-token-position/lib/index.esm.js', '/js/{{ .VERSION }}/lib/node_modules/marked-token-position/lib/index.esm.js',
'/js/{{ .VERSION }}/lib/sjcl.js', '/js/{{ .VERSION }}/lib/sjcl.js',
'/js/{{ .VERSION }}/marked_position.mjs', '/js/{{ .VERSION }}/marked_position.mjs',
'/js/{{ .VERSION }}/mbus.mjs', '/js/{{ .VERSION }}/mbus.mjs',
'/js/{{ .VERSION }}/node_store.mjs',
'/js/{{ .VERSION }}/notes2.mjs',
'/js/{{ .VERSION }}/page_history.mjs',
'/js/{{ .VERSION }}/page_node.mjs', '/js/{{ .VERSION }}/page_node.mjs',
'/js/{{ .VERSION }}/page_storage.mjs', '/js/{{ .VERSION }}/page_storage.mjs',
'/js/{{ .VERSION }}/sidebar.mjs', '/js/{{ .VERSION }}/node_store.mjs',
'/js/{{ .VERSION }}/notes2.mjs',
'/js/{{ .VERSION }}/sync.mjs', '/js/{{ .VERSION }}/sync.mjs',
'/js/{{ .VERSION }}/tree.mjs',
] ]
async function precache() { async function precache() {
@ -120,13 +110,9 @@ self.addEventListener('activate', event => {
}) })
self.addEventListener('fetch', event => { self.addEventListener('fetch', event => {
// The fetch event is also seeing requests to other domains. // console.debug('SERVICE WORKER: fetch', event.request.url)
// Just let the browser handle those for itself.
const ourDomain = event.request.url.startsWith(self.location.origin)
if (!ourDomain)
return event
if (`{{ .DevMode }}` == 'true') if ({{ .DevMode }})
return event return event
event.respondWith(fetchAsset(event)) event.respondWith(fetchAsset(event))

27
user.go Normal file
View file

@ -0,0 +1,27 @@
package main
import (
// External
"github.com/golang-jwt/jwt/v5"
)
type UserSession struct {
UserID int
Username string
Password string
Name string
ClientUUID string
}
func NewUser(claims jwt.MapClaims) (u UserSession) {
uid, _ := claims["uid"].(float64)
name, _ := claims["name"].(string)
username, _ := claims["login"].(string)
clientUUID, _ := claims["cid"].(string)
u.UserID = int(uid)
u.Username = username
u.Name = name
u.ClientUUID = clientUUID
return
}

View file

@ -1,63 +0,0 @@
package user
import (
// External
"github.com/golang-jwt/jwt/v5"
"github.com/jmoiron/sqlx"
// Standard
"encoding/json"
)
type User struct {
ID int
Username string
Name string
Preferences map[string]UserPreferences
}
type UserSession struct {
UserID int
Username string
Password string
Name string
ClientUUID string
Db *sqlx.DB
}
type UserPreferences struct {
DownloadImages bool
DownloadFiles bool
}
func NewUser(claims jwt.MapClaims) (u UserSession) {
uid, _ := claims["uid"].(float64)
name, _ := claims["name"].(string)
username, _ := claims["login"].(string)
clientUUID, _ := claims["cid"].(string)
u.UserID = int(uid)
u.Username = username
u.Name = name
u.ClientUUID = clientUUID
return
}
func (u UserSession) Preferences() (prefs map[string]UserPreferences, err error) {
row := u.Db.QueryRow(`SELECT preferences FROM public.user WHERE id=$1`, u.UserID)
var data []byte
err = row.Scan(&data)
if err != nil {
return
}
err = json.Unmarshal(data, &prefs)
return
}
func (u UserSession) SetPreferences(prefs map[string]UserPreferences) (err error) {
j, _ := json.Marshal(prefs)
_, err = u.Db.Exec(`UPDATE public.user SET preferences=$2 WHERE id=$1`, u.UserID, j)
return
}

View file

@ -15,7 +15,7 @@
"crypto": "/js/{{ .VERSION }}/crypto.mjs", "crypto": "/js/{{ .VERSION }}/crypto.mjs",
"node_store": "/js/{{ .VERSION }}/node_store.mjs", "node_store": "/js/{{ .VERSION }}/node_store.mjs",
"node": "/js/{{ .VERSION }}/page_node.mjs", "node": "/js/{{ .VERSION }}/page_node.mjs",
"sidebar": "/js/{{ .VERSION }}/sidebar.mjs" "tree": "/js/{{ .VERSION }}/tree.mjs"
} }
} }
</script> </script>

View file

@ -1,13 +1,5 @@
{{ define "page" }} {{ define "page" }}
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/notes2.css"> <div id="notes2">
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/page_history.css">
<!-- Drag and drop elements -->
<!-- page-node -->
<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> <div id="tree" tabindex=0></div>
<div id="main-page"> <div id="main-page">
@ -16,40 +8,26 @@
<n2-pagestorage></n2-pagestorage> <n2-pagestorage></n2-pagestorage>
</div> </div>
<div id="page-root">
<div>
<img src="/images/{{ .VERSION }}/logo.svg">
<div> {{ .VERSION }}</div>
<div class="create">Create note</div>
</div>
</div>
<!-- Node editing --> <!-- Node editing -->
<div id="page-node"> <div id="page-node">
<div id="crumbs"></div> <div id="crumbs"></div>
<n2-syncprogress></n2-syncprogress>
<n2-nodeui id="note"></n2-nodeui> <n2-nodeui id="note"></n2-nodeui>
</div> </div>
<!-- History -->
<n2-pagehistory id="page-history"></n2-pagehistory>
<!-- Preferences -->
<n2-pagepreferences id="page-preferences"></n2-pagepreferences>
</div> </div>
<n2-syncprogress></n2-syncprogress>
</div> </div>
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/notes2.css">
<script type="module"> <script type="module">
import {NodeStore} from '/js/{{ .VERSION }}/node_store.mjs' import {NodeStore} from '/js/{{ .VERSION }}/node_store.mjs'
import {App} from "/js/{{ .VERSION }}/app.mjs" import {App} from "/js/{{ .VERSION }}/app.mjs"
import {API} from 'api' import {API} from 'api'
import {Sync} from 'sync' import {Sync} from 'sync'
import { } from '/js/{{ .VERSION }}/page_preferences.mjs'
import { } from '/js/{{ .VERSION }}/page_storage.mjs' import { } from '/js/{{ .VERSION }}/page_storage.mjs'
import { } from '/js/{{ .VERSION }}/page_history.mjs'
import { } from '/js/{{ .VERSION }}/file.mjs' import { } from '/js/{{ .VERSION }}/file.mjs'
window.Sync = Sync window.Sync = Sync