Compare commits
27 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74851b9c4d | ||
|
|
81d02b82dc | ||
|
|
1a712fb7a9 | ||
|
|
f5cbfb0b22 | ||
|
|
ea3bdaca03 | ||
|
|
0fe5cd78b3 | ||
|
|
d6d8b64bb9 | ||
|
|
c36b4ace13 | ||
|
|
7c46127938 | ||
|
|
b0a95c9382 | ||
|
|
ecf68132a1 | ||
|
|
58ece3f823 | ||
|
|
86fcbbb68f | ||
|
|
d1c3b9e963 | ||
|
|
dbd3872f0f | ||
|
|
e71516fd76 | ||
|
|
15bd742ef7 | ||
|
|
da7999fb24 | ||
|
|
d9adfd3a91 | ||
|
|
edd3d11b09 | ||
|
|
960c9e2625 | ||
|
|
658733b1d8 | ||
|
|
04c936e730 | ||
|
|
63434678ce | ||
|
|
53d8d16086 | ||
|
|
61b0ba9ada | ||
|
|
1055404dc0 |
|
|
@ -8,6 +8,9 @@ import (
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/lib/pq"
|
"github.com/lib/pq"
|
||||||
|
|
||||||
|
// Internal
|
||||||
|
appUser "notes2/user"
|
||||||
|
|
||||||
// Standard
|
// Standard
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
|
@ -27,12 +30,6 @@ type Manager struct {
|
||||||
ExpireDays int
|
ExpireDays int
|
||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
|
||||||
ID int
|
|
||||||
Username string
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
func httpError(w http.ResponseWriter, err error) { // {{{
|
func httpError(w http.ResponseWriter, err error) { // {{{
|
||||||
j, _ := json.Marshal(struct {
|
j, _ := json.Marshal(struct {
|
||||||
OK bool
|
OK bool
|
||||||
|
|
@ -165,16 +162,16 @@ func (mngr *Manager) AuthenticationHandler(w http.ResponseWriter, r *http.Reques
|
||||||
mngr.log.Info("authentication", "username", request.Username, "status", "accepted")
|
mngr.log.Info("authentication", "username", request.Username, "status", "accepted")
|
||||||
j, _ := json.Marshal(struct {
|
j, _ := json.Marshal(struct {
|
||||||
OK bool
|
OK bool
|
||||||
User User
|
User appUser.User
|
||||||
Token string
|
Token string
|
||||||
}{true, user, token})
|
}{true, user, token})
|
||||||
w.Write(j)
|
w.Write(j)
|
||||||
} // }}}
|
} // }}}
|
||||||
|
|
||||||
func (mngr *Manager) Authenticate(username, password string) (authenticated bool, user User, err error) { // {{{
|
func (mngr *Manager) Authenticate(username, password string) (authenticated bool, user appUser.User, err error) { // {{{
|
||||||
var row *sql.Row
|
var row *sql.Row
|
||||||
row = mngr.db.QueryRow(`
|
row = mngr.db.QueryRow(`
|
||||||
SELECT id, username, name
|
SELECT id, username, name, preferences
|
||||||
FROM public.user
|
FROM public.user
|
||||||
WHERE
|
WHERE
|
||||||
LOWER(username) = LOWER($1) AND
|
LOWER(username) = LOWER($1) AND
|
||||||
|
|
@ -183,13 +180,21 @@ func (mngr *Manager) Authenticate(username, password string) (authenticated bool
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
)
|
)
|
||||||
err = row.Scan(&user.ID, &user.Username, &user.Name)
|
var data []byte
|
||||||
|
err = row.Scan(&user.ID, &user.Username, &user.Name, &data)
|
||||||
if err != nil && err.Error() == "sql: no rows in result set" {
|
if err != nil && err.Error() == "sql: no rows in result set" {
|
||||||
err = nil
|
err = nil
|
||||||
authenticated = false
|
authenticated = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
authenticated = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(data, &user.Preferences)
|
||||||
|
if err != nil {
|
||||||
|
authenticated = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -278,7 +283,7 @@ func (mngr *Manager) ChangePassword(username, currentPassword, newPassword strin
|
||||||
changed = (rowsAffected == 1)
|
changed = (rowsAffected == 1)
|
||||||
return
|
return
|
||||||
} // }}}
|
} // }}}
|
||||||
func (mngr *Manager) NewClientUUID(user User) (clientUUID string, err error) { // {{{
|
func (mngr *Manager) NewClientUUID(user appUser.User) (clientUUID string, err error) { // {{{
|
||||||
// Each client session has its own UUID.
|
// Each client session has its own UUID.
|
||||||
// Loop through until a unique one is established.
|
// Loop through until a unique one is established.
|
||||||
var proposedClientUUID string
|
var proposedClientUUID string
|
||||||
|
|
|
||||||
65
main.go
|
|
@ -4,6 +4,7 @@ import (
|
||||||
// Internal
|
// Internal
|
||||||
"notes2/authentication"
|
"notes2/authentication"
|
||||||
"notes2/html_template"
|
"notes2/html_template"
|
||||||
|
appUser "notes2/user"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
// Standard
|
// Standard
|
||||||
|
|
@ -23,7 +24,7 @@ import (
|
||||||
"text/template"
|
"text/template"
|
||||||
)
|
)
|
||||||
|
|
||||||
const VERSION = "v23"
|
const VERSION = "v29"
|
||||||
const CONTEXT_USER = 1
|
const CONTEXT_USER = 1
|
||||||
const SYNC_PAGINATION = 200
|
const SYNC_PAGINATION = 200
|
||||||
|
|
||||||
|
|
@ -134,6 +135,8 @@ func main() { // {{{
|
||||||
http.HandleFunc("/offline", pageOffline)
|
http.HandleFunc("/offline", pageOffline)
|
||||||
|
|
||||||
http.HandleFunc("/user/authenticate", AuthManager.AuthenticationHandler)
|
http.HandleFunc("/user/authenticate", AuthManager.AuthenticationHandler)
|
||||||
|
http.HandleFunc("GET /user/preferences", authenticated(actionUserGetPreferences))
|
||||||
|
http.HandleFunc("POST /user/preferences", authenticated(actionUserSetPreferences))
|
||||||
|
|
||||||
http.HandleFunc("/sync/from_server/count/{sequence}", authenticated(actionSyncFromServerCount))
|
http.HandleFunc("/sync/from_server/count/{sequence}", authenticated(actionSyncFromServerCount))
|
||||||
http.HandleFunc("/sync/from_server/{sequence}/{offset}", authenticated(actionSyncFromServer))
|
http.HandleFunc("/sync/from_server/{sequence}/{offset}", authenticated(actionSyncFromServer))
|
||||||
|
|
@ -178,7 +181,7 @@ func authenticated(fn func(http.ResponseWriter, *http.Request)) func(http.Respon
|
||||||
}
|
}
|
||||||
|
|
||||||
// User object is added to the context for the next handler.
|
// User object is added to the context for the next handler.
|
||||||
user := NewUser(claims)
|
user := appUser.NewUser(claims)
|
||||||
r = r.WithContext(context.WithValue(r.Context(), CONTEXT_USER, user))
|
r = r.WithContext(context.WithValue(r.Context(), CONTEXT_USER, user))
|
||||||
|
|
||||||
Log.Debug("webserver", "op", "request", "method", r.Method, "url", r.URL.String(), "username", user.Username, "client", user.ClientUUID)
|
Log.Debug("webserver", "op", "request", "method", r.Method, "url", r.URL.String(), "username", user.Username, "client", user.ClientUUID)
|
||||||
|
|
@ -266,7 +269,7 @@ func actionSyncFromServer(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
// The purpose of the Client UUID is to avoid
|
// The purpose of the Client UUID is to avoid
|
||||||
// sending nodes back once again to a client that
|
// sending nodes back once again to a client that
|
||||||
// just created or modified it.
|
// just created or modified it.
|
||||||
user := getUser(r)
|
user := getUserSession(r)
|
||||||
changedFrom, _ := strconv.Atoi(r.PathValue("sequence"))
|
changedFrom, _ := strconv.Atoi(r.PathValue("sequence"))
|
||||||
offset, _ := strconv.Atoi(r.PathValue("offset"))
|
offset, _ := strconv.Atoi(r.PathValue("offset"))
|
||||||
|
|
||||||
|
|
@ -289,7 +292,7 @@ func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
// The purpose of the Client UUID is to avoid
|
// The purpose of the Client UUID is to avoid
|
||||||
// sending nodes back once again to a client that
|
// sending nodes back once again to a client that
|
||||||
// just created or modified it.
|
// just created or modified it.
|
||||||
user := getUser(r)
|
user := getUserSession(r)
|
||||||
changedFrom, _ := strconv.Atoi(r.PathValue("sequence"))
|
changedFrom, _ := strconv.Atoi(r.PathValue("sequence"))
|
||||||
|
|
||||||
count, err := NodesCount(user.UserID, uint64(changedFrom), user.ClientUUID)
|
count, err := NodesCount(user.UserID, uint64(changedFrom), user.ClientUUID)
|
||||||
|
|
@ -309,7 +312,7 @@ func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
w.Write(j)
|
w.Write(j)
|
||||||
} // }}}
|
} // }}}
|
||||||
func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
|
func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
user := getUser(r)
|
user := getUserSession(r)
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
uuid := r.PathValue("uuid")
|
uuid := r.PathValue("uuid")
|
||||||
|
|
@ -325,7 +328,7 @@ func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
})
|
})
|
||||||
} // }}}
|
} // }}}
|
||||||
func actionNodeHistoryRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
|
func actionNodeHistoryRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
user := getUser(r)
|
user := getUserSession(r)
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
uuid := r.PathValue("uuid")
|
uuid := r.PathValue("uuid")
|
||||||
|
|
@ -348,7 +351,7 @@ func actionNodeHistoryRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
})
|
})
|
||||||
} // }}}
|
} // }}}
|
||||||
func actionNodeHistoryCount(w http.ResponseWriter, r *http.Request) { // {{{
|
func actionNodeHistoryCount(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
user := getUser(r)
|
user := getUserSession(r)
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
uuid := r.PathValue("uuid")
|
uuid := r.PathValue("uuid")
|
||||||
|
|
@ -365,7 +368,7 @@ func actionNodeHistoryCount(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
})
|
})
|
||||||
} // }}}
|
} // }}}
|
||||||
func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{
|
func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
user := getUser(r)
|
user := getUserSession(r)
|
||||||
|
|
||||||
body, _ := io.ReadAll(r.Body)
|
body, _ := io.ReadAll(r.Body)
|
||||||
var request struct {
|
var request struct {
|
||||||
|
|
@ -389,6 +392,47 @@ func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
})
|
})
|
||||||
} // }}}
|
} // }}}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
httpError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
responseData(w, map[string]any{
|
||||||
|
"OK": true,
|
||||||
|
})
|
||||||
|
} // }}}
|
||||||
|
|
||||||
func createNewUser(username string) { // {{{
|
func createNewUser(username string) { // {{{
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
|
||||||
|
|
@ -431,7 +475,8 @@ func changePassword(username string) { // {{{
|
||||||
|
|
||||||
fmt.Printf("\nPassword changed\n")
|
fmt.Printf("\nPassword changed\n")
|
||||||
} // }}}
|
} // }}}
|
||||||
func getUser(r *http.Request) UserSession { // {{{
|
func getUserSession(r *http.Request) appUser.UserSession { // {{{
|
||||||
user, _ := r.Context().Value(CONTEXT_USER).(UserSession)
|
user, _ := r.Context().Value(CONTEXT_USER).(appUser.UserSession)
|
||||||
|
user.Db = db
|
||||||
return user
|
return user
|
||||||
} // }}}
|
} // }}}
|
||||||
|
|
|
||||||
2
node.go
|
|
@ -54,6 +54,7 @@ type Node struct {
|
||||||
DeletedSeq sql.NullInt64 `db:"deleted_seq"`
|
DeletedSeq sql.NullInt64 `db:"deleted_seq"`
|
||||||
Content string
|
Content string
|
||||||
ContentEncrypted string `db:"content_encrypted" json:"-"`
|
ContentEncrypted string `db:"content_encrypted" json:"-"`
|
||||||
|
Special bool
|
||||||
}
|
}
|
||||||
|
|
||||||
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) { // {{{
|
||||||
|
|
@ -135,6 +136,7 @@ func Nodes(userID, offset int, synced uint64, clientUUID string) (nodes []Node,
|
||||||
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::uuid AND
|
||||||
(
|
(
|
||||||
|
|
|
||||||
119
sql/00006.sql
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
CREATE OR REPLACE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid uuid, IN p_nodes jsonb)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $procedure$
|
||||||
|
|
||||||
|
DECLARE
|
||||||
|
node_data jsonb;
|
||||||
|
node_updated timestamptz;
|
||||||
|
db_updated timestamptz;
|
||||||
|
db_uuid uuid;
|
||||||
|
db_client uuid;
|
||||||
|
db_history_uuid uuid;
|
||||||
|
node_uuid uuid;
|
||||||
|
node_parent_uuid uuid;
|
||||||
|
node_history_uuid uuid;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes)
|
||||||
|
LOOP
|
||||||
|
node_uuid = (node_data->>'UUID')::uuid;
|
||||||
|
node_history_uuid = (node_data->>'HistoryUUID')::uuid;
|
||||||
|
node_updated = (node_data->>'Updated')::timestamptz;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- Frontend is using an all-zero UUID to define the root node.
|
||||||
|
-- Database is using NULL.
|
||||||
|
IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' OR node_data->>'ParentUUID' = '' THEN
|
||||||
|
node_parent_uuid = NULL;
|
||||||
|
ELSE
|
||||||
|
node_parent_uuid = (node_data->>'ParentUUID')::uuid;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- Every jode has a new history UUID to keep the history entry uniquely identifiable
|
||||||
|
-- across clients. A history entry could potentially be sent again, but should be
|
||||||
|
-- safe to ignore as every change to a node should have a new history UUID.
|
||||||
|
--
|
||||||
|
-- The current node is also stored as history.
|
||||||
|
INSERT INTO node_history(
|
||||||
|
user_id, "uuid", "history_uuid", parents, created, updated,
|
||||||
|
"name", "content", "content_encrypted",
|
||||||
|
client
|
||||||
|
)
|
||||||
|
VALUES(
|
||||||
|
p_user_id, -- combined key
|
||||||
|
node_uuid, -- combined key
|
||||||
|
node_history_uuid, -- combined key
|
||||||
|
(jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors",
|
||||||
|
COALESCE((node_data->>'Created')::timestamptz, NOW()),
|
||||||
|
COALESCE((node_data->>'Updated')::timestamptz, NOW()),
|
||||||
|
(node_data->>'Name')::varchar,
|
||||||
|
(node_data->>'Content')::text,
|
||||||
|
'', /* content_encrypted */
|
||||||
|
p_client_uuid
|
||||||
|
)
|
||||||
|
ON CONFLICT ("user_id", "uuid", "history_uuid")
|
||||||
|
DO NOTHING;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- Retrieve the current modified timestamp for this node from the database.
|
||||||
|
SELECT
|
||||||
|
uuid, updated, client
|
||||||
|
INTO
|
||||||
|
db_uuid, db_updated, db_client
|
||||||
|
FROM public."node"
|
||||||
|
WHERE
|
||||||
|
user_id = p_user_id AND
|
||||||
|
uuid::uuid = node_uuid::uuid;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- Is the node not in database? It needs to be created.
|
||||||
|
IF db_uuid IS NULL THEN
|
||||||
|
RAISE NOTICE '01 New node %', node_uuid;
|
||||||
|
|
||||||
|
INSERT INTO public."node" (
|
||||||
|
user_id, "uuid", parent_uuid, created, updated,
|
||||||
|
"name", "content", "content_encrypted",
|
||||||
|
client
|
||||||
|
)
|
||||||
|
VALUES(
|
||||||
|
p_user_id,
|
||||||
|
node_uuid,
|
||||||
|
node_parent_uuid,
|
||||||
|
COALESCE((node_data->>'Created')::timestamptz, NOW()),
|
||||||
|
COALESCE((node_data->>'Updated')::timestamptz, NOW()),
|
||||||
|
(node_data->>'Name')::varchar,
|
||||||
|
(node_data->>'Content')::text,
|
||||||
|
'', /* content_encrypted */
|
||||||
|
p_client_uuid
|
||||||
|
);
|
||||||
|
|
||||||
|
CONTINUE;
|
||||||
|
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- Update the public node as well if it was older than incoming node.
|
||||||
|
IF node_updated > db_updated THEN
|
||||||
|
UPDATE public."node"
|
||||||
|
SET
|
||||||
|
updated = (node_data->>'Updated')::timestamptz,
|
||||||
|
updated_seq = nextval('node_updates'),
|
||||||
|
parent_uuid = (node_data->>'ParentUUID')::uuid,
|
||||||
|
name = (node_data->>'Name')::varchar,
|
||||||
|
content = (node_data->>'Content')::text,
|
||||||
|
client = p_client_uuid
|
||||||
|
WHERE
|
||||||
|
user_id = p_user_id AND
|
||||||
|
uuid::uuid = node_uuid::uuid;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
END LOOP;
|
||||||
|
END
|
||||||
|
$procedure$
|
||||||
|
;
|
||||||
119
sql/00007.sql
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
CREATE OR REPLACE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid uuid, IN p_nodes jsonb)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $procedure$
|
||||||
|
|
||||||
|
DECLARE
|
||||||
|
node_data jsonb;
|
||||||
|
node_updated timestamptz;
|
||||||
|
db_updated timestamptz;
|
||||||
|
db_uuid uuid;
|
||||||
|
db_client uuid;
|
||||||
|
db_history_uuid uuid;
|
||||||
|
node_uuid uuid;
|
||||||
|
node_parent_uuid uuid;
|
||||||
|
node_history_uuid uuid;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes)
|
||||||
|
LOOP
|
||||||
|
node_uuid = (node_data->>'UUID')::uuid;
|
||||||
|
node_history_uuid = (node_data->>'HistoryUUID')::uuid;
|
||||||
|
node_updated = (node_data->>'Updated')::timestamptz;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- Frontend is using an all-zero UUID to define the root node.
|
||||||
|
-- Database is using NULL.
|
||||||
|
IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' OR node_data->>'ParentUUID' = '' THEN
|
||||||
|
node_parent_uuid = NULL;
|
||||||
|
ELSE
|
||||||
|
node_parent_uuid = (node_data->>'ParentUUID')::uuid;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- Every jode has a new history UUID to keep the history entry uniquely identifiable
|
||||||
|
-- across clients. A history entry could potentially be sent again, but should be
|
||||||
|
-- safe to ignore as every change to a node should have a new history UUID.
|
||||||
|
--
|
||||||
|
-- The current node is also stored as history.
|
||||||
|
INSERT INTO node_history(
|
||||||
|
user_id, "uuid", "history_uuid", parents, created, updated,
|
||||||
|
"name", "content", "content_encrypted",
|
||||||
|
client
|
||||||
|
)
|
||||||
|
VALUES(
|
||||||
|
p_user_id, -- combined key
|
||||||
|
node_uuid, -- combined key
|
||||||
|
node_history_uuid, -- combined key
|
||||||
|
(jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors",
|
||||||
|
COALESCE((node_data->>'Created')::timestamptz, NOW()),
|
||||||
|
COALESCE((node_data->>'Updated')::timestamptz, NOW()),
|
||||||
|
(node_data->>'Name')::varchar,
|
||||||
|
(node_data->>'Content')::text,
|
||||||
|
'', /* content_encrypted */
|
||||||
|
p_client_uuid
|
||||||
|
)
|
||||||
|
ON CONFLICT ("user_id", "uuid", "history_uuid")
|
||||||
|
DO NOTHING;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- Retrieve the current modified timestamp for this node from the database.
|
||||||
|
SELECT
|
||||||
|
uuid, updated, client
|
||||||
|
INTO
|
||||||
|
db_uuid, db_updated, db_client
|
||||||
|
FROM public."node"
|
||||||
|
WHERE
|
||||||
|
user_id = p_user_id AND
|
||||||
|
uuid::uuid = node_uuid::uuid;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- Is the node not in database? It needs to be created.
|
||||||
|
IF db_uuid IS NULL THEN
|
||||||
|
RAISE NOTICE '01 New node %', node_uuid;
|
||||||
|
|
||||||
|
INSERT INTO public."node" (
|
||||||
|
user_id, "uuid", parent_uuid, created, updated,
|
||||||
|
"name", "content", "content_encrypted",
|
||||||
|
client
|
||||||
|
)
|
||||||
|
VALUES(
|
||||||
|
p_user_id,
|
||||||
|
node_uuid,
|
||||||
|
node_parent_uuid,
|
||||||
|
COALESCE((node_data->>'Created')::timestamptz, NOW()),
|
||||||
|
COALESCE((node_data->>'Updated')::timestamptz, NOW()),
|
||||||
|
(node_data->>'Name')::varchar,
|
||||||
|
(node_data->>'Content')::text,
|
||||||
|
'', /* content_encrypted */
|
||||||
|
p_client_uuid
|
||||||
|
);
|
||||||
|
|
||||||
|
CONTINUE;
|
||||||
|
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- Update the public node as well if it was older than incoming node.
|
||||||
|
IF node_updated > db_updated THEN
|
||||||
|
UPDATE public."node"
|
||||||
|
SET
|
||||||
|
updated = (node_data->>'Updated')::timestamptz,
|
||||||
|
updated_seq = nextval('node_updates'),
|
||||||
|
parent_uuid = node_parent_uuid,
|
||||||
|
name = (node_data->>'Name')::varchar,
|
||||||
|
content = (node_data->>'Content')::text,
|
||||||
|
client = p_client_uuid
|
||||||
|
WHERE
|
||||||
|
user_id = p_user_id AND
|
||||||
|
uuid::uuid = node_uuid::uuid;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
END LOOP;
|
||||||
|
END
|
||||||
|
$procedure$
|
||||||
|
;
|
||||||
123
sql/00008.sql
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
CREATE OR REPLACE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid uuid, IN p_nodes jsonb)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $procedure$
|
||||||
|
|
||||||
|
DECLARE
|
||||||
|
node_data jsonb;
|
||||||
|
node_updated timestamptz;
|
||||||
|
db_updated timestamptz;
|
||||||
|
db_uuid uuid;
|
||||||
|
db_client uuid;
|
||||||
|
db_history_uuid uuid;
|
||||||
|
node_uuid uuid;
|
||||||
|
node_parent_uuid uuid;
|
||||||
|
node_history_uuid uuid;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes)
|
||||||
|
LOOP
|
||||||
|
node_uuid = (node_data->>'UUID')::uuid;
|
||||||
|
node_history_uuid = (node_data->>'HistoryUUID')::uuid;
|
||||||
|
node_updated = (node_data->>'Updated')::timestamptz;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- Frontend is using an all-zero UUID to define the root node.
|
||||||
|
-- Database is using NULL.
|
||||||
|
IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' OR node_data->>'ParentUUID' = '' THEN
|
||||||
|
node_parent_uuid = NULL;
|
||||||
|
ELSE
|
||||||
|
node_parent_uuid = (node_data->>'ParentUUID')::uuid;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Safeguard against being your own parent.
|
||||||
|
IF node_uuid = node_parent_uuid THEN
|
||||||
|
RAISE EXCEPTION 'Node UUID is same as node parent UUID.' USING ERRCODE = 'XPRNT';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
|
||||||
|
-- Every jode has a new history UUID to keep the history entry uniquely identifiable
|
||||||
|
-- across clients. A history entry could potentially be sent again, but should be
|
||||||
|
-- safe to ignore as every change to a node should have a new history UUID.
|
||||||
|
--
|
||||||
|
-- The current node is also stored as history.
|
||||||
|
INSERT INTO node_history(
|
||||||
|
user_id, "uuid", "history_uuid", parents, created, updated,
|
||||||
|
"name", "content", "content_encrypted",
|
||||||
|
client
|
||||||
|
)
|
||||||
|
VALUES(
|
||||||
|
p_user_id, -- combined key
|
||||||
|
node_uuid, -- combined key
|
||||||
|
node_history_uuid, -- combined key
|
||||||
|
(jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors",
|
||||||
|
COALESCE((node_data->>'Created')::timestamptz, NOW()),
|
||||||
|
COALESCE((node_data->>'Updated')::timestamptz, NOW()),
|
||||||
|
(node_data->>'Name')::varchar,
|
||||||
|
(node_data->>'Content')::text,
|
||||||
|
'', /* content_encrypted */
|
||||||
|
p_client_uuid
|
||||||
|
)
|
||||||
|
ON CONFLICT ("user_id", "uuid", "history_uuid")
|
||||||
|
DO NOTHING;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- Retrieve the current modified timestamp for this node from the database.
|
||||||
|
SELECT
|
||||||
|
uuid, updated, client
|
||||||
|
INTO
|
||||||
|
db_uuid, db_updated, db_client
|
||||||
|
FROM public."node"
|
||||||
|
WHERE
|
||||||
|
user_id = p_user_id AND
|
||||||
|
uuid::uuid = node_uuid::uuid;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- Is the node not in database? It needs to be created.
|
||||||
|
IF db_uuid IS NULL THEN
|
||||||
|
RAISE NOTICE '01 New node %', node_uuid;
|
||||||
|
|
||||||
|
INSERT INTO public."node" (
|
||||||
|
user_id, "uuid", parent_uuid, created, updated,
|
||||||
|
"name", "content", "content_encrypted",
|
||||||
|
client
|
||||||
|
)
|
||||||
|
VALUES(
|
||||||
|
p_user_id,
|
||||||
|
node_uuid,
|
||||||
|
node_parent_uuid,
|
||||||
|
COALESCE((node_data->>'Created')::timestamptz, NOW()),
|
||||||
|
COALESCE((node_data->>'Updated')::timestamptz, NOW()),
|
||||||
|
(node_data->>'Name')::varchar,
|
||||||
|
(node_data->>'Content')::text,
|
||||||
|
'', /* content_encrypted */
|
||||||
|
p_client_uuid
|
||||||
|
);
|
||||||
|
|
||||||
|
CONTINUE;
|
||||||
|
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- Update the public node as well if it was older than incoming node.
|
||||||
|
IF node_updated > db_updated THEN
|
||||||
|
UPDATE public."node"
|
||||||
|
SET
|
||||||
|
updated = (node_data->>'Updated')::timestamptz,
|
||||||
|
updated_seq = nextval('node_updates'),
|
||||||
|
parent_uuid = node_parent_uuid,
|
||||||
|
name = (node_data->>'Name')::varchar,
|
||||||
|
content = (node_data->>'Content')::text,
|
||||||
|
client = p_client_uuid
|
||||||
|
WHERE
|
||||||
|
user_id = p_user_id AND
|
||||||
|
uuid::uuid = node_uuid::uuid;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
END LOOP;
|
||||||
|
END
|
||||||
|
$procedure$
|
||||||
|
;
|
||||||
35
sql/00009.sql
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
-- Special node such as orphaned and deleted nodes.
|
||||||
|
ALTER TABLE public.node ADD special bool DEFAULT false NOT NULL;
|
||||||
|
|
||||||
|
|
||||||
|
-- Needs to be dropped in order to drop the index on UUID.
|
||||||
|
ALTER TABLE public.node DROP CONSTRAINT node_node_fk;
|
||||||
|
|
||||||
|
-- Index was missing user ID.
|
||||||
|
DROP INDEX public.node_uuid_idx;
|
||||||
|
CREATE UNIQUE INDEX node_user_uuid_idx ON public.node (user_id,"uuid");
|
||||||
|
|
||||||
|
-- Restore the "foreign" key of parent UUID back to UUID.
|
||||||
|
ALTER TABLE public.node ADD CONSTRAINT node_node_fk FOREIGN KEY (user_id,parent_uuid) REFERENCES public.node(user_id,"uuid") ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||||
|
|
||||||
|
|
||||||
|
-- Auto-create the special nodes for each user.
|
||||||
|
CREATE OR REPLACE FUNCTION create_user_nodes()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
-- NEW holds the row being created.
|
||||||
|
-- No semi-colons omitted here, PL/pgSQL requires them.
|
||||||
|
INSERT INTO public.node (user_id, uuid, parent_uuid, special, name)
|
||||||
|
VALUES
|
||||||
|
(NEW.id, '00000000-0000-0000-0000-000000000000'::uuid, null, true, 'Start'),
|
||||||
|
(NEW.id, '00000000-0000-0000-0000-000000000001'::uuid, null, true, 'Orphaned nodes'),
|
||||||
|
(NEW.id, '00000000-0000-0000-0000-000000000002'::uuid, null, true, 'Deleted nodes');
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_after_user_insert
|
||||||
|
AFTER INSERT ON public.user
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION create_user_nodes();
|
||||||
1
sql/00010.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE public."user" ADD preferences jsonb DEFAULT '{}' NOT NULL;
|
||||||
|
|
@ -102,6 +102,11 @@
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&.copy {
|
||||||
|
border: var(--markdown-copy-border);
|
||||||
|
background-color: var(--markdown-copy-background);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
|
|
@ -111,6 +116,14 @@
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|
||||||
|
&.copy {
|
||||||
|
border: var(--markdown-copy-border);
|
||||||
|
background-color: var(--markdown-copy-background);
|
||||||
|
code {
|
||||||
|
background-color: inherit !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
border: unset;
|
border: unset;
|
||||||
padding: unset;
|
padding: unset;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,15 @@
|
||||||
|
|
||||||
--line-color: #ccc;
|
--line-color: #ccc;
|
||||||
--tree-expander: 0px;
|
--tree-expander: 0px;
|
||||||
--functions-width: 180px;
|
--functions-width: 150px;
|
||||||
|
|
||||||
|
--menu-color: #fff;
|
||||||
|
--menu-item-hover-color: #f4f4f4;
|
||||||
|
|
||||||
|
--font-monospace: "Liberation Mono", monospace;
|
||||||
|
|
||||||
|
--markdown-copy-border: 1px solid #0a0;
|
||||||
|
--markdown-copy-background: #e3f4d7;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
|
|
@ -20,6 +28,10 @@ html {
|
||||||
filter: var(--colorize);
|
filter: var(--colorize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
font-family: var(--font-monospace);
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
|
|
@ -61,9 +73,10 @@ button {
|
||||||
1fr;
|
1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.page-history {
|
/* The other pages just gets the whole page without dividing it up. */
|
||||||
|
&:not(.page-node) {
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"tree-expander tree pad1 n2-pagehistory pad2"
|
"tree-expander tree pad1 n2-page pad2"
|
||||||
;
|
;
|
||||||
|
|
||||||
grid-template-columns:
|
grid-template-columns:
|
||||||
|
|
@ -186,6 +199,11 @@ button {
|
||||||
img {
|
img {
|
||||||
width: auto;
|
width: auto;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
|
|
||||||
|
&.deleted {
|
||||||
|
height: 24px;
|
||||||
|
transform: translateX(3px) translateY(3px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,7 +246,6 @@ button {
|
||||||
|
|
||||||
#notes2 {
|
#notes2 {
|
||||||
&.page-node {
|
&.page-node {
|
||||||
|
|
||||||
#page-root {
|
#page-root {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
@ -243,7 +260,7 @@ button {
|
||||||
display: contents;
|
display: contents;
|
||||||
|
|
||||||
n2-pagestorage {
|
n2-pagestorage {
|
||||||
grid-area: content;
|
grid-area: n2-page;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -251,9 +268,14 @@ button {
|
||||||
&.page-history {
|
&.page-history {
|
||||||
#page-history {
|
#page-history {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-area: n2-pagehistory;
|
grid-area: n2-page;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
n2-pagehistory {}
|
&.page-preferences {
|
||||||
|
#page-preferences {
|
||||||
|
display: block;
|
||||||
|
grid-area: n2-page;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -265,7 +287,6 @@ button {
|
||||||
#page-root {
|
#page-root {
|
||||||
display: contents !important;
|
display: contents !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -418,6 +439,8 @@ n2-nodeui {
|
||||||
font-size: 1.75em;
|
font-size: 1.75em;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
margin-bottom: 0px;
|
margin-bottom: 0px;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: min-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-functions {
|
.el-functions {
|
||||||
|
|
@ -431,7 +454,6 @@ n2-nodeui {
|
||||||
grid-area: content;
|
grid-area: content;
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
font-family: monospace;
|
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
color: #333;
|
color: #333;
|
||||||
|
|
||||||
|
|
@ -455,6 +477,10 @@ n2-nodeui {
|
||||||
grid-area: content;
|
grid-area: content;
|
||||||
display: none;
|
display: none;
|
||||||
|
|
||||||
|
font-family: var(--font-monospace);
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
border-top: 1px solid #e0e0e0;
|
border-top: 1px solid #e0e0e0;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
|
|
|
||||||
71
static/images/icon_drag.svg
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 6.35 6.35"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
sodipodi:docname="icon_drag.svg"
|
||||||
|
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
inkscape:zoom="22.627417"
|
||||||
|
inkscape:cx="11.291611"
|
||||||
|
inkscape:cy="10.84967"
|
||||||
|
inkscape:window-width="2190"
|
||||||
|
inkscape:window-height="1401"
|
||||||
|
inkscape:window-x="1463"
|
||||||
|
inkscape:window-y="18"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1"
|
||||||
|
showgrid="false" /><defs
|
||||||
|
id="defs1" /><g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-107.95,-148.16667)"><title
|
||||||
|
id="title1">folder-open</title><title
|
||||||
|
id="title1-1">folder-open-outline</title><title
|
||||||
|
id="title1-5">notebook-outline</title><title
|
||||||
|
id="title1-8">text-box-outline</title><path
|
||||||
|
style="fill:#ffffff;stroke-width:0.264583"
|
||||||
|
d="m 108.3015,148.56838 h 3.95851 v 3.96688 h -3.95851 z"
|
||||||
|
id="path3"
|
||||||
|
sodipodi:nodetypes="ccccc" /><path
|
||||||
|
d="m 108.47917,148.16667 c -0.29369,0 -0.52917,0.23548 -0.52917,0.52917 V 152.4 c 0,0.29369 0.23548,0.52917 0.52917,0.52917 h 3.70416 c 0.29369,0 0.52917,-0.23548 0.52917,-0.52917 v -3.70416 c 0,-0.29369 -0.23548,-0.52917 -0.52917,-0.52917 h -3.70416 m 0,0.52917 h 3.70416 V 152.4 h -3.70416 v -3.70416"
|
||||||
|
id="path1"
|
||||||
|
style="fill:#666666;fill-opacity:1;stroke:none;stroke-width:0.264583"
|
||||||
|
sodipodi:nodetypes="cssssssscccccc" /><path
|
||||||
|
d="m 109.00833,149.225 v 0.52917 h 2.64584 V 149.225 h -2.64584 m 0,1.05834 v 0.52916 h 2.64584 v -0.52916 h -2.64584 m 0,1.05833 v 0.52917 h 2.64584 v -0.52917 z"
|
||||||
|
id="path2"
|
||||||
|
style="fill:#999999;fill-opacity:1;stroke-width:0.264583"
|
||||||
|
sodipodi:nodetypes="ccccccccccccccc" /><g
|
||||||
|
id="g5"
|
||||||
|
transform="translate(0.26458031,0.26458956)"><g
|
||||||
|
id="g8"
|
||||||
|
transform="matrix(1.2067669,0,0,1.2067669,-23.043599,-31.373186)"><circle
|
||||||
|
style="fill:#800000;fill-opacity:1;stroke:none;stroke-width:1.14487;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="path8"
|
||||||
|
cx="112.05721"
|
||||||
|
cy="152.28557"
|
||||||
|
r="1.5347482" /></g><path
|
||||||
|
style="fill:none;stroke:#ffffff;stroke-width:0.79375;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 111.32748,151.54414 1.71172,1.71172"
|
||||||
|
id="path4" /><path
|
||||||
|
style="fill:none;stroke:#ffffff;stroke-width:0.79375;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 113.0392,151.54414 -1.71172,1.71172"
|
||||||
|
id="path5" /></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
75
static/images/icon_drag_ok.svg
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 6.35 6.35"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
sodipodi:docname="icon_drag_ok.svg"
|
||||||
|
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
inkscape:zoom="32"
|
||||||
|
inkscape:cx="13.859375"
|
||||||
|
inkscape:cy="14.890625"
|
||||||
|
inkscape:window-width="2190"
|
||||||
|
inkscape:window-height="1401"
|
||||||
|
inkscape:window-x="1463"
|
||||||
|
inkscape:window-y="18"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1"
|
||||||
|
showgrid="false" /><defs
|
||||||
|
id="defs1" /><g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-107.95,-148.16667)"><title
|
||||||
|
id="title1">folder-open</title><title
|
||||||
|
id="title1-1">folder-open-outline</title><title
|
||||||
|
id="title1-5">notebook-outline</title><title
|
||||||
|
id="title1-8">text-box-outline</title><path
|
||||||
|
style="fill:#ffffff;stroke-width:0.264583"
|
||||||
|
d="m 108.3015,148.56838 h 3.95851 v 3.96688 h -3.95851 z"
|
||||||
|
id="path3"
|
||||||
|
sodipodi:nodetypes="ccccc" /><title
|
||||||
|
id="title1-53">text-box-check-outline</title><path
|
||||||
|
style="stroke-width:0.264583;fill:#999999;fill-opacity:1"
|
||||||
|
d="m 111.65417,149.75417 h -2.64584 V 149.225 h 2.64584"
|
||||||
|
id="path7" /><path
|
||||||
|
style="fill:#999999;fill-opacity:1;stroke-width:0.264583"
|
||||||
|
d="m 109.00833,150.8125 v -0.52916 h 2.64584 v 0.52916"
|
||||||
|
id="path6"
|
||||||
|
sodipodi:nodetypes="cccc" /><path
|
||||||
|
style="fill:#999999;fill-opacity:1;stroke-width:0.313059"
|
||||||
|
d="m 111.65417,151.87084 h -2.64584 v -0.52917 h 2.64584"
|
||||||
|
id="path5"
|
||||||
|
sodipodi:nodetypes="cccc" /><path
|
||||||
|
style="fill:#666666;fill-opacity:1;stroke-width:0.264583"
|
||||||
|
d="m 111.26226,152.92917 h -2.78309 c -0.29369,0 -0.52917,-0.23548 -0.52917,-0.52917 v -3.70416 c 0,-0.29369 0.23548,-0.52917 0.52917,-0.52917 h 3.70416 c 0.29369,0 0.52917,0.23548 0.52917,0.52917 v 2.7004 c -0.1614,-0.0926 -0.33867,-0.15875 -0.52917,-0.1905 v -2.5099 h -3.70416 V 152.4 h 2.59259 c 0.0318,0.1905 0.0979,0.36777 0.1905,0.52917"
|
||||||
|
id="path4"
|
||||||
|
sodipodi:nodetypes="cssssssccccccc" /><g
|
||||||
|
id="g8"
|
||||||
|
transform="matrix(1.2067669,0,0,1.2067669,-23.043599,-31.373186)"><ellipse
|
||||||
|
style="fill:#338000;fill-opacity:1;stroke:none;stroke-width:1.14488;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="path8"
|
||||||
|
cx="112.27646"
|
||||||
|
cy="152.50487"
|
||||||
|
rx="1.5347482"
|
||||||
|
ry="1.5347837" /><path
|
||||||
|
style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583"
|
||||||
|
d="m 112.01188,153.31978 -0.72761,-0.79375 0.30692,-0.30692 0.42069,0.42069 0.94985,-0.94985 0.30692,0.37306"
|
||||||
|
id="path1-5" /></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
49
static/images/icon_drag_source.svg
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="15.999988"
|
||||||
|
height="15.999988"
|
||||||
|
viewBox="0 0 4.2333301 4.2333301"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
sodipodi:docname="icon_drag_source.svg"
|
||||||
|
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
inkscape:zoom="8.4025252"
|
||||||
|
inkscape:cx="45.522029"
|
||||||
|
inkscape:cy="-1.1306125"
|
||||||
|
inkscape:window-width="2190"
|
||||||
|
inkscape:window-height="1401"
|
||||||
|
inkscape:window-x="1463"
|
||||||
|
inkscape:window-y="18"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1" />
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-187.14773,-188.73523)">
|
||||||
|
<title
|
||||||
|
id="title1">drag-variant</title>
|
||||||
|
<path
|
||||||
|
d="m 191.38106,190.85189 -0.8907,0.89269 -0.49793,-0.49594 0.39279,-0.39675 -0.39279,-0.38881 0.49793,-0.49792 0.8907,0.88673 m -2.11667,-2.11666 0.88674,0.89071 -0.49791,0.49792 -0.38883,-0.39278 -0.39674,0.39278 -0.49594,-0.49792 0.89268,-0.89071 m 0,4.23333 -0.88673,-0.8907 0.49792,-0.49793 0.38881,0.39279 0.39675,-0.39279 0.49594,0.49793 -0.89269,0.8907 m -2.11666,-2.11667 0.89071,-0.89268 0.49792,0.49594 -0.39278,0.39674 0.39278,0.38883 -0.49792,0.49791 -0.89071,-0.88674 m 2.11666,-0.39674 a 0.3967509,0.3967509 0 0 1 0.39675,0.39674 0.3967509,0.3967509 0 0 1 -0.39675,0.39675 0.3967509,0.3967509 0 0 1 -0.39674,-0.39675 0.3967509,0.3967509 0 0 1 0.39674,-0.39674 z"
|
||||||
|
id="path1"
|
||||||
|
style="stroke-width:0.198375" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
56
static/images/icon_menu.svg
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="23.999981"
|
||||||
|
viewBox="0 0 3.1750001 6.349995"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||||
|
sodipodi:docname="icon_menu.svg"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
inkscape:zoom="11.859035"
|
||||||
|
inkscape:cx="8.6010372"
|
||||||
|
inkscape:cy="17.32856"
|
||||||
|
inkscape:window-width="2190"
|
||||||
|
inkscape:window-height="1401"
|
||||||
|
inkscape:window-x="1463"
|
||||||
|
inkscape:window-y="18"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1" /><defs
|
||||||
|
id="defs1" /><g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-147.15925,-92.339586)"><title
|
||||||
|
id="title1">menu</title><title
|
||||||
|
id="title1-6">hamburger</title><circle
|
||||||
|
style="fill:#000000;stroke:none;stroke-width:0.264583"
|
||||||
|
id="path3"
|
||||||
|
cx="149.55338"
|
||||||
|
cy="93.120461"
|
||||||
|
r="0.78087437" /><circle
|
||||||
|
style="fill:#000000;stroke:none;stroke-width:0.264583"
|
||||||
|
id="circle4"
|
||||||
|
cx="149.55338"
|
||||||
|
cy="97.908707"
|
||||||
|
r="0.78087437" /><circle
|
||||||
|
style="fill:#000000;stroke:none;stroke-width:0.264583"
|
||||||
|
id="circle5"
|
||||||
|
cx="149.55338"
|
||||||
|
cy="95.514587"
|
||||||
|
r="0.78087437" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
|
|
@ -24,12 +24,12 @@
|
||||||
inkscape:deskcolor="#d1d1d1"
|
inkscape:deskcolor="#d1d1d1"
|
||||||
inkscape:document-units="px"
|
inkscape:document-units="px"
|
||||||
inkscape:zoom="31.614857"
|
inkscape:zoom="31.614857"
|
||||||
inkscape:cx="5.0609117"
|
inkscape:cx="5.0450964"
|
||||||
inkscape:cy="9.5524708"
|
inkscape:cy="9.5682862"
|
||||||
inkscape:window-width="2190"
|
inkscape:window-width="2190"
|
||||||
inkscape:window-height="1401"
|
inkscape:window-height="1401"
|
||||||
inkscape:window-x="1463"
|
inkscape:window-x="1463"
|
||||||
inkscape:window-y="0"
|
inkscape:window-y="18"
|
||||||
inkscape:window-maximized="1"
|
inkscape:window-maximized="1"
|
||||||
inkscape:current-layer="layer1"
|
inkscape:current-layer="layer1"
|
||||||
showgrid="false" /><defs
|
showgrid="false" /><defs
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
68
static/images/leaf_deleted.svg
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="24.000015"
|
||||||
|
height="23.999998"
|
||||||
|
viewBox="0 0 6.350004 6.3499995"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
sodipodi:docname="leaf_deleted.svg"
|
||||||
|
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
inkscape:zoom="22.627417"
|
||||||
|
inkscape:cx="3.292466"
|
||||||
|
inkscape:cy="15.62264"
|
||||||
|
inkscape:window-width="2190"
|
||||||
|
inkscape:window-height="1401"
|
||||||
|
inkscape:window-x="1463"
|
||||||
|
inkscape:window-y="18"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1"
|
||||||
|
showgrid="false" /><defs
|
||||||
|
id="defs1" /><g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-107.95,-148.16667)"><title
|
||||||
|
id="title1">folder-open</title><title
|
||||||
|
id="title1-1">folder-open-outline</title><title
|
||||||
|
id="title1-5">notebook-outline</title><title
|
||||||
|
id="title1-8">text-box-outline</title><path
|
||||||
|
style="fill:#ffffff;stroke-width:0.264583"
|
||||||
|
d="m 108.3015,148.56838 h 3.95851 v 3.96688 h -3.95851 z"
|
||||||
|
id="path3"
|
||||||
|
sodipodi:nodetypes="ccccc" /><path
|
||||||
|
d="m 108.47917,148.16667 c -0.29369,0 -0.52917,0.23548 -0.52917,0.52917 V 152.4 c 0,0.29369 0.23548,0.52917 0.52917,0.52917 h 3.70416 c 0.29369,0 0.52917,-0.23548 0.52917,-0.52917 v -3.70416 c 0,-0.29369 -0.23548,-0.52917 -0.52917,-0.52917 h -3.70416 m 0,0.52917 h 3.70416 V 152.4 h -3.70416 v -3.70416"
|
||||||
|
id="path1"
|
||||||
|
style="fill:#ababab;fill-opacity:1;stroke-width:0.264583"
|
||||||
|
sodipodi:nodetypes="cssssssscccccc" /><path
|
||||||
|
d="m 109.00833,149.225 v 0.52917 h 2.64584 V 149.225 h -2.64584 m 0,1.05834 v 0.52916 h 2.64584 v -0.52916 h -2.64584 m 0,1.05833 v 0.52917 h 1.85209 v -0.52917 z"
|
||||||
|
id="path2"
|
||||||
|
style="fill:#c7c7c7;fill-opacity:1;stroke-width:0.264583"
|
||||||
|
sodipodi:nodetypes="ccccccccccccccc" /><title
|
||||||
|
id="title1-9">delete-circle</title><g
|
||||||
|
id="g1"
|
||||||
|
transform="matrix(1.6249303,0,0,1.6249427,-68.307567,-93.38766)"><rect
|
||||||
|
style="fill:#ffffff;stroke:none;stroke-width:0.79375"
|
||||||
|
id="rect1"
|
||||||
|
width="1.5817325"
|
||||||
|
height="1.8579081"
|
||||||
|
x="110.28522"
|
||||||
|
y="150.33034" /><path
|
||||||
|
d="m 111.0761,149.95667 c 0.72034,0 1.30261,0.58227 1.30261,1.30262 0,0.72035 -0.58227,1.3026 -1.30261,1.3026 -0.72035,0 -1.30263,-0.58225 -1.30263,-1.3026 0,-0.72035 0.58227,-1.30262 1.30263,-1.30262 m 0.6513,0.65131 h -0.32566 l -0.13026,-0.13026 h -0.39078 l -0.13027,0.13026 h -0.32565 v 0.26052 h 1.30262 v -0.26052 m -1.0421,1.43288 h 0.78157 a 0.13026143,0.13026143 0 0 0 0.13026,-0.13026 v -0.91184 h -1.04209 v 0.91184 a 0.13026143,0.13026143 0 0 0 0.13026,0.13026 z"
|
||||||
|
id="path1-1"
|
||||||
|
style="fill:#aa0000;stroke-width:0.130261" /></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 3.3 KiB |
61
static/images/leaf_orphaned.svg
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="24.000015"
|
||||||
|
height="23.999998"
|
||||||
|
viewBox="0 0 6.350004 6.3499995"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
sodipodi:docname="leaf_orphaned.svg"
|
||||||
|
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
inkscape:zoom="22.627417"
|
||||||
|
inkscape:cx="3.2924659"
|
||||||
|
inkscape:cy="15.600543"
|
||||||
|
inkscape:window-width="1916"
|
||||||
|
inkscape:window-height="1161"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1"
|
||||||
|
showgrid="false" /><defs
|
||||||
|
id="defs1" /><g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-107.95,-148.16667)"><title
|
||||||
|
id="title1">folder-open</title><title
|
||||||
|
id="title1-1">folder-open-outline</title><title
|
||||||
|
id="title1-5">notebook-outline</title><title
|
||||||
|
id="title1-8">text-box-outline</title><path
|
||||||
|
style="fill:#ffffff;stroke-width:0.264583"
|
||||||
|
d="m 108.3015,148.56838 h 3.95851 v 3.96688 h -3.95851 z"
|
||||||
|
id="path3"
|
||||||
|
sodipodi:nodetypes="ccccc" /><path
|
||||||
|
d="m 108.47917,148.16667 c -0.29369,0 -0.52917,0.23548 -0.52917,0.52917 V 152.4 c 0,0.29369 0.23548,0.52917 0.52917,0.52917 h 3.70416 c 0.29369,0 0.52917,-0.23548 0.52917,-0.52917 v -3.70416 c 0,-0.29369 -0.23548,-0.52917 -0.52917,-0.52917 h -3.70416 m 0,0.52917 h 3.70416 V 152.4 h -3.70416 v -3.70416"
|
||||||
|
id="path1"
|
||||||
|
style="fill:#ababab;fill-opacity:1;stroke-width:0.264583"
|
||||||
|
sodipodi:nodetypes="cssssssscccccc" /><path
|
||||||
|
d="m 109.00833,149.225 v 0.52917 h 2.64584 V 149.225 h -2.64584 m 0,1.05834 v 0.52916 h 2.64584 v -0.52916 h -2.64584 m 0,1.05833 v 0.52917 h 1.85209 v -0.52917 z"
|
||||||
|
id="path2"
|
||||||
|
style="fill:#c7c7c7;fill-opacity:1;stroke-width:0.264583"
|
||||||
|
sodipodi:nodetypes="ccccccccccccccc" /><title
|
||||||
|
id="title1-9">delete-circle</title><title
|
||||||
|
id="title1-53">ghost</title><path
|
||||||
|
d="m 112.39499,150.28334 a 1.9050024,1.9050024 0 0 0 -1.905,1.905 v 2.32833 l 0.635,-0.635 0.635,0.635 0.635,-0.635 0.635,0.635 0.635,-0.635 0.63501,0.635 v -2.32833 a 1.9050024,1.9050024 0 0 0 -1.90501,-1.905 m -0.635,1.27 a 0.42333387,0.42333387 0 0 1 0.42334,0.42333 0.42333387,0.42333387 0 0 1 -0.42334,0.42334 0.42333387,0.42333387 0 0 1 -0.42333,-0.42334 0.42333387,0.42333387 0 0 1 0.42333,-0.42333 m 1.27,0 a 0.42333387,0.42333387 0 0 1 0.42334,0.42333 0.42333387,0.42333387 0 0 1 -0.42334,0.42334 0.42333387,0.42333387 0 0 1 -0.42333,-0.42334 0.42333387,0.42333387 0 0 1 0.42333,-0.42333 z"
|
||||||
|
id="path1-5"
|
||||||
|
style="fill:#005190;fill-opacity:1;stroke-width:0.264583" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
|
|
@ -1,7 +1,7 @@
|
||||||
export class API {
|
export class API {
|
||||||
// query resolves into the JSON data produced by the application, or an exception with 'type' and 'error' properties.
|
// query resolves into the JSON data produced by the application, or an exception with 'type' and 'error' properties.
|
||||||
static async query(method, path, request) {
|
static async query(method, path, request) {
|
||||||
return new Promise((resolve, reject) => {
|
try {
|
||||||
const body = JSON.stringify(request)
|
const body = JSON.stringify(request)
|
||||||
const headers = {}
|
const headers = {}
|
||||||
|
|
||||||
|
|
@ -12,33 +12,22 @@ export class API {
|
||||||
headers.Authorization = `Bearer ${token}`
|
headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch(path, { method, headers, body })
|
const res = await fetch(path, { method, headers, body })
|
||||||
.then(response => {
|
|
||||||
// An HTTP communication level error occured.
|
// An HTTP communication level error occured.
|
||||||
if (!response.ok || response.status != 200)
|
if (!res.ok || res.status != 200)
|
||||||
return reject({
|
throw new Error('HTTP error', { cause: { type: 'http', error: res, }})
|
||||||
type: 'http',
|
|
||||||
error: response,
|
|
||||||
})
|
|
||||||
return response.json()
|
|
||||||
})
|
|
||||||
.then(json => {
|
|
||||||
// Application level response are handled here.
|
// Application level response are handled here.
|
||||||
|
const json = await res.json()
|
||||||
if (!json.OK)
|
if (!json.OK)
|
||||||
return reject({
|
throw new Error(json.Error, { cause: { type: 'application', application: json, }})
|
||||||
type: 'application',
|
|
||||||
error: json.Error,
|
return json
|
||||||
application: json,
|
|
||||||
})
|
} catch (err) {
|
||||||
resolve(json)
|
|
||||||
})
|
|
||||||
.catch(err =>
|
|
||||||
// Catch any other errors from fetch.
|
// Catch any other errors from fetch.
|
||||||
reject({
|
throw new Error(err.message, { cause: { type: 'http', error: err, }})
|
||||||
type: 'http',
|
}
|
||||||
error: err,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static hasAuthenticationToken() {//{{{
|
static hasAuthenticationToken() {//{{{
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { ROOT_NODE } from 'node_store'
|
||||||
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
||||||
import { N2Sidebar } from 'sidebar'
|
import { N2Sidebar } from 'sidebar'
|
||||||
import { Node } from 'node'
|
import { Node } from 'node'
|
||||||
|
import { N2PreferenceSet } from './page_preferences.mjs'
|
||||||
|
|
||||||
export class App {
|
export class App {
|
||||||
static PAGES = ['node', 'history', 'storage']
|
static PAGES = ['node', 'history', 'storage']
|
||||||
|
|
@ -12,6 +13,9 @@ export class App {
|
||||||
this.crumbs = new N2Crumbs()
|
this.crumbs = new N2Crumbs()
|
||||||
this.crumbsElement = document.getElementById('crumbs')
|
this.crumbsElement = document.getElementById('crumbs')
|
||||||
this.nodeUI = document.getElementById('note')
|
this.nodeUI = document.getElementById('note')
|
||||||
|
this.dragIcon = new N2DragIcon()
|
||||||
|
|
||||||
|
this.preferences = this.getPreferences()
|
||||||
|
|
||||||
this.sidebar.render().then(sidebar => {
|
this.sidebar.render().then(sidebar => {
|
||||||
document.getElementById('tree').append(sidebar)
|
document.getElementById('tree').append(sidebar)
|
||||||
|
|
@ -60,6 +64,11 @@ export class App {
|
||||||
classList.add('page-' + page)
|
classList.add('page-' + page)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
_mbus.subscribe('DEVICE_PREFERENCE_SET_UPDATED', ()=>{
|
||||||
|
this.preferences = this.getPreferences()
|
||||||
|
console.log(this.preferences.data)
|
||||||
|
})
|
||||||
|
|
||||||
window.addEventListener('keydown', event => this.keyHandler(event))
|
window.addEventListener('keydown', event => this.keyHandler(event))
|
||||||
window.addEventListener('popstate', event => this.popState(event))
|
window.addEventListener('popstate', event => this.popState(event))
|
||||||
document.getElementById('notes2').addEventListener('click', event => {
|
document.getElementById('notes2').addEventListener('click', event => {
|
||||||
|
|
@ -68,6 +77,7 @@ export class App {
|
||||||
})
|
})
|
||||||
|
|
||||||
document.querySelector('#page-root .create').addEventListener('click', () => this.createNode())
|
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' })
|
||||||
|
|
||||||
|
|
@ -78,13 +88,13 @@ 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.
|
// Most keybindings is Alt+Shift, since the popular browsers at the time (2023) allows to override thees.
|
||||||
// Ctrl+S is the exception to using Alt+Shift, since it is overridable and in such widespread use for saving.
|
// Ctrl+S is the exception to using Alt+Shift, since it is overridable and in such widespread use for saving.
|
||||||
// Thus, the exception is acceptable to consequent use of alt+shift.
|
// Thus, the exception is acceptable to consequent use of alt+shift.
|
||||||
|
const CTRL = !event.shiftKey && event.ctrlKey && !event.altKey
|
||||||
const SHIFT_ALT = event.shiftKey && !event.ctrlKey && event.altKey
|
const SHIFT_ALT = event.shiftKey && !event.ctrlKey && event.altKey
|
||||||
const SHIFT_CTRL_ALT = event.shiftKey && event.ctrlKey && event.altKey
|
const SHIFT_CTRL_ALT = event.shiftKey && event.ctrlKey && event.altKey
|
||||||
|
|
||||||
|
|
@ -121,7 +131,7 @@ export class App {
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'S':
|
case 'S':
|
||||||
if (!SHIFT_ALT) { handled = false; break }
|
if (!CTRL) { handled = false; break }
|
||||||
this.nodeUI.saveNode()
|
this.nodeUI.saveNode()
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
@ -149,6 +159,10 @@ export class App {
|
||||||
}//}}}
|
}//}}}
|
||||||
async saveNode() {//{{{
|
async saveNode() {//{{{
|
||||||
|
|
||||||
|
}//}}}
|
||||||
|
async moveNode(node, targetNodeUUID) {// {{{
|
||||||
|
node.moveToParent(targetNodeUUID)
|
||||||
|
await node.save()
|
||||||
}// }}}
|
}// }}}
|
||||||
async createNode(createUnderUUID) {//{{{
|
async createNode(createUnderUUID) {//{{{
|
||||||
const parentUUID = createUnderUUID ? createUnderUUID : this.currentNode.UUID
|
const parentUUID = createUnderUUID ? createUnderUUID : this.currentNode.UUID
|
||||||
|
|
@ -205,6 +219,12 @@ export class App {
|
||||||
let classList = document.querySelector('#main-page').classList
|
let classList = document.querySelector('#main-page').classList
|
||||||
return classList.contains(page)
|
return classList.contains(page)
|
||||||
}// }}}
|
}// }}}
|
||||||
|
getPreferences() {// {{{
|
||||||
|
const devPrefSet = localStorage.getItem('device_preference_set') || 'default'
|
||||||
|
const userData = localStorage.getItem('user') || '{"default": {}}'
|
||||||
|
const user = JSON.parse(userData)
|
||||||
|
return new N2PreferenceSet(devPrefSet, user.Preferences[devPrefSet])
|
||||||
|
}// }}}
|
||||||
}
|
}
|
||||||
|
|
||||||
class N2Crumbs extends CustomHTMLElement {
|
class N2Crumbs extends CustomHTMLElement {
|
||||||
|
|
@ -238,7 +258,6 @@ class N2Crumbs extends CustomHTMLElement {
|
||||||
return this
|
return this
|
||||||
}// }}}
|
}// }}}
|
||||||
}
|
}
|
||||||
customElements.define('n2-crumbs', N2Crumbs)
|
|
||||||
|
|
||||||
class N2Crumb extends CustomHTMLElement {
|
class N2Crumb extends CustomHTMLElement {
|
||||||
static {// {{{
|
static {// {{{
|
||||||
|
|
@ -269,7 +288,6 @@ class N2Crumb extends CustomHTMLElement {
|
||||||
this.elLink.addEventListener('click', () => _mbus.dispatch("GO_TO_NODE", { nodeUUID: this.uuid, dontPush: false, dontExpand: true }))
|
this.elLink.addEventListener('click', () => _mbus.dispatch("GO_TO_NODE", { nodeUUID: this.uuid, dontPush: false, dontExpand: true }))
|
||||||
}// }}}
|
}// }}}
|
||||||
}
|
}
|
||||||
customElements.define('n2-crumb', N2Crumb)
|
|
||||||
|
|
||||||
function tmpl(html) {// {{{
|
function tmpl(html) {// {{{
|
||||||
const el = document.createElement('template')
|
const el = document.createElement('template')
|
||||||
|
|
@ -343,4 +361,52 @@ class OpSearch extends Op {
|
||||||
}// }}}
|
}// }}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class N2DragIcon extends CustomHTMLElement {
|
||||||
|
static {// {{{
|
||||||
|
this.tmpl = document.createElement('template')
|
||||||
|
this.tmpl.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 16384;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<img data-el="icon" src="/images/${_VERSION}/icon_drag.svg">
|
||||||
|
`
|
||||||
|
}// }}}
|
||||||
|
constructor() {// {{{
|
||||||
|
super(true)
|
||||||
|
|
||||||
|
document.addEventListener('dragover', e => {
|
||||||
|
this.style.left = `${e.clientX + 8}px`
|
||||||
|
this.style.top = `${e.clientY}px`
|
||||||
|
})
|
||||||
|
|
||||||
|
this.dragSource = null
|
||||||
|
}// }}}
|
||||||
|
start() {// {{{
|
||||||
|
this.style.display = 'block'
|
||||||
|
}// }}}
|
||||||
|
end() {// {{{
|
||||||
|
this.style.display = 'none'
|
||||||
|
}// }}}
|
||||||
|
icon(name) {// {{{
|
||||||
|
if (name != '')
|
||||||
|
name = '_' + name
|
||||||
|
this.elIcon.setAttribute('src', `/images/${_VERSION}/icon_drag${name}.svg`)
|
||||||
|
}// }}}
|
||||||
|
setSource(s) {// {{{
|
||||||
|
this.dragSource = s
|
||||||
|
}// }}}
|
||||||
|
getSource() {// {{{
|
||||||
|
return this.dragSource
|
||||||
|
}// }}}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('n2-crumbs', N2Crumbs)
|
||||||
|
customElements.define('n2-crumb', N2Crumb)
|
||||||
|
customElements.define('n2-dragicon', N2DragIcon)
|
||||||
|
|
||||||
// vim: foldmethod=marker
|
// vim: foldmethod=marker
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,17 @@
|
||||||
|
/* Use data-el or data-field attribute.
|
||||||
|
* Element with data-el="hum-ding" is accessible as this.elHumDing and fields with
|
||||||
|
* data-field="long-dong" as this.fieldLongDong.
|
||||||
|
*
|
||||||
|
* All field values can be retrieved with fieldValues() and uses the data-field attribute
|
||||||
|
* as LongDong as key.
|
||||||
|
*/
|
||||||
|
|
||||||
export class CustomHTMLElement extends HTMLElement {
|
export class CustomHTMLElement extends HTMLElement {
|
||||||
constructor(useShadow) {// {{{
|
constructor(useShadow) {// {{{
|
||||||
super()
|
super()
|
||||||
|
|
||||||
|
this._fields = new Map()
|
||||||
|
|
||||||
const workOn = useShadow ? this.attachShadow({ mode: 'open' }) : this
|
const workOn = useShadow ? this.attachShadow({ mode: 'open' }) : this
|
||||||
workOn.appendChild(this.constructor.tmpl.content.cloneNode(true))
|
workOn.appendChild(this.constructor.tmpl.content.cloneNode(true))
|
||||||
workOn.querySelectorAll('*').forEach(el => {
|
workOn.querySelectorAll('*').forEach(el => {
|
||||||
|
|
@ -9,6 +19,7 @@ export class CustomHTMLElement extends HTMLElement {
|
||||||
if (field !== undefined) {
|
if (field !== undefined) {
|
||||||
const fieldName = this.toElementName('field', field)
|
const fieldName = this.toElementName('field', field)
|
||||||
this[fieldName] = el
|
this[fieldName] = el
|
||||||
|
this._fields.set(this.toElementName('', field), el)
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = el.dataset.el
|
const name = el.dataset.el
|
||||||
|
|
@ -19,39 +30,22 @@ export class CustomHTMLElement extends HTMLElement {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}// }}}
|
}// }}}
|
||||||
|
allFields() {// {{{
|
||||||
|
return this._fields
|
||||||
|
}// }}}
|
||||||
|
fieldValues() {// {{{
|
||||||
|
const state = {}
|
||||||
|
for (const [name, field] of this._fields) {
|
||||||
|
if (field.tagName.toLowerCase() == 'input' && field.getAttribute('type').toLowerCase() == 'checkbox')
|
||||||
|
state[name] = field.checked
|
||||||
|
else
|
||||||
|
state[name] = field.value
|
||||||
|
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}// }}}
|
||||||
toElementName(prefix, str) {// {{{
|
toElementName(prefix, str) {// {{{
|
||||||
str = prefix + '-' + str
|
str = prefix + '-' + str
|
||||||
return str.replace(/-(id|[a-z])/g, match => match.toUpperCase().replace('-', ''))
|
return str.replace(/-(id|[a-z])/g, match => match.toUpperCase().replace('-', ''))
|
||||||
}// }}}
|
}// }}}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StupidPreactCustomHTMLElement extends HTMLElement {
|
|
||||||
constructor() {// {{{
|
|
||||||
super()
|
|
||||||
|
|
||||||
// Stupid stuff because of Preact.
|
|
||||||
this.clonedNodes = this.constructor.tmpl.content.cloneNode(true)
|
|
||||||
this.clonedNodes.querySelectorAll('*').forEach(el => {
|
|
||||||
const field = el.dataset.field
|
|
||||||
if (field !== undefined) {
|
|
||||||
const fieldName = this.toElementName('field', field)
|
|
||||||
this[fieldName] = el
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = el.dataset.el
|
|
||||||
if (name !== undefined) {
|
|
||||||
const elName = this.toElementName('el', name)
|
|
||||||
this[elName] = el
|
|
||||||
el.classList.add('el-' + name)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}// }}}
|
|
||||||
toElementName(prefix, str) {// {{{
|
|
||||||
str = prefix + '-' + str
|
|
||||||
return str.replace(/-(id|[a-z])/g, match => match.toUpperCase().replace('-', ''))
|
|
||||||
}// }}}
|
|
||||||
connectedCallback() {// {{{
|
|
||||||
// Stupid stuff because of Preact.
|
|
||||||
this.appendChild(this.clonedNodes)
|
|
||||||
}// }}}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ export class MarkedPosition {
|
||||||
constructor() {// {{{
|
constructor() {// {{{
|
||||||
window.marked_setpos = (event) => this.setpos(event)
|
window.marked_setpos = (event) => this.setpos(event)
|
||||||
window.marked_changecheckbox = (event) => this.changecheckbox(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) {// {{{
|
||||||
|
|
@ -119,8 +120,32 @@ export class MarkedPosition {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}// }}}
|
}// }}}
|
||||||
|
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({
|
||||||
|
|
@ -165,12 +190,12 @@ export class MarkedPosition {
|
||||||
const code = token.text.replace(other.endingNewline, '') + '\n'
|
const code = token.text.replace(other.endingNewline, '') + '\n'
|
||||||
|
|
||||||
if (!langString) {
|
if (!langString) {
|
||||||
return `<pre ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"><code>`
|
return `<pre ondblclick="marked_setpos(event)" onmousedown="marked_copy_to_clipboard(event, 'pre')" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"><code>`
|
||||||
+ (token.escaped ? code : escapeHtmlEntities(code, true))
|
+ (token.escaped ? code : escapeHtmlEntities(code, true))
|
||||||
+ '</code></pre>\n'
|
+ '</code></pre>\n'
|
||||||
}
|
}
|
||||||
|
|
||||||
return `<pre ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"><code class="language-`
|
return `<pre ondblclick="marked_setpos(event)" onmousedown="marked_copy_to_clipboard(event, 'pre')" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"><code class="language-`
|
||||||
+ escapeHtmlEntities(langString)
|
+ escapeHtmlEntities(langString)
|
||||||
+ '">'
|
+ '">'
|
||||||
+ (token.escaped ? code : escapeHtmlEntities(code, true))
|
+ (token.escaped ? code : escapeHtmlEntities(code, true))
|
||||||
|
|
@ -260,7 +285,7 @@ export class MarkedPosition {
|
||||||
},
|
},
|
||||||
|
|
||||||
codespan(token) {
|
codespan(token) {
|
||||||
return `<code ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${escapeHtmlEntities(token.text, true)}</code>`
|
return `<code ondblclick="marked_setpos(event)" onmousedown="marked_copy_to_clipboard(event, 'code')" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${escapeHtmlEntities(token.text, true)}</code>`
|
||||||
},
|
},
|
||||||
|
|
||||||
br(token) {
|
br(token) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { Node } from 'node'
|
import { Node } from 'node'
|
||||||
|
|
||||||
export const ROOT_NODE = '00000000-0000-0000-0000-000000000000'
|
export const ROOT_NODE = '00000000-0000-0000-0000-000000000000'
|
||||||
|
export const ORPHANED_NODE = '00000000-0000-0000-0000-000000000001'
|
||||||
|
export const DELETED_NODE = '00000000-0000-0000-0000-000000000002'
|
||||||
|
|
||||||
export class NodeStore {
|
export class NodeStore {
|
||||||
constructor() {//{{{
|
constructor() {//{{{
|
||||||
|
|
@ -13,6 +15,8 @@ export class NodeStore {
|
||||||
this.sendQueue = null
|
this.sendQueue = null
|
||||||
this.nodesHistory = null
|
this.nodesHistory = null
|
||||||
this.files = null
|
this.files = null
|
||||||
|
|
||||||
|
this.initializeSpecialNodes()
|
||||||
}//}}}
|
}//}}}
|
||||||
initializeDB() {//{{{
|
initializeDB() {//{{{
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|
@ -76,8 +80,7 @@ export class NodeStore {
|
||||||
this.sendQueue = new SimpleNodeStore(this.db, 'send_queue')
|
this.sendQueue = new SimpleNodeStore(this.db, 'send_queue')
|
||||||
this.nodesHistory = new NodeHistoryStore(this.db, 'nodes_history')
|
this.nodesHistory = new NodeHistoryStore(this.db, 'nodes_history')
|
||||||
this.files = new SimpleNodeStore(this.db, 'files')
|
this.files = new SimpleNodeStore(this.db, 'files')
|
||||||
this.initializeRootNode()
|
resolve()
|
||||||
.then(() => resolve())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
req.onerror = (event) => {
|
req.onerror = (event) => {
|
||||||
|
|
@ -85,39 +88,10 @@ export class NodeStore {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}//}}}
|
}//}}}
|
||||||
initializeRootNode() {//{{{
|
initializeSpecialNodes() {// {{{
|
||||||
return new Promise((resolve, reject) => {
|
this.nodes[ROOT_NODE] = new Node({ UUID: ROOT_NODE, Name: 'Start', Special: true }, -1)
|
||||||
// The root node is a magical node which displays as the first node if none is specified.
|
this.nodes[DELETED_NODE] = new Node({ UUID: DELETED_NODE, Name: 'Deleted nodes', Special: true }, -1)
|
||||||
// If not already existing, it will be created.
|
this.nodes[ORPHANED_NODE] = new Node({ UUID: ORPHANED_NODE, Name: 'Orphaned nodes', Special: true }, -1)
|
||||||
const trx = this.db.transaction('nodes', 'readwrite')
|
|
||||||
const nodes = trx.objectStore('nodes')
|
|
||||||
const getRequest = nodes.get(ROOT_NODE)
|
|
||||||
getRequest.onsuccess = (event) => {
|
|
||||||
// Root node exists - nice!
|
|
||||||
if (event.target.result !== undefined) {
|
|
||||||
resolve(event.target.result)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const putRequest = nodes.put({
|
|
||||||
UUID: ROOT_NODE,
|
|
||||||
Name: 'Notes2',
|
|
||||||
Content: 'Hello, World!',
|
|
||||||
Updated: new Date().toISOString(),
|
|
||||||
ParentUUID: '',
|
|
||||||
})
|
|
||||||
putRequest.onsuccess = (event) => {
|
|
||||||
resolve(event.target.result)
|
|
||||||
}
|
|
||||||
putRequest.onerror = (event) => {
|
|
||||||
reject(event.target.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
getRequest.onerror = (event) => reject(event.target.error)
|
|
||||||
})
|
|
||||||
}//}}}
|
|
||||||
purgeCache() {//{{{
|
|
||||||
this.nodes = {}
|
|
||||||
}// }}}
|
}// }}}
|
||||||
|
|
||||||
node(uuid, dataIfUndefined, newLevel) {//{{{
|
node(uuid, dataIfUndefined, newLevel) {//{{{
|
||||||
|
|
@ -247,6 +221,7 @@ export class NodeStore {
|
||||||
nodeStore = t.objectStore('nodes')
|
nodeStore = t.objectStore('nodes')
|
||||||
|
|
||||||
t.oncomplete = (_event) => {
|
t.oncomplete = (_event) => {
|
||||||
|
console.log('complete')
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -271,6 +246,14 @@ export class NodeStore {
|
||||||
}//}}}
|
}//}}}
|
||||||
get(uuid, suppliedNodestore) {//{{{
|
get(uuid, suppliedNodestore) {//{{{
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
switch (uuid) {
|
||||||
|
case ROOT_NODE:
|
||||||
|
case DELETED_NODE:
|
||||||
|
case ORPHANED_NODE:
|
||||||
|
resolve(this.nodes[uuid])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// A nodestore can be provided in order to
|
// A nodestore can be provided in order to
|
||||||
// avoid creating new transactions.
|
// avoid creating new transactions.
|
||||||
let trx
|
let trx
|
||||||
|
|
@ -308,6 +291,16 @@ export class NodeStore {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (node.UUID === DELETED_NODE || node.ParentUUID === DELETED_NODE) {
|
||||||
|
resolve(accumulated)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.UUID === ORPHANED_NODE || node.ParentUUID === ORPHANED_NODE) {
|
||||||
|
resolve(accumulated)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const getRequest = nodeParentIndex.get(node.ParentUUID)
|
const getRequest = nodeParentIndex.get(node.ParentUUID)
|
||||||
getRequest.onsuccess = (event) => {
|
getRequest.onsuccess = (event) => {
|
||||||
// Node not found in IndexedDB.
|
// Node not found in IndexedDB.
|
||||||
|
|
@ -358,6 +351,7 @@ class SimpleNodeStore {
|
||||||
// Node to be moved is first stored in the new queue.
|
// Node to be moved is first stored in the new queue.
|
||||||
const req = store.put(node.data)
|
const req = store.put(node.data)
|
||||||
req.onsuccess = () => {
|
req.onsuccess = () => {
|
||||||
|
console.log('here')
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
req.onerror = (event) => {
|
req.onerror = (event) => {
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,70 @@ import { ROOT_NODE, uuidv7 } from 'node_store'
|
||||||
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
||||||
import { MarkedPosition } from './marked_position.mjs'
|
import { MarkedPosition } from './marked_position.mjs'
|
||||||
|
|
||||||
|
class N2NodeMenu extends CustomHTMLElement {
|
||||||
|
static {// {{{
|
||||||
|
this.tmpl = document.createElement('template')
|
||||||
|
this.tmpl.innerHTML = `
|
||||||
|
<style>
|
||||||
|
n2-nodemenu {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding: 0;
|
||||||
|
position-anchor: --node-menu;
|
||||||
|
box-shadow: rgba(0, 0, 0, 0.05) 0px 6px 24px 0px, rgba(0, 0, 0, 0.08) 0px 0px 0px 1px;
|
||||||
|
|
||||||
|
top: anchor(bottom);
|
||||||
|
right: anchor(right);
|
||||||
|
left: auto;
|
||||||
|
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
padding: 8px 16px 8px 8px;
|
||||||
|
border-bottom: 1px solid var(--line-color);
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: min-content 1fr;
|
||||||
|
grid-gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--menu-item-hover-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="node-menu">
|
||||||
|
<div class="menu-item" data-el="format-tables">
|
||||||
|
<img class="colorize" src="/images/${_VERSION}/icon_table.svg">
|
||||||
|
<div>Format tables</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-item" data-el="history">
|
||||||
|
<img class="colorize" src="/images/${_VERSION}/icon_history.svg">
|
||||||
|
<div>History</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}// }}}
|
||||||
|
constructor() {// {{{
|
||||||
|
super()
|
||||||
|
}// }}}
|
||||||
|
}
|
||||||
|
customElements.define('n2-nodemenu', N2NodeMenu)
|
||||||
|
|
||||||
export class N2PageNodeUI extends CustomHTMLElement {
|
export class N2PageNodeUI extends CustomHTMLElement {
|
||||||
static {// {{{
|
static {// {{{
|
||||||
this.tmpl = document.createElement('template')
|
this.tmpl = document.createElement('template')
|
||||||
this.tmpl.innerHTML = `
|
this.tmpl.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
.el-functions {
|
n2-nodeui > .el-functions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr repeat(4, min-content);
|
grid-template-columns: 1fr repeat(3, min-content);
|
||||||
grid-gap: 8px;
|
grid-gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-items: end;
|
justify-items: end;
|
||||||
|
|
@ -18,6 +74,14 @@ export class N2PageNodeUI extends CustomHTMLElement {
|
||||||
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>
|
||||||
|
|
@ -28,10 +92,13 @@ export class N2PageNodeUI extends CustomHTMLElement {
|
||||||
<div data-el="functions">
|
<div data-el="functions">
|
||||||
<img data-el="icon-save" src="/images/${_VERSION}/icon_save_disabled.svg">
|
<img data-el="icon-save" src="/images/${_VERSION}/icon_save_disabled.svg">
|
||||||
<img data-el="icon-markdown">
|
<img data-el="icon-markdown">
|
||||||
<img data-el="icon-table-format" class="colorize" src="/images/${_VERSION}/icon_table.svg">
|
|
||||||
<img data-el="icon-history" class="colorize" src="/images/${_VERSION}/icon_history.svg">
|
|
||||||
<img data-el="icon-new-document" class="colorize" src="/images/${_VERSION}/icon_new_document.svg">
|
<img data-el="icon-new-document" class="colorize" src="/images/${_VERSION}/icon_new_document.svg">
|
||||||
|
<button data-el="menu" popovertarget="node-functions-menu">
|
||||||
|
<img data-el="icon-menu" class="colorize" src="/images/${_VERSION}/icon_menu.svg">
|
||||||
|
</button>
|
||||||
|
<n2-nodemenu data-el="node-menu" id="node-functions-menu" popover></n2-nodemenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
`
|
`
|
||||||
}// }}}
|
}// }}}
|
||||||
|
|
||||||
|
|
@ -40,11 +107,13 @@ export class N2PageNodeUI extends CustomHTMLElement {
|
||||||
this.node = null
|
this.node = null
|
||||||
|
|
||||||
this.style.display = 'contents'
|
this.style.display = 'contents'
|
||||||
this.classList.add('show-markdown') // TODO Should probably be moved to settings.
|
|
||||||
this.marked = new MarkedPosition()
|
this.marked = new MarkedPosition()
|
||||||
|
|
||||||
_mbus.subscribe('NODE_UI_OPEN', event => {
|
_mbus.subscribe('NODE_UI_OPEN', event => {
|
||||||
this.node = event.detail.data
|
this.node = event.detail.data
|
||||||
|
|
||||||
|
|
||||||
|
if (!this.node.isSpecial())
|
||||||
this.showMarkdown(true)
|
this.showMarkdown(true)
|
||||||
this.render()
|
this.render()
|
||||||
})
|
})
|
||||||
|
|
@ -66,11 +135,27 @@ export class N2PageNodeUI extends CustomHTMLElement {
|
||||||
_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))
|
_mbus.subscribe('MARKDOWN_CHANGE_CHECKBOX', ({ detail }) => this.checkboxUpdated(detail.data))
|
||||||
|
|
||||||
|
// Binding the node rename handler.
|
||||||
this.elName.addEventListener('click', async () => this.renameNode())
|
this.elName.addEventListener('click', async () => this.renameNode())
|
||||||
|
|
||||||
|
// Bind handlers for content keyboard input and paste.
|
||||||
this.elNodeContent.addEventListener('input', event => this.contentChanged(event))
|
this.elNodeContent.addEventListener('input', event => this.contentChanged(event))
|
||||||
this.elNodeContent.addEventListener('paste', async (event) => this.pasteHandler(event))
|
this.elNodeContent.addEventListener('paste', async (event) => this.pasteHandler(event))
|
||||||
|
|
||||||
|
// Bind node icon handlers.
|
||||||
|
this.elIconSave.addEventListener('click', () => this.saveNode())
|
||||||
this.elIconMarkdown.addEventListener('click', () => this.showMarkdown(!this.showMarkdown()))
|
this.elIconMarkdown.addEventListener('click', () => this.showMarkdown(!this.showMarkdown()))
|
||||||
this.elIconTableFormat.addEventListener('click', event => {
|
this.elIconNewDocument.addEventListener('click', event => {
|
||||||
|
if (event.shiftKey)
|
||||||
|
_app.createNode(this.node.ParentUUID)
|
||||||
|
else
|
||||||
|
_app.createNode()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Bind node menu items to handlers.
|
||||||
|
this.elNodeMenu.elFormatTables.addEventListener('click', event => {
|
||||||
|
this.elNodeMenu.hidePopover()
|
||||||
|
|
||||||
if (!event.shiftKey)
|
if (!event.shiftKey)
|
||||||
this.elNodeContent.value = this.formatAllTables(this.elNodeContent.value)
|
this.elNodeContent.value = this.formatAllTables(this.elNodeContent.value)
|
||||||
else {
|
else {
|
||||||
|
|
@ -84,15 +169,12 @@ export class N2PageNodeUI extends CustomHTMLElement {
|
||||||
|
|
||||||
this.node.setContent(this.elNodeContent.value)
|
this.node.setContent(this.elNodeContent.value)
|
||||||
})
|
})
|
||||||
this.elIconHistory.addEventListener('click', () => _mbus.dispatch('SHOW_PAGE', { page: 'history' }))
|
this.elNodeMenu.elHistory.addEventListener('click', () => {
|
||||||
this.elIconSave.addEventListener('click', () => this.saveNode())
|
_mbus.dispatch('SHOW_PAGE', { page: 'history' })
|
||||||
this.elIconNewDocument.addEventListener('click', event => {
|
|
||||||
if (event.shiftKey)
|
|
||||||
_app.createNode(this.node.ParentUUID)
|
|
||||||
else
|
|
||||||
_app.createNode()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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() {// {{{
|
||||||
|
|
@ -422,12 +504,23 @@ export class Node {
|
||||||
getParent() {//{{{
|
getParent() {//{{{
|
||||||
return this._parent
|
return this._parent
|
||||||
}//}}}
|
}//}}}
|
||||||
|
moveToParent(newParentUUID) {// {{{
|
||||||
|
if (this.UUID === newParentUUID)
|
||||||
|
throw new Error("New parent UUID is the same as node UUID. Can't be your own parent.")
|
||||||
|
|
||||||
|
this.ParentUUID = newParentUUID
|
||||||
|
this.data.ParentUUID = newParentUUID
|
||||||
|
this._modified = true
|
||||||
|
}// }}}
|
||||||
isLastSibling() {//{{{
|
isLastSibling() {//{{{
|
||||||
return this._sibling_after === null
|
return this._sibling_after === null
|
||||||
}//}}}
|
}//}}}
|
||||||
isFirstSibling() {//{{{
|
isFirstSibling() {//{{{
|
||||||
return this._sibling_before === null
|
return this._sibling_before === null
|
||||||
}//}}}
|
}//}}}
|
||||||
|
isSpecial() {// {{{
|
||||||
|
return this.data.Special
|
||||||
|
}// }}}
|
||||||
content() {//{{{
|
content() {//{{{
|
||||||
/* TODO - implement crypto
|
/* TODO - implement crypto
|
||||||
if (this.CryptoKeyID != 0 && !this._decrypted)
|
if (this.CryptoKeyID != 0 && !this._decrypted)
|
||||||
|
|
@ -463,9 +556,10 @@ export class Node {
|
||||||
|
|
||||||
// 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.
|
/* The node history is a local store for node history.
|
||||||
* This could be provisioned from the server or cleared if
|
* This could be provisioned from the server or cleared if
|
||||||
* deemed unnecessary.
|
* deemed unnecessary.
|
||||||
|
|
@ -481,13 +575,19 @@ export class Node {
|
||||||
const history = nodeStore.nodesHistory.add(this)
|
const history = nodeStore.nodesHistory.add(this)
|
||||||
|
|
||||||
// Updated node is added to the send queue to be stored on server.
|
// Updated node is added to the send queue to be stored on server.
|
||||||
|
|
||||||
const sendQueue = nodeStore.sendQueue.add(this)
|
const sendQueue = nodeStore.sendQueue.add(this)
|
||||||
|
|
||||||
// Updated node is saved to the primary node store.
|
// Updated node is saved to the primary node store.
|
||||||
const nodeStoreAdding = nodeStore.add([this])
|
const nodeStoreAdding = nodeStore.add([this])
|
||||||
|
|
||||||
return Promise.all([history, sendQueue, nodeStoreAdding])
|
console.log('waiting')
|
||||||
|
await Promise.all([history, sendQueue, nodeStoreAdding])
|
||||||
|
console.log('waiting done')
|
||||||
|
|
||||||
|
return
|
||||||
}//}}}
|
}//}}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// vim: foldmethod=marker
|
// vim: foldmethod=marker
|
||||||
|
|
|
||||||
283
static/js/page_preferences.mjs
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
import { CustomHTMLElement } from "./lib/custom_html_element.mjs"
|
||||||
|
import { API } from './api.mjs'
|
||||||
|
|
||||||
|
export class N2PagePreferences extends CustomHTMLElement {
|
||||||
|
static {// {{{
|
||||||
|
this.tmpl = document.createElement('template')
|
||||||
|
this.tmpl.innerHTML = `
|
||||||
|
<style>
|
||||||
|
.el-sets {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: min-content;
|
||||||
|
grid-gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host > div {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-pref-set {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: min-content min-content;
|
||||||
|
grid-gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<h1>Preferences</h1>
|
||||||
|
|
||||||
|
<div>Changes preferences to not download images or files on the device doesn't remove the already downloaded data.</div>
|
||||||
|
|
||||||
|
<div class="dev-pref-set">
|
||||||
|
<div>Device preference set</div>
|
||||||
|
<select data-el="dev-preference-set"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-el="sets"></div>
|
||||||
|
|
||||||
|
<button data-el="new-set">New set</button>
|
||||||
|
<button data-el="save" disabled>Save</button>
|
||||||
|
`
|
||||||
|
}// }}}
|
||||||
|
constructor() {// {{{
|
||||||
|
super(true)
|
||||||
|
this.sets = []
|
||||||
|
|
||||||
|
this.elNewSet.addEventListener('click', () => this.newSet())
|
||||||
|
this.elSave.addEventListener('click', () => this.save())
|
||||||
|
this.elDevPreferenceSet.addEventListener('change', event=>this.changePreferenceSet(event))
|
||||||
|
|
||||||
|
window._mbus.subscribe('SHOW_PAGE', async event => {
|
||||||
|
if (event.detail.data?.page == 'preferences') {
|
||||||
|
this.sets = await this.getPreferenceSets()
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
window._mbus.subscribe('PREFERENCE_SET_MODIFIED', () => this.preferencesModified())
|
||||||
|
window._mbus.subscribe('PREFERENCE_SET_DELETE', event => this.preferencesDelete(event.detail.data.set))
|
||||||
|
}// }}}
|
||||||
|
sortSets(a, b) {// {{{
|
||||||
|
if (a.name == 'default') return -1
|
||||||
|
if (b.name == 'default') return 1
|
||||||
|
|
||||||
|
if (a.name.toLowerCase() < b.name.toLowerCase()) return -1
|
||||||
|
if (a.name.toLowerCase() > b.name.toLowerCase()) return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}// }}}
|
||||||
|
async render() {// {{{
|
||||||
|
try {
|
||||||
|
this.sets.sort(this.sortSets)
|
||||||
|
this.elSets.replaceChildren(...this.sets)
|
||||||
|
|
||||||
|
const setNames = this.sets.entries().map(([i, set]) => {
|
||||||
|
const optn = document.createElement('option')
|
||||||
|
optn.innerText = set.name
|
||||||
|
return optn
|
||||||
|
})
|
||||||
|
this.elDevPreferenceSet.replaceChildren(...setNames)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
alert(e.message)
|
||||||
|
}
|
||||||
|
}// }}}
|
||||||
|
async getPreferenceSets() {// {{{
|
||||||
|
const userData = localStorage.getItem('user')
|
||||||
|
if (userData === null)
|
||||||
|
throw new Error('Could not find user in localStorage')
|
||||||
|
|
||||||
|
const user = JSON.parse(userData)
|
||||||
|
const prefsData = user.Preferences
|
||||||
|
|
||||||
|
if (prefsData === undefined)
|
||||||
|
throw new Error('User object is missing preferences')
|
||||||
|
|
||||||
|
if (!prefsData.hasOwnProperty('default'))
|
||||||
|
throw new Error('The "default" preferences set is missing')
|
||||||
|
|
||||||
|
return Object.keys(prefsData).map(name => new N2PreferenceSet(name, prefsData[name]))
|
||||||
|
}// }}}
|
||||||
|
async retrieveServerPreferences() {// {{{
|
||||||
|
try {
|
||||||
|
API.query('GET', '/user/preferences')
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
alert(`Error retrieving preferences: ${e.message}`)
|
||||||
|
}
|
||||||
|
}// }}}
|
||||||
|
changePreferenceSet(event) {// {{{
|
||||||
|
this.preferencesModified()
|
||||||
|
}// }}}
|
||||||
|
newSet() {// {{{
|
||||||
|
let name = prompt("Name for new preference set")
|
||||||
|
if (!name)
|
||||||
|
return
|
||||||
|
|
||||||
|
name = name.trim()
|
||||||
|
if (name === '')
|
||||||
|
return
|
||||||
|
|
||||||
|
if (name == 'default') {
|
||||||
|
alert(`Name can't be "default".`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = this.sets.some(s => s.name.toLowerCase() == name.toLowerCase())
|
||||||
|
if (exists) {
|
||||||
|
alert(`Set with name "${name}" already exist.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sets.push(new N2PreferenceSet(name, {}))
|
||||||
|
this.preferencesModified()
|
||||||
|
this.render()
|
||||||
|
}// }}}
|
||||||
|
preferencesModified() {// {{{
|
||||||
|
this.elSave.removeAttribute('disabled')
|
||||||
|
}// }}}
|
||||||
|
preferencesDelete(deleteSet) {// {{{
|
||||||
|
if (deleteSet.name == 'default') {
|
||||||
|
alert("Can't delete the default set.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`Confirm deleting "${deleteSet.name}"`))
|
||||||
|
return
|
||||||
|
|
||||||
|
this.sets = this.sets.filter(set => {
|
||||||
|
return !(set.name === deleteSet.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.preferencesModified()
|
||||||
|
this.render()
|
||||||
|
}// }}}
|
||||||
|
async save() {// {{{
|
||||||
|
try {
|
||||||
|
let newPrefs = {}
|
||||||
|
this.sets.forEach(s => {
|
||||||
|
const setState = s.getState()
|
||||||
|
newPrefs[setState.name] = setState.state
|
||||||
|
})
|
||||||
|
|
||||||
|
// Throws exception on both HTTP and application errors.
|
||||||
|
await API.query('POST', '/user/preferences', newPrefs)
|
||||||
|
|
||||||
|
const userData = localStorage.getItem('user')
|
||||||
|
const user = JSON.parse(userData)
|
||||||
|
user.Preferences = newPrefs
|
||||||
|
localStorage.setItem('user', JSON.stringify(user))
|
||||||
|
localStorage.setItem('device_preference_set', this.elDevPreferenceSet.value)
|
||||||
|
_mbus.dispatch('DEVICE_PREFERENCE_SET_UPDATED')
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
alert(e.message)
|
||||||
|
} finally {
|
||||||
|
this.elSave.setAttribute('disabled', true)
|
||||||
|
}
|
||||||
|
|
||||||
|
}// }}}
|
||||||
|
}
|
||||||
|
customElements.define('n2-pagepreferences', N2PagePreferences)
|
||||||
|
|
||||||
|
// Preferences is a set of preferences, of which there can be many named.
|
||||||
|
export class N2PreferenceSet extends CustomHTMLElement {
|
||||||
|
static {// {{{
|
||||||
|
this.tmpl = document.createElement('template')
|
||||||
|
this.tmpl.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
border: 1px solid var(--line-color);
|
||||||
|
padding: 16px;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: min-content 1fr;
|
||||||
|
justify-items: start;
|
||||||
|
align-items: center;
|
||||||
|
grid-gap: 8px 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr min-content;
|
||||||
|
|
||||||
|
.el-name {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-delete {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<div data-el="name"></div>
|
||||||
|
<div data-el="delete">✘</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div><label for="download-images">Download images on device</label></div>
|
||||||
|
<input data-field="download-images" type="checkbox" id="download-images">
|
||||||
|
|
||||||
|
<div><label for="download-files">Download files on device</label></div>
|
||||||
|
<input data-field="download-files" type="checkbox" id="download-files">
|
||||||
|
`
|
||||||
|
}// }}}
|
||||||
|
constructor(name, data) {// {{{
|
||||||
|
super(true)
|
||||||
|
this.name = name
|
||||||
|
this.data = data
|
||||||
|
this.render()
|
||||||
|
|
||||||
|
// Enable the save button when settings are modified.
|
||||||
|
this.allFields().forEach(f =>
|
||||||
|
f.addEventListener('input', () => _mbus.dispatch('PREFERENCE_SET_MODIFIED'))
|
||||||
|
)
|
||||||
|
|
||||||
|
this.elName.addEventListener('click', () => this.updateName())
|
||||||
|
this.elDelete.addEventListener('click', () => this.deleteSet())
|
||||||
|
}// }}}
|
||||||
|
updateName() {// {{{
|
||||||
|
if (this.name == 'default') {
|
||||||
|
alert('Can not change name of the default profile.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = prompt("Change name", this.name)
|
||||||
|
if (!name)
|
||||||
|
return
|
||||||
|
|
||||||
|
this.name = name
|
||||||
|
this.render()
|
||||||
|
_mbus.dispatch('PREFERENCE_SET_MODIFIED')
|
||||||
|
}// }}}
|
||||||
|
deleteSet() {// {{{
|
||||||
|
_mbus.dispatch('PREFERENCE_SET_DELETE', { set: this })
|
||||||
|
}// }}}
|
||||||
|
render() {// {{{
|
||||||
|
this.elName.innerText = this.name
|
||||||
|
|
||||||
|
this.fieldDownloadImages.checked = this.data.DownloadImages
|
||||||
|
this.fieldDownloadFiles.checked = this.data.DownloadFiles
|
||||||
|
}// }}}
|
||||||
|
getState() {// {{{
|
||||||
|
const name = this.name.trim()
|
||||||
|
if (name === '')
|
||||||
|
throw new Error('Name can not be empty.')
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: this.name.trim(),
|
||||||
|
state: this.fieldValues(),
|
||||||
|
}
|
||||||
|
}// }}}
|
||||||
|
}
|
||||||
|
customElements.define('n2-preferenceset', N2PreferenceSet)
|
||||||
|
|
@ -13,7 +13,10 @@ export class N2PageStorage extends CustomHTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
|
|
||||||
window._mbus.subscribe('SHOW_PAGE', () => this.render())
|
window._mbus.subscribe('SHOW_PAGE', event => {
|
||||||
|
if (event.detail.data?.page == 'storage')
|
||||||
|
this.render()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
async render() {
|
async render() {
|
||||||
const countNodes = await globalThis.nodeStore.nodeCount()
|
const countNodes = await globalThis.nodeStore.nodeCount()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { ROOT_NODE } from 'node_store'
|
import { ROOT_NODE, ORPHANED_NODE, DELETED_NODE } from 'node_store'
|
||||||
|
import { Node } from 'node'
|
||||||
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
||||||
import { Color, Solver } from './lib/css_colorize.mjs'
|
import { Color, Solver } from './lib/css_colorize.mjs'
|
||||||
|
|
||||||
|
|
@ -127,6 +128,7 @@ export class N2Sidebar extends CustomHTMLElement {
|
||||||
this.elSearch.addEventListener('click', () => _mbus.dispatch('op-search'))
|
this.elSearch.addEventListener('click', () => _mbus.dispatch('op-search'))
|
||||||
this.elSync.addEventListener('click', () => _sync.run())
|
this.elSync.addEventListener('click', () => _sync.run())
|
||||||
this.elLogo.addEventListener('click', () => _app.goToNode(ROOT_NODE, false, false))
|
this.elLogo.addEventListener('click', () => _app.goToNode(ROOT_NODE, false, false))
|
||||||
|
this.elSettings.addEventListener('click', ()=> _mbus.dispatch('SHOW_PAGE', { page: 'preferences' }))
|
||||||
this.elHideTree.addEventListener('click', event => {
|
this.elHideTree.addEventListener('click', event => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
_mbus.dispatch('TREE_EXPANSION', { expand: false })
|
_mbus.dispatch('TREE_EXPANSION', { expand: false })
|
||||||
|
|
@ -156,8 +158,26 @@ export class N2Sidebar extends CustomHTMLElement {
|
||||||
this.expandedNodes[ROOT_NODE] = true
|
this.expandedNodes[ROOT_NODE] = true
|
||||||
const startnode = await nodeStore.get(ROOT_NODE)
|
const startnode = await nodeStore.get(ROOT_NODE)
|
||||||
const starttreenode = new N2TreeNode(this, startnode, null)
|
const starttreenode = new N2TreeNode(this, startnode, null)
|
||||||
|
|
||||||
|
const deletednode = await nodeStore.get(DELETED_NODE)
|
||||||
|
const deletedtreenode = new SpecialNodeDeleted(this, deletednode, null)
|
||||||
|
|
||||||
|
const orphanednode = await nodeStore.get(ORPHANED_NODE)
|
||||||
|
const orphanedtreenode = new SpecialNodeOrphaned(this, orphanednode, null)
|
||||||
|
|
||||||
|
startnode._sibling_after = deletednode
|
||||||
|
deletednode._sibling_before = startnode
|
||||||
|
|
||||||
|
deletednode._sibling_after = orphanednode
|
||||||
|
orphanednode._sibling_before = deletednode
|
||||||
|
|
||||||
this.treeNodeComponents[startnode.UUID] = starttreenode
|
this.treeNodeComponents[startnode.UUID] = starttreenode
|
||||||
|
this.treeNodeComponents[deletednode.UUID] = deletedtreenode
|
||||||
|
this.treeNodeComponents[orphanednode.UUID] = orphanedtreenode
|
||||||
|
|
||||||
this.elTreenodes.appendChild(await starttreenode.render())
|
this.elTreenodes.appendChild(await starttreenode.render())
|
||||||
|
this.elTreenodes.appendChild(await deletedtreenode.render())
|
||||||
|
this.elTreenodes.appendChild(await orphanedtreenode.render())
|
||||||
|
|
||||||
// Notify the application that the initial tree is rendered (with children)
|
// Notify the application that the initial tree is rendered (with children)
|
||||||
// and that initial node selection can take place. App will check URL to
|
// and that initial node selection can take place. App will check URL to
|
||||||
|
|
@ -178,9 +198,8 @@ export class N2Sidebar extends CustomHTMLElement {
|
||||||
this.expandedNodes[UUID] = false
|
this.expandedNodes[UUID] = false
|
||||||
return this.expandedNodes[UUID]
|
return this.expandedNodes[UUID]
|
||||||
}//}}}
|
}//}}}
|
||||||
setNodeExpanded(node, value) {//{{{
|
async setNodeExpanded(node, value) {//{{{
|
||||||
let expanded = this.expandedNodes[node.UUID]
|
let expanded = this.expandedNodes[node.UUID]
|
||||||
|
|
||||||
if (expanded === undefined) {
|
if (expanded === undefined) {
|
||||||
this.expandedNodes[node.UUID] = false
|
this.expandedNodes[node.UUID] = false
|
||||||
expanded = false
|
expanded = false
|
||||||
|
|
@ -230,8 +249,6 @@ export class N2Sidebar extends CustomHTMLElement {
|
||||||
// Holding shift down does it recursively.
|
// Holding shift down does it recursively.
|
||||||
case Space:
|
case Space:
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
if (n.UUID === ROOT_NODE)
|
|
||||||
return
|
|
||||||
const expanded = this.getNodeExpanded(n.UUID)
|
const expanded = this.getNodeExpanded(n.UUID)
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
this.recursiveExpand(n, !expanded)
|
this.recursiveExpand(n, !expanded)
|
||||||
|
|
@ -240,38 +257,31 @@ export class N2Sidebar extends CustomHTMLElement {
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'g':
|
|
||||||
case 'Home':
|
case 'Home':
|
||||||
this.navigateTop()
|
this.navigateTop()
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'G':
|
|
||||||
case 'End':
|
case 'End':
|
||||||
this.navigateBottom()
|
this.navigateBottom()
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'j':
|
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
await this.navigateDown(this.selectedNode)
|
await this.navigateDown(this.selectedNode)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'k':
|
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
await this.navigateUp(this.selectedNode)
|
await this.navigateUp(this.selectedNode)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'h':
|
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
await this.navigateLeft(this.selectedNode)
|
await this.navigateLeft(this.selectedNode)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'l':
|
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
await this.navigateRight(this.selectedNode)
|
await this.navigateRight(this.selectedNode)
|
||||||
break
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// nonsole.log(event.key)
|
|
||||||
handled = false
|
handled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -393,23 +403,26 @@ export class N2Sidebar extends CustomHTMLElement {
|
||||||
}//}}}
|
}//}}}
|
||||||
async navigateTop() {//{{{
|
async navigateTop() {//{{{
|
||||||
const root = await nodeStore.get(ROOT_NODE)
|
const root = await nodeStore.get(ROOT_NODE)
|
||||||
if (root.Children.length === 0)
|
_mbus.dispatch("GO_TO_NODE", { nodeUUID: root.UUID, dontPush: false, dontExpand: true })
|
||||||
return
|
|
||||||
_mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[0]?.UUID, dontPush: false, dontExpand: true })
|
|
||||||
}//}}}
|
}//}}}
|
||||||
async navigateBottom() {//{{{
|
async navigateBottom() {//{{{
|
||||||
const root = await nodeStore.get(ROOT_NODE)
|
const orphaned = await nodeStore.get(ORPHANED_NODE)
|
||||||
if (root.Children.length === 0)
|
|
||||||
return
|
|
||||||
|
|
||||||
const toplevel = root.Children[root.Children.length - 1]
|
if (!orphaned.hasChildren() || this.getNodeExpanded(orphaned.UUID)) {
|
||||||
|
_mbus.dispatch("GO_TO_NODE", { nodeUUID: orphaned.UUID, dontPush: false, dontExpand: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TODO - fix this when orphaned nodes are implemented.
|
||||||
|
const toplevel = orphaned.Children[orphaned.Children.length - 1]
|
||||||
const toplevelExpanded = this.getNodeExpanded(toplevel?.UUID)
|
const toplevelExpanded = this.getNodeExpanded(toplevel?.UUID)
|
||||||
|
|
||||||
if (toplevelExpanded) {
|
if (toplevelExpanded) {
|
||||||
const lastnode = this.getLastExpandedNode(toplevel)
|
const lastnode = this.getLastExpandedNode(toplevel)
|
||||||
_mbus.dispatch("GO_TO_NODE", { nodeUUID: lastnode?.UUID, dontPush: false, dontExpand: true })
|
_mbus.dispatch("GO_TO_NODE", { nodeUUID: lastnode?.UUID, dontPush: false, dontExpand: true })
|
||||||
} else
|
} else
|
||||||
_mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[root.Children.length - 1]?.UUID, dontPush: false, dontExpand: true })
|
_mbus.dispatch("GO_TO_NODE", { nodeUUID: orphaned.Children[orphaned.Children.length - 1]?.UUID, dontPush: false, dontExpand: true })
|
||||||
|
*/
|
||||||
}//}}}
|
}//}}}
|
||||||
|
|
||||||
getParentWithNextSibling(node) {//{{{
|
getParentWithNextSibling(node) {//{{{
|
||||||
|
|
@ -430,6 +443,10 @@ export class N2Sidebar extends CustomHTMLElement {
|
||||||
if (state)
|
if (state)
|
||||||
await this.setNodeExpanded(node, true)
|
await this.setNodeExpanded(node, true)
|
||||||
|
|
||||||
|
// An expanded node needs to have its children fetched.
|
||||||
|
if (!node.hasFetchedChildren())
|
||||||
|
await node.fetchChildren()
|
||||||
|
|
||||||
for (const child of node.Children)
|
for (const child of node.Children)
|
||||||
await this.recursiveExpand(child, state)
|
await this.recursiveExpand(child, state)
|
||||||
|
|
||||||
|
|
@ -449,15 +466,22 @@ export class N2Sidebar extends CustomHTMLElement {
|
||||||
treenode?.scrollIntoView({ block: 'nearest' })
|
treenode?.scrollIntoView({ block: 'nearest' })
|
||||||
}// }}}
|
}// }}}
|
||||||
}
|
}
|
||||||
customElements.define('n2-sidebar', N2Sidebar)
|
|
||||||
|
|
||||||
export class N2TreeNode extends CustomHTMLElement {
|
export class N2TreeNode extends CustomHTMLElement {
|
||||||
|
static DRAG_ICON = new Image()
|
||||||
|
static DRAG_ICON_OK = new Image()
|
||||||
|
|
||||||
static {// {{{
|
static {// {{{
|
||||||
|
N2TreeNode.DRAG_ICON.src = `/images/${_VERSION}/leaf.svg`
|
||||||
|
N2TreeNode.DRAG_ICON_OK.src = `/images/${_VERSION}/expanded.svg`
|
||||||
|
|
||||||
this.tmpl = document.createElement('template')
|
this.tmpl = document.createElement('template')
|
||||||
this.tmpl.innerHTML = `
|
this.tmpl.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
n2-sidebar:focus-within {
|
n2-sidebar:focus-within {
|
||||||
.el-name {
|
n2-specialnodedeleted > .el-name,
|
||||||
|
n2-specialnodeorphaned > .el-name,
|
||||||
|
n2-treenode > .el-name {
|
||||||
&.selected {
|
&.selected {
|
||||||
span {
|
span {
|
||||||
position:relative;
|
position:relative;
|
||||||
|
|
@ -478,10 +502,60 @@ export class N2TreeNode extends CustomHTMLElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
n2-treenode {
|
||||||
|
& > .el-name {
|
||||||
|
white-space: nowrap;
|
||||||
|
width: min-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.drag-source {
|
||||||
|
& > .el-name {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > .el-name:after {
|
||||||
|
position: absolute;
|
||||||
|
content: url('/images/${_VERSION}/icon_drag_source.svg');
|
||||||
|
filter: var(--colorize);
|
||||||
|
top: -1px;
|
||||||
|
right: -24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.drag-target {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
& > .el-name {
|
||||||
|
anchor-name: --name;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > .el-name:after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
border: 2px dashed #888;
|
||||||
|
|
||||||
|
top: calc(anchor(--name top) - 12px);
|
||||||
|
right: calc(anchor(--name right) - 8px);
|
||||||
|
bottom: calc(anchor(--name bottom) - 8px);
|
||||||
|
left: calc(anchor(--name left) - 40px);
|
||||||
|
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > .el-drag-icon {
|
||||||
|
display: block;
|
||||||
|
top: 0px;
|
||||||
|
left: 0px;
|
||||||
|
z-index: 16384;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div data-el="expand-toggle" class="expand-toggle">
|
<div data-el="expand-toggle" class="expand-toggle">
|
||||||
<img data-el="expand">
|
<img data-el="expand" draggable="false">
|
||||||
</div>
|
</div>
|
||||||
<div data-el="name" class="name"><span></span></div>
|
<div data-el="name" class="name"><span></span></div>
|
||||||
<div data-el="children" class="children"></div>
|
<div data-el="children" class="children"></div>
|
||||||
|
|
@ -490,6 +564,7 @@ export class N2TreeNode extends CustomHTMLElement {
|
||||||
|
|
||||||
constructor(sidebar, node, parent) {//{{{
|
constructor(sidebar, node, parent) {//{{{
|
||||||
super()
|
super()
|
||||||
|
this.setAttribute('draggable', 'true')
|
||||||
this.classList.add('node')
|
this.classList.add('node')
|
||||||
|
|
||||||
this.sidebar = sidebar
|
this.sidebar = sidebar
|
||||||
|
|
@ -498,13 +573,100 @@ export class N2TreeNode extends CustomHTMLElement {
|
||||||
|
|
||||||
this.children_populated = false
|
this.children_populated = false
|
||||||
this.rendered = false
|
this.rendered = false
|
||||||
|
this.dragNode = null
|
||||||
|
|
||||||
this.elExpandToggle.addEventListener('click', () => this.sidebar.setNodeExpanded(this.node, !this.sidebar.getNodeExpanded(this.node.UUID)))
|
this.elExpandToggle.addEventListener('click', event => {
|
||||||
|
if (this.node.hasChildren())
|
||||||
|
this.expandNode(event)
|
||||||
|
else
|
||||||
|
_mbus.dispatch('TREE_NODE_SELECTED', this.node)
|
||||||
|
})
|
||||||
this.elName.addEventListener('click', () => _mbus.dispatch('TREE_NODE_SELECTED', this.node))
|
this.elName.addEventListener('click', () => _mbus.dispatch('TREE_NODE_SELECTED', this.node))
|
||||||
|
|
||||||
_mbus.subscribe(`NODE_EXPAND_${node.UUID}`, _state => {
|
_mbus.subscribe(`NODE_EXPAND_${node.UUID}`, _state => {
|
||||||
this.render(true)
|
this.render(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Drag-and-dropping of nodes
|
||||||
|
this.addEventListener('dragstart', event => this.dragStart(event))
|
||||||
|
this.addEventListener('dragend', event => this.dragEnd(event))
|
||||||
|
this.addEventListener('dragover', event => this.dragOver(event))
|
||||||
|
this.addEventListener('drop', event => this.dragDrop(event))
|
||||||
|
this.elName.addEventListener('dragenter', event => this.dragEnter(event))
|
||||||
|
this.elName.addEventListener('dragleave', event => this.dragLeave(event))
|
||||||
|
}// }}}
|
||||||
|
|
||||||
|
dragStart(e) {// {{{
|
||||||
|
if (this.node.isModified()) {
|
||||||
|
alert('Save note before moving it.')
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.classList.add('drag-source')
|
||||||
|
const blankPixel = new Image()
|
||||||
|
blankPixel.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
|
||||||
|
e.dataTransfer.setDragImage(blankPixel, 0, 0)
|
||||||
|
e.dataTransfer.allowedEffects = 'none'
|
||||||
|
e.stopPropagation()
|
||||||
|
_app.dragIcon.setSource(this)
|
||||||
|
_app.dragIcon.start()
|
||||||
|
}// }}}
|
||||||
|
dragEnd(e) {// {{{
|
||||||
|
this.classList.remove('drag-source')
|
||||||
|
_app.dragIcon.end()
|
||||||
|
e.stopPropagation()
|
||||||
|
}// }}}
|
||||||
|
dragOver(e) {// {{{
|
||||||
|
e.dataTransfer.dropEffect = 'move'
|
||||||
|
e.preventDefault()
|
||||||
|
}// }}}
|
||||||
|
async dragDrop(e) {// {{{
|
||||||
|
try {
|
||||||
|
e.stopPropagation()
|
||||||
|
const sourceNode = _app.dragIcon.getSource()
|
||||||
|
|
||||||
|
// Abort if user drops the node back on itself.
|
||||||
|
if (sourceNode.node.UUID === this.node.UUID)
|
||||||
|
return
|
||||||
|
|
||||||
|
await _app.moveNode(sourceNode.node, this.node.UUID)
|
||||||
|
|
||||||
|
_app.sidebar.setNodeExpanded(this, true)
|
||||||
|
await this.render(true, true)
|
||||||
|
await sourceNode.render(true, true)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
alert(e)
|
||||||
|
} finally {
|
||||||
|
this.dragLeave(e)
|
||||||
|
}
|
||||||
|
}// }}}
|
||||||
|
dragEnter(e) {// {{{
|
||||||
|
const targetNode = e.target.closest('n2-treenode')
|
||||||
|
if (targetNode.classList.contains('drag-source'))
|
||||||
|
return
|
||||||
|
e.stopPropagation()
|
||||||
|
_app.dragIcon.icon('ok')
|
||||||
|
this.classList.add('drag-target')
|
||||||
|
}// }}}
|
||||||
|
dragLeave(e) {// {{{
|
||||||
|
e.stopPropagation()
|
||||||
|
e.dataTransfer.dropEffect = 'none'
|
||||||
|
e.dataTransfer.setDragImage(N2TreeNode.DRAG_ICON, -16, 8)
|
||||||
|
_app.dragIcon.icon('')
|
||||||
|
this.classList.remove('drag-target')
|
||||||
|
}// }}}
|
||||||
|
|
||||||
|
async expandNode(event) {// {{{
|
||||||
|
const expanded = _app.sidebar.getNodeExpanded(this.node.UUID)
|
||||||
|
|
||||||
|
if (event.shiftKey) {
|
||||||
|
_app.sidebar.recursiveExpand(this.node, !expanded)
|
||||||
|
} else {
|
||||||
|
_app.sidebar.setNodeExpanded(this.node, !expanded)
|
||||||
|
}
|
||||||
}// }}}
|
}// }}}
|
||||||
async fetchChildren(force_fetch) {//{{{
|
async fetchChildren(force_fetch) {//{{{
|
||||||
if (this.children_populated && !force_fetch)
|
if (this.children_populated && !force_fetch)
|
||||||
|
|
@ -541,6 +703,17 @@ export class N2TreeNode extends CustomHTMLElement {
|
||||||
// The expand icon <img> is only changed to not get a flickering when re-rendering.
|
// The expand icon <img> is only changed to not get a flickering when re-rendering.
|
||||||
if (this.node.UUID === ROOT_NODE)
|
if (this.node.UUID === ROOT_NODE)
|
||||||
this.setImgSrc(this.elExpand, `/images/${window._VERSION}/icon_home.svg`)
|
this.setImgSrc(this.elExpand, `/images/${window._VERSION}/icon_home.svg`)
|
||||||
|
|
||||||
|
else if (this.node.UUID === DELETED_NODE) {
|
||||||
|
this.setImgSrc(this.elExpand, `/images/${window._VERSION}/leaf_deleted.svg`)
|
||||||
|
this.elExpand.classList.add('deleted')
|
||||||
|
}
|
||||||
|
|
||||||
|
else if (this.node.UUID === ORPHANED_NODE) {
|
||||||
|
this.setImgSrc(this.elExpand, `/images/${window._VERSION}/leaf_orphaned.svg`)
|
||||||
|
this.elExpand.classList.add('deleted')
|
||||||
|
}
|
||||||
|
|
||||||
else if (!this.node.hasChildren())
|
else if (!this.node.hasChildren())
|
||||||
this.setImgSrc(this.elExpand, `/images/${window._VERSION}/leaf.svg`)
|
this.setImgSrc(this.elExpand, `/images/${window._VERSION}/leaf.svg`)
|
||||||
else if (this.sidebar.getNodeExpanded(this.node.UUID))
|
else if (this.sidebar.getNodeExpanded(this.node.UUID))
|
||||||
|
|
@ -575,6 +748,24 @@ export class N2TreeNode extends CustomHTMLElement {
|
||||||
img.setAttribute('src', newSrc)
|
img.setAttribute('src', newSrc)
|
||||||
}// }}}
|
}// }}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SpecialNodeDeleted extends N2TreeNode {
|
||||||
|
constructor(sidebar, node, parent) {//{{{
|
||||||
|
super(sidebar, node, parent)
|
||||||
|
this.removeAttribute('draggable')
|
||||||
|
}//}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SpecialNodeOrphaned extends N2TreeNode {
|
||||||
|
constructor(sidebar, node, parent) {//{{{
|
||||||
|
super(sidebar, node, parent)
|
||||||
|
this.removeAttribute('draggable')
|
||||||
|
}//}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('n2-sidebar', N2Sidebar)
|
||||||
customElements.define('n2-treenode', N2TreeNode)
|
customElements.define('n2-treenode', N2TreeNode)
|
||||||
|
customElements.define('n2-specialnodedeleted', SpecialNodeDeleted)
|
||||||
|
customElements.define('n2-specialnodeorphaned', SpecialNodeOrphaned)
|
||||||
|
|
||||||
// vim: foldmethod=marker
|
// vim: foldmethod=marker
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ export class Sync {
|
||||||
nodeStore.setAppState('latest_sync_node', currMax)
|
nodeStore.setAppState('latest_sync_node', currMax)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('sync node tree', e)
|
console.error('sync node tree', e)
|
||||||
|
alert(e.message)
|
||||||
} finally {
|
} finally {
|
||||||
syncEnd = Date.now()
|
syncEnd = Date.now()
|
||||||
const duration = (syncEnd - syncStart) / 1000
|
const duration = (syncEnd - syncStart) / 1000
|
||||||
|
|
@ -157,8 +158,8 @@ export class Sync {
|
||||||
_mbus.dispatch('SYNC_UPLOADED', { count: nodesToSend.length })
|
_mbus.dispatch('SYNC_UPLOADED', { count: nodesToSend.length })
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.trace(e)
|
console.error(e)
|
||||||
alert(e.error)
|
alert(e.message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
user.go
|
|
@ -1,27 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
// External
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UserSession struct {
|
|
||||||
UserID int
|
|
||||||
Username string
|
|
||||||
Password string
|
|
||||||
Name string
|
|
||||||
ClientUUID string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewUser(claims jwt.MapClaims) (u UserSession) {
|
|
||||||
uid, _ := claims["uid"].(float64)
|
|
||||||
name, _ := claims["name"].(string)
|
|
||||||
username, _ := claims["login"].(string)
|
|
||||||
clientUUID, _ := claims["cid"].(string)
|
|
||||||
|
|
||||||
u.UserID = int(uid)
|
|
||||||
u.Username = username
|
|
||||||
u.Name = name
|
|
||||||
u.ClientUUID = clientUUID
|
|
||||||
return
|
|
||||||
}
|
|
||||||
63
user/pkg.go
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
// External
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
|
||||||
|
// Standard
|
||||||
|
"encoding/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID int
|
||||||
|
Username string
|
||||||
|
Name string
|
||||||
|
Preferences map[string]UserPreferences
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserSession struct {
|
||||||
|
UserID int
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Name string
|
||||||
|
ClientUUID string
|
||||||
|
Db *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserPreferences struct {
|
||||||
|
DownloadImages bool
|
||||||
|
DownloadFiles bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUser(claims jwt.MapClaims) (u UserSession) {
|
||||||
|
uid, _ := claims["uid"].(float64)
|
||||||
|
name, _ := claims["name"].(string)
|
||||||
|
username, _ := claims["login"].(string)
|
||||||
|
clientUUID, _ := claims["cid"].(string)
|
||||||
|
|
||||||
|
u.UserID = int(uid)
|
||||||
|
u.Username = username
|
||||||
|
u.Name = name
|
||||||
|
u.ClientUUID = clientUUID
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u UserSession) Preferences() (prefs map[string]UserPreferences, err error) {
|
||||||
|
row := u.Db.QueryRow(`SELECT preferences FROM public.user WHERE id=$1`, u.UserID)
|
||||||
|
|
||||||
|
var data []byte
|
||||||
|
err = row.Scan(&data)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(data, &prefs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u UserSession) SetPreferences(prefs map[string]UserPreferences) (err error) {
|
||||||
|
j, _ := json.Marshal(prefs)
|
||||||
|
_, err = u.Db.Exec(`UPDATE public.user SET preferences=$2 WHERE id=$1`, u.UserID, j)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
{{ define "page" }}
|
{{ define "page" }}
|
||||||
|
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/notes2.css">
|
||||||
|
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/page_history.css">
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Drag and drop elements -->
|
||||||
|
|
||||||
<!-- page-node -->
|
<!-- page-node -->
|
||||||
<div id="notes2" class="page-history">
|
<div id="notes2" class="page-node">
|
||||||
<div id="tree-expander" onclick="window._mbus.dispatch('TREE_EXPANSION', { expand: true })">></div>
|
<div id="tree-expander" onclick="window._mbus.dispatch('TREE_EXPANSION', { expand: true })">></div>
|
||||||
<div id="tree" tabindex=0></div>
|
<div id="tree" tabindex=0></div>
|
||||||
|
|
||||||
|
|
@ -26,23 +32,22 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- History -->
|
<!-- History -->
|
||||||
<div id="page-history">
|
<n2-pagehistory id="page-history"></n2-pagehistory>
|
||||||
<n2-pagehistory></n2-pagehistory>
|
|
||||||
</div>
|
<!-- Preferences -->
|
||||||
|
<n2-pagepreferences id="page-preferences"></n2-pagepreferences>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<n2-syncprogress></n2-syncprogress>
|
<n2-syncprogress></n2-syncprogress>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/notes2.css">
|
|
||||||
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/page_history.css">
|
|
||||||
|
|
||||||
<script type="module">
|
<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 }}/page_history.mjs'
|
||||||
import { } from '/js/{{ .VERSION }}/file.mjs'
|
import { } from '/js/{{ .VERSION }}/file.mjs'
|
||||||
|
|
|
||||||