Compare commits

..

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

76 changed files with 5308 additions and 7424 deletions

1
.gitignore vendored
View file

@ -1,2 +1 @@
notes2
untracked

View file

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

View file

@ -3,6 +3,7 @@ package main
import (
// Standard
"encoding/json"
"fmt"
"os"
)
@ -24,14 +25,11 @@ type Config struct {
Secret string
ExpireDays int
}
Storage struct {
Files string
}
}
func readConfig(fname string) (err error) {
func readConfig() (err error) {
var configData []byte
fname := fmt.Sprintf("%s/.config/notes2.json", os.Getenv("HOME"))
configData, err = os.ReadFile(fname)
if err != nil {
return

134
file.go
View file

@ -5,47 +5,35 @@ import (
"github.com/jmoiron/sqlx"
// Standard
"fmt"
"os"
"path"
"time"
)
type File struct {
ID int
UUID string
UserID int `db:"user_id"`
Name string `db:"name"`
Size int
Type string `db:"type"`
Modified time.Time
UploadComplete bool `db:"upload_complete"`
NodeID int `db:"node_id"`
Filename string
Size int64
MIME string
MD5 string
Uploaded time.Time
}
var (
fileUUIDCache map[string]int
)
func init() {
fileUUIDCache = make(map[string]int)
}
func AddFileToDb(userID int, file *File) (err error) { // {{{
func AddFile(userID int, file *File) (err error) { // {{{
file.UserID = userID
var rows *sqlx.Rows
rows, err = db.Queryx(`
INSERT INTO public.file(user_id, uuid, name, size, type, modified, upload_complete)
VALUES($1, $2, $3, $4, $5, $6, false)
ON CONFLICT (user_id, uuid) DO UPDATE set name = public.file.name
INSERT INTO file(user_id, node_id, filename, size, mime, md5)
VALUES($1, $2, $3, $4, $5, $6)
RETURNING id
`,
file.UserID,
file.UUID,
file.Name,
file.NodeID,
file.Filename,
file.Size,
file.Type,
file.Modified,
file.MIME,
file.MD5,
)
if err != nil {
return
@ -88,101 +76,5 @@ func Files(userID int, nodeUUID string, fileID int) (files []File, err error) {
return
} // }}}
func FileIDToPath(id int) (string, string) { // {{{
str := fmt.Sprintf("%012d", id)
dir := path.Join(config.Storage.Files, str[0:4], str[4:8])
fpath := path.Join(config.Storage.Files, str[0:4], str[4:8], str[8:12])
return dir, fpath
} // }}}
func FileWriteData(userID int, dbID int, data []byte, create bool) (err error) { // {{{
dir, fpath := FileIDToPath(dbID)
err = os.MkdirAll(dir, 0700)
if err != nil {
Log.Error("file", "error", err)
return
}
var f *os.File
if create {
f, err = os.OpenFile(fpath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
} else {
f, err = os.OpenFile(fpath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
}
if err != nil {
Log.Error("file", "error", err)
return
}
defer f.Close()
_, err = f.Write(data)
if err != nil {
Log.Error("file", "error", err)
return
}
return
} // }}}
func FileCountForUser(userID int) (count int, err error) { // {{{
row := db.QueryRowx(`SELECT COUNT(*) FROM public.file WHERE user_id = $1`, userID)
err = row.Scan(&count)
return
} // }}}
func FileServerUUIDsForUser(userID, page int) (uuids []string, more bool, err error) { // {{{
uuids = []string{}
var rows *sqlx.Rows
rows, err = db.Queryx(`
SELECT
uuid
FROM public.file
WHERE
user_id = $1
ORDER BY
uuid ASC
LIMIT $2 OFFSET $3
`,
userID,
SYNC_PAGINATION+1,
page*SYNC_PAGINATION,
)
if err != nil {
return
}
defer rows.Close()
var uuid string
for rows.Next() {
err = rows.Scan(&uuid)
if err != nil {
return
}
uuids = append(uuids, uuid)
}
more = len(uuids) > SYNC_PAGINATION
uuids = uuids[0:min(SYNC_PAGINATION, len(uuids))]
return
} // }}}
func FileMetadata(userID int, uuid string) (f File, err error) { // {{{
row := db.QueryRowx(`SELECT * FROM public.file WHERE user_id = $1 AND "uuid" = $2`, userID, uuid)
err = row.StructScan(&f)
return
} // }}}
func FileForUser(userID int, uuid string) (f *os.File, md File, err error) { // {{{
row := db.QueryRowx(`SELECT * FROM public.file WHERE user_id = $1 AND "uuid" = $2`, userID, uuid)
err = row.StructScan(&md)
if err != nil {
return
}
_, fpath := FileIDToPath(md.ID)
f, err = os.OpenFile(fpath, os.O_RDONLY, 0600)
return
} // }}}
// vim: foldmethod=marker

View file

@ -11,7 +11,6 @@ import (
"net/http"
"os"
"regexp"
"sync"
)
type Engine struct {
@ -23,10 +22,6 @@ type Engine struct {
DevMode bool
}
var (
templateLock sync.Mutex
)
func NewEngine(viewFS, staticFS fs.FS, devmode bool) (e Engine, err error) { // {{{
e.parsedTemplates = make(map[string]*template.Template)
e.viewFS = viewFS
@ -67,12 +62,12 @@ func (e *Engine) getComponentFilenames() (files []string, err error) { // {{{
} // }}}
func (e *Engine) ReloadTemplates() { // {{{
templateLock.Lock()
e.parsedTemplates = make(map[string]*template.Template)
templateLock.Unlock()
} // }}}
func (e *Engine) StaticResource(w http.ResponseWriter, r *http.Request) { // {{{
var err error
// URLs with pattern /(css|images)/v1.0.0/foobar are stripped of the version.
// To get rid of problems with cached content in browser on a new version release,
// while also not disabling cache altogether.
@ -88,7 +83,11 @@ func (e *Engine) StaticResource(w http.ResponseWriter, r *http.Request) { // {{{
r.URL.Path = fmt.Sprintf("/%s/%s", comp[1], comp[2])
if e.DevMode {
p := fmt.Sprintf("static/%s/%s", comp[1], comp[2])
_, err = os.Stat(p)
if err == nil {
e.staticLocalFS.ServeHTTP(w, r)
}
return
}
}
@ -126,9 +125,7 @@ func (e *Engine) getPage(layout, page string) (tmpl *template.Template, err erro
return
}
templateLock.Lock()
e.parsedTemplates[page] = tmpl
templateLock.Unlock()
return
} // }}}
func (e *Engine) Render(p Page, w http.ResponseWriter, r *http.Request) (err error) { // {{{

283
main.go
View file

@ -4,7 +4,7 @@ import (
// Internal
"notes2/authentication"
"notes2/html_template"
appUser "notes2/user"
"os"
// Standard
"bufio"
@ -16,18 +16,16 @@ import (
"io"
"log/slog"
"net/http"
"os"
"path"
"regexp"
"strconv"
"strings"
"text/template"
"time"
)
const VERSION = "v30"
const VERSION = "v2"
const CONTEXT_USER = 1
const SYNC_PAGINATION = 1
const SYNC_PAGINATION = 200
var (
FlagGenerate bool
@ -36,7 +34,6 @@ var (
FlagConfig string
FlagCreateUser string
FlagChangePassword string
FlagVersion bool
Webengine HTMLTemplate.Engine
config Config
Log *slog.Logger
@ -67,26 +64,18 @@ func init() { // {{{
flag.BoolVar(&FlagLoremIpsum, "lorem-ipsum", false, "Replace all G- nodes with lorem ipsum paragraphs")
flag.StringVar(&FlagCreateUser, "create-user", "", "Username for creating a new user")
flag.StringVar(&FlagChangePassword, "change-password", "", "Change the password for the given username")
flag.BoolVar(&FlagVersion, "version", false, "Print version and exit")
flag.Parse()
RxpBearerToken = regexp.MustCompile("(?i)^\\s*Bearer\\s+(.*?)\\s*$")
} // }}}
func initLog() { // {{{
opts := slog.HandlerOptions{}
opts.Level = slog.LevelInfo
opts.Level = slog.LevelDebug
Log = slog.New(slog.NewJSONHandler(os.Stdout, &opts))
} // }}}
func main() { // {{{
if FlagVersion {
fmt.Println(VERSION)
return
}
initLog()
Log.Info("application", "version", VERSION)
err := readConfig(FlagConfig)
err := readConfig()
if err != nil {
Log.Error("config", "error", err)
os.Exit(1)
@ -140,27 +129,18 @@ func main() { // {{{
}
http.HandleFunc("/", rootHandler)
http.HandleFunc("/notes2", pageNotes2)
http.HandleFunc("/login", pageLogin)
http.HandleFunc("/sync", pageSync)
http.HandleFunc("/offline", pageOffline)
http.HandleFunc("/user/authenticate", AuthManager.AuthenticationHandler)
http.HandleFunc("GET /user/preferences", authenticated(actionUserGetPreferences))
http.HandleFunc("POST /user/preferences", authenticated(actionUserSetPreferences))
http.HandleFunc("/sync/from_server/count/{sequence}", authenticated(actionSyncFromServerCount))
http.HandleFunc("/sync/from_server/{sequence}/{offset}", authenticated(actionSyncFromServer))
http.HandleFunc("/sync/to_server", authenticated(actionSyncToServer))
http.HandleFunc("/sync/file/upload/{uuid}", authenticated(actionSyncFileUpload))
http.HandleFunc("/node/retrieve/{uuid}", authenticated(actionNodeRetrieve))
http.HandleFunc("/node/history/retrieve/{uuid}/{offset}", authenticated(actionNodeHistoryRetrieve))
http.HandleFunc("/node/history/count/{uuid}", authenticated(actionNodeHistoryCount))
http.HandleFunc("/file/count", authenticated(actionFileCount))
http.HandleFunc("/file/server_uuids/{offset}", authenticated(actionFileServerUUIDs))
http.HandleFunc("/file/uuid/{uuid}", authenticated(actionFile))
http.HandleFunc("/file/uuid/{uuid}/metadata", authenticated(actionFileMetadata))
http.HandleFunc("/service_worker.js", pageServiceWorker)
@ -182,11 +162,6 @@ func authenticated(fn func(http.ResponseWriter, *http.Request)) func(http.Respon
// The Bearer token is extracted.
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
authHeader = "Bearer " + r.URL.Query().Get("jwt")
}
authParts := RxpBearerToken.FindStringSubmatch(authHeader)
if len(authParts) != 2 {
failed(fmt.Errorf("Authorization missing or invalid"))
@ -202,7 +177,7 @@ func authenticated(fn func(http.ResponseWriter, *http.Request)) func(http.Respon
}
// User object is added to the context for the next handler.
user := appUser.NewUser(claims)
user := NewUser(claims)
r = r.WithContext(context.WithValue(r.Context(), CONTEXT_USER, user))
Log.Debug("webserver", "op", "request", "method", r.Method, "url", r.URL.String(), "username", user.Username, "client", user.ClientUUID)
@ -214,13 +189,7 @@ func rootHandler(w http.ResponseWriter, r *http.Request) { // {{{
// All URLs not specifically handled are routed to this function.
// Everything going here should be a static resource.
if r.URL.Path == "/" {
page := NewPage("notes2")
err := Webengine.Render(page, w, r)
if err != nil {
w.Write([]byte(err.Error()))
return
}
http.Redirect(w, r, "/notes2", http.StatusSeeOther)
return
}
@ -276,6 +245,15 @@ func pageLogin(w http.ResponseWriter, r *http.Request) { // {{{
return
}
} // }}}
func pageNotes2(w http.ResponseWriter, r *http.Request) { // {{{
page := NewPage("notes2")
err := Webengine.Render(page, w, r)
if err != nil {
w.Write([]byte(err.Error()))
return
}
} // }}}
func pageSync(w http.ResponseWriter, r *http.Request) { // {{{
page := NewPage("sync")
@ -287,13 +265,10 @@ func pageSync(w http.ResponseWriter, r *http.Request) { // {{{
} // }}}
func actionSyncFromServer(w http.ResponseWriter, r *http.Request) { // {{{
// XXX - DELETE ME!
time.Sleep(time.Millisecond * 200)
// The purpose of the Client UUID is to avoid
// sending nodes back once again to a client that
// just created or modified it.
user := getUserSession(r)
user := getUser(r)
changedFrom, _ := strconv.Atoi(r.PathValue("sequence"))
offset, _ := strconv.Atoi(r.PathValue("offset"))
@ -304,6 +279,12 @@ func actionSyncFromServer(w http.ResponseWriter, r *http.Request) { // {{{
return
}
/*
Log.Debug("/sync/from_server", "num_nodes", len(nodes), "maxSeq", maxSeq)
foo, _ := json.Marshal(nodes)
os.WriteFile(fmt.Sprintf("/tmp/nodes-%d.json", offset), foo, 0644)
*/
j, _ := json.Marshal(struct {
OK bool
Nodes []Node
@ -316,7 +297,7 @@ func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{
// The purpose of the Client UUID is to avoid
// sending nodes back once again to a client that
// just created or modified it.
user := getUserSession(r)
user := getUser(r)
changedFrom, _ := strconv.Atoi(r.PathValue("sequence"))
count, err := NodesCount(user.UserID, uint64(changedFrom), user.ClientUUID)
@ -336,7 +317,7 @@ func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{
w.Write(j)
} // }}}
func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
user := getUserSession(r)
user := getUser(r)
var err error
uuid := r.PathValue("uuid")
@ -351,48 +332,8 @@ func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
"Node": node,
})
} // }}}
func actionNodeHistoryRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
user := getUserSession(r)
var err error
uuid := r.PathValue("uuid")
offset, err := strconv.Atoi(r.PathValue("offset"))
if err != nil {
responseError(w, err)
return
}
nodes, hasMore, err := RetrieveNodeHistory(user.UserID, uuid, offset)
if err != nil {
responseError(w, err)
return
}
responseData(w, map[string]any{
"OK": true,
"Nodes": nodes,
"HasMore": hasMore,
})
} // }}}
func actionNodeHistoryCount(w http.ResponseWriter, r *http.Request) { // {{{
user := getUserSession(r)
var err error
uuid := r.PathValue("uuid")
count, err := RetrieveNodeHistoryCount(user.UserID, uuid)
if err != nil {
responseError(w, err)
return
}
responseData(w, map[string]any{
"OK": true,
"Count": count,
})
} // }}}
func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{
user := getUserSession(r)
user := getUser(r)
body, _ := io.ReadAll(r.Body)
var request struct {
@ -404,9 +345,9 @@ func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{
return
}
_, err = db.Exec(`CALL add_nodes($1, $2::uuid, $3::jsonb)`, user.UserID, user.ClientUUID, request.NodeData)
_, err = db.Exec(`CALL add_nodes($1, $2, $3::jsonb)`, user.UserID, user.ClientUUID, request.NodeData)
if err != nil {
Log.Error("sync", "error", err, "user_id", user.UserID, "client_uuid", user.ClientUUID, "node_data", request.NodeData)
Log.Error("sync", "error", err)
httpError(w, err)
return
}
@ -415,167 +356,6 @@ func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{
"OK": true,
})
} // }}}
func actionSyncFileUpload(w http.ResponseWriter, r *http.Request) { // {{{
uuid := r.PathValue("uuid")
session := getUserSession(r)
sentBytes, _ := strconv.Atoi(r.Header.Get("X-File-Sent-Bytes"))
totalBytes, _ := strconv.Atoi(r.Header.Get("X-File-Total-Bytes"))
// First block comes with file metadata.
file := File{}
if sentBytes == 0 {
file.UUID = uuid
file.Name = r.Header.Get("X-File-Name")
file.Size, _ = strconv.Atoi(r.Header.Get("X-File-Size"))
file.Type = r.Header.Get("X-File-Type")
modded, _ := strconv.ParseInt(r.Header.Get("X-File-Modified"), 10, 64)
file.Modified = time.UnixMilli(modded)
err := AddFileToDb(session.UserID, &file)
if err != nil {
Log.Error("upload", "op", "AddFileToDB", "error", err)
httpError(w, err)
return
}
fileUUIDCache[uuid] = file.ID
}
data, _ := io.ReadAll(r.Body)
var err error
switch sentBytes {
case 0:
// First block of data.
Log.Info("file_upload", "status", "new", "user", session.UserID, "uuid", uuid, "total", totalBytes)
id := fileUUIDCache[uuid]
err = FileWriteData(session.UserID, id, data, true)
case totalBytes:
// Last block of data - sent with 0 length data.
Log.Info("file_upload", "status", "done", "user", session.UserID, "uuid", uuid, "total", totalBytes)
id := fileUUIDCache[uuid]
delete(fileUUIDCache, uuid)
_, err = db.Exec(`UPDATE public.file SET upload_complete = true WHERE id = $1`, id)
default:
id := fileUUIDCache[uuid]
err = FileWriteData(session.UserID, id, data, false)
}
if err != nil {
Log.Error("upload", "error", err)
httpError(w, err)
return
}
responseData(w, map[string]any{
"OK": true,
})
} // }}}
func actionUserGetPreferences(w http.ResponseWriter, r *http.Request) { // {{{
user := getUserSession(r)
prefs, err := user.Preferences()
if err != nil {
httpError(w, err)
return
}
responseData(w, map[string]any{
"OK": true,
"Preferences": prefs,
})
} // }}}
func actionUserSetPreferences(w http.ResponseWriter, r *http.Request) { // {{{
session := getUserSession(r)
// Verify the "default" profile is still there.
var newPrefs map[string]appUser.UserPreferences
body, _ := io.ReadAll(r.Body)
err := json.Unmarshal(body, &newPrefs)
if err != nil {
httpError(w, err)
return
}
if _, found := newPrefs["default"]; !found {
httpError(w, fmt.Errorf("'default' profile missing."))
return
}
err = session.SetPreferences(newPrefs)
if err != nil {
httpError(w, err)
return
}
responseData(w, map[string]any{
"OK": true,
})
} // }}}
func actionFileCount(w http.ResponseWriter, r *http.Request) { // {{{
session := getUserSession(r)
count, err := FileCountForUser(session.UserID)
if err != nil {
Log.Error("file_count", "error", err)
httpError(w, err)
return
}
responseData(w, map[string]any{
"OK": true,
"Count": count,
})
} // }}}
func actionFileServerUUIDs(w http.ResponseWriter, r *http.Request) { // {{{
session := getUserSession(r)
offset, _ := strconv.Atoi(r.PathValue("offset"))
uuids, more, err := FileServerUUIDsForUser(session.UserID, offset)
if err != nil {
Log.Error("file_server_uuids", "error", err)
httpError(w, err)
return
}
responseData(w, map[string]any{
"OK": true,
"UUIDs": uuids,
"MoreRowsExist": more,
})
} // }}}
func actionFile(w http.ResponseWriter, r *http.Request) { // {{{
uuid := r.PathValue("uuid")
session := getUserSession(r)
f, md, err := FileForUser(session.UserID, uuid)
if err != nil {
Log.Error("file_metadata", "error", err)
httpError(w, err)
return
}
w.Header().Add("X-File-Name", md.Name)
w.Header().Add("X-File-Modified", fmt.Sprintf("%d", md.Modified.UnixMilli()))
w.Header().Add("Content-Type", md.Type)
http.ServeContent(w, r, md.Name, md.Modified, f)
} // }}}
func actionFileMetadata(w http.ResponseWriter, r *http.Request) { // {{{
uuid := r.PathValue("uuid")
session := getUserSession(r)
md, err := FileMetadata(session.UserID, uuid)
if err != nil {
Log.Error("file_metadata", "error", err)
httpError(w, err)
return
}
responseData(w, map[string]any{
"OK": true,
"Metadata": md,
})
} // }}}
func createNewUser(username string) { // {{{
reader := bufio.NewReader(os.Stdin)
@ -619,8 +399,7 @@ func changePassword(username string) { // {{{
fmt.Printf("\nPassword changed\n")
} // }}}
func getUserSession(r *http.Request) appUser.UserSession { // {{{
user, _ := r.Context().Value(CONTEXT_USER).(appUser.UserSession)
user.Db = db
func getUser(r *http.Request) UserSession { // {{{
user, _ := r.Context().Value(CONTEXT_USER).(UserSession)
return user
} // }}}

149
node.go
View file

@ -3,8 +3,8 @@ package main
import (
// External
werr "git.gibonuddevalla.se/go/wrappederror"
"github.com/derektata/lorem/ipsum"
"github.com/jmoiron/sqlx"
"github.com/derektata/lorem/ipsum"
// Standard
"database/sql"
@ -44,7 +44,6 @@ type Node struct {
UUID string
UserID int `db:"user_id"`
ParentUUID string `db:"parent_uuid"`
HistoryUUID string `db:"history_uuid"`
Name string
Created time.Time
Updated time.Time
@ -54,15 +53,80 @@ type Node struct {
DeletedSeq sql.NullInt64 `db:"deleted_seq"`
Content string
ContentEncrypted string `db:"content_encrypted" json:"-"`
Special bool
Markdown bool
// CryptoKeyID int `db:"crypto_key_id"`
//Files []File
//ChecklistGroups []ChecklistGroup
}
func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint64, moreRowsExist bool, err error) { // {{{
const LIMIT = 100
var rows *sqlx.Rows
rows, err = db.Queryx(`
SELECT
uuid,
COALESCE(parent_uuid, '') AS parent_uuid,
name,
created,
updated,
deleted IS NOT NULL AS deleted,
created_seq,
updated_seq,
deleted_seq
FROM
public.node
WHERE
user_id = $1 AND
NOT history AND (
created_seq > $4 OR
updated_seq > $4 OR
deleted_seq > $4
)
ORDER BY
created ASC
LIMIT $2 OFFSET $3
`,
userID,
LIMIT+1,
offset,
synced,
)
if err != nil {
return
}
defer rows.Close()
nodes = []TreeNode{}
numNodes := 0
for rows.Next() {
// Query selects up to one more row than the decided limit.
// Saves one SQL query for row counting.
// Thus if numNodes is larger than the limit, more rows exist for the next call.
numNodes++
if numNodes > LIMIT {
moreRowsExist = true
return
}
node := TreeNode{}
if err = rows.StructScan(&node); err != nil {
return
}
nodes = append(nodes, node)
// DeletedSeq will be 0 if invalid, and thus not be a problem for the max function.
maxSeq = max(maxSeq, node.CreatedSeq, node.UpdatedSeq, uint64(node.DeletedSeq.Int64))
}
return
} // }}}
func Nodes(userID, offset int, synced uint64, clientUUID string) (nodes []Node, maxSeq uint64, moreRowsExist bool, err error) { // {{{
var rows *sqlx.Rows
rows, err = db.Queryx(`
SELECT
uuid,
COALESCE(parent_uuid, '00000000-0000-0000-0000-000000000000'::uuid) AS parent_uuid,
COALESCE(parent_uuid, '') AS parent_uuid,
name,
created,
updated,
@ -71,14 +135,14 @@ func Nodes(userID, offset int, synced uint64, clientUUID string) (nodes []Node,
updated_seq,
deleted_seq,
content,
content_encrypted
content_encrypted,
markdown
FROM
public.node
WHERE
NOT special AND
user_id = $1 AND
client != $5::uuid AND
(
client != $5 AND
NOT history AND (
created_seq > $4 OR
updated_seq > $4 OR
deleted_seq > $4
@ -129,10 +193,9 @@ func NodesCount(userID int, synced uint64, clientUUID string) (count int, err er
FROM
public.node
WHERE
NOT special AND
user_id = $1 AND
client != $3 AND
(
NOT history AND (
created_seq > $2 OR
updated_seq > $2 OR
deleted_seq > $2
@ -185,72 +248,6 @@ func RetrieveNode(userID int, nodeUUID string) (node Node, err error) { // {{{
return
} // }}}
func RetrieveNodeHistory(userID int, nodeUUID string, offset int) (nodes []Node, hasMore bool, err error) { // {{{
nodes = []Node{}
var rows *sqlx.Rows
rows, err = db.Queryx(`
SELECT
uuid,
history_uuid,
user_id,
name,
created,
updated,
content,
content_encrypted
FROM node_history
WHERE
user_id = $1 AND
uuid = $2
LIMIT $3 OFFSET $4
`,
userID,
nodeUUID,
SYNC_PAGINATION+1,
offset,
)
if err != nil {
err = werr.Wrap(err)
return
}
defer rows.Close()
for rows.Next() {
node := Node{}
if err = rows.StructScan(&node); err != nil {
err = werr.Wrap(err)
return
}
nodes = append(nodes, node)
}
if len(nodes) > SYNC_PAGINATION {
hasMore = true
nodes = nodes[0 : len(nodes)-1]
}
return
} // }}}
func RetrieveNodeHistoryCount(userID int, nodeUUID string) (count int, err error) { // {{{
var row *sql.Row
row = db.QueryRow(`
SELECT
COUNT(*)
FROM node_history
WHERE
user_id = $1 AND
uuid = $2
`,
userID,
nodeUUID,
)
if err = row.Scan(&count); err != nil {
err = werr.Wrap(err)
return
}
return
} // }}}
func NodeCrumbs(nodeUUID string) (nodes []Node, err error) { // {{{
var rows *sqlx.Rows
rows, err = db.Queryx(`

View file

@ -1,217 +1,47 @@
--
-- Name: pg_trgm; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;
CREATE SEQUENCE node_updates;
--
-- Name: EXTENSION pg_trgm; Type: COMMENT; Schema: -; Owner:
--
COMMENT ON EXTENSION pg_trgm IS 'text similarity measurement and index searching based on trigrams';
--
-- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public;
--
-- Name: EXTENSION pgcrypto; Type: COMMENT; Schema: -; Owner:
--
COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions';
--
-- Name: json_ancestor_array; Type: TYPE; Schema: public; Owner: postgres
--
CREATE TYPE public.json_ancestor_array AS (
"Ancestors" character varying[]
CREATE TABLE public."user" (
id serial4 NOT NULL,
username varchar NOT NULL,
"name" varchar NOT NULL,
"password" varchar NOT NULL,
last_login timestamp DEFAULT now() NOT NULL,
timezone varchar DEFAULT 'UTC'::character varying NOT NULL,
CONSTRAINT user_pk PRIMARY KEY (id)
);
--
-- Name: add_nodes(integer, character varying, jsonb); Type: PROCEDURE; Schema: public; Owner: postgres
--
CREATE TABLE public.node (
id serial4 NOT NULL,
user_id int4 NOT NULL,
"uuid" bpchar(36) DEFAULT gen_random_uuid() NOT NULL,
parent_uuid bpchar(36) NULL,
CREATE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid character varying, IN p_nodes jsonb)
LANGUAGE plpgsql
AS $$
created timestamptz DEFAULT NOW() NOT NULL,
updated timestamptz DEFAULT NOW() NOT NULL,
deleted timestamptz NULL,
DECLARE
node_data jsonb;
node_updated timestamptz;
db_updated timestamptz;
db_uuid bpchar;
db_client bpchar;
db_client_seq int;
node_uuid bpchar;
parent_uuid_nullable bpchar;
created_seq bigint NOT NULL DEFAULT nextval('node_updates'),
updated_seq bigint NOT NULL DEFAULT nextval('node_updates'),
deleted_seq bigint NULL,
BEGIN
RAISE NOTICE '--------------------------';
FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes)
LOOP
node_uuid = (node_data->>'UUID')::bpchar;
node_updated = (node_data->>'Updated')::timestamptz;
"name" varchar(256) DEFAULT ''::character varying NOT NULL,
"content" text DEFAULT ''::text NOT NULL,
content_encrypted text DEFAULT ''::text NOT NULL,
markdown bool DEFAULT false NOT NULL,
IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' THEN
parent_uuid_nullable = NULL;
ELSE
parent_uuid_nullable = node_data->>'ParentUUID';
END IF;
/* Retrieve the current modified timestamp for this node from the database. */
SELECT
uuid, updated, client, client_sequence
INTO
db_uuid, db_updated, db_client, db_client_seq
FROM public."node"
WHERE
user_id = p_user_id AND
uuid = node_uuid;
/* Is the node not in database? It needs to be created. */
IF db_uuid IS NULL THEN
RAISE NOTICE '01 New node %', node_uuid;
INSERT INTO public."node" (
user_id, "uuid", parent_uuid, created, updated,
"name", "content", markdown, "content_encrypted",
client, client_sequence
)
VALUES(
p_user_id,
node_uuid,
parent_uuid_nullable,
(node_data->>'Created')::timestamptz,
(node_data->>'Updated')::timestamptz,
(node_data->>'Name')::varchar,
(node_data->>'Content')::text,
(node_data->>'Markdown')::bool,
'', /* content_encrypted */
p_client_uuid,
(node_data->>'ClientSequence')::int
CONSTRAINT name_length CHECK ((length(TRIM(BOTH FROM name)) > 0)),
CONSTRAINT node_pk PRIMARY KEY (id),
CONSTRAINT user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE RESTRICT ON UPDATE RESTRICT
);
CONTINUE;
END IF;
CREATE UNIQUE INDEX node_uuid_idx ON public.node USING btree (uuid);
CREATE INDEX node_search_index ON public.node USING gin (name gin_trgm_ops, content gin_trgm_ops);
/* The client could send a specific node again if it didn't receive the OK from this procedure before. */
IF db_updated = node_updated AND db_client = p_client_uuid AND db_client_seq = (node_data->>'ClientSequence')::int THEN
RAISE NOTICE '04, already recorded, %, %', db_client, db_client_seq;
CONTINUE;
END IF;
/* Determine if the incoming node data is to go into history or replace the current node. */
IF db_updated > node_updated THEN
RAISE NOTICE '02 DB newer, % > % (%))', db_updated, node_updated, node_uuid;
/* Incoming node is going straight to history since it is older than the current node. */
INSERT INTO node_history(
user_id, "uuid", parents, created, updated,
"name", "content", markdown, "content_encrypted",
client, client_sequence
)
VALUES(
p_user_id,
node_uuid,
(jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors",
(node_data->>'Created')::timestamptz,
(node_data->>'Updated')::timestamptz,
(node_data->>'Name')::varchar,
(node_data->>'Content')::text,
(node_data->>'Markdown')::bool,
'', /* content_encrypted */
p_client_uuid,
(node_data->>'ClientSequence')::int
)
ON CONFLICT (client, client_sequence)
DO NOTHING;
ELSE
RAISE NOTICE '03 Client newer, % > % (%, %)', node_updated, db_updated, node_uuid, (node_data->>'ClientSequence');
/* Incoming node is newer and will replace the current node.
*
* The current node is copied to the node_history table and then modified in place
* with the incoming data. */
INSERT INTO node_history(
user_id, "uuid", parents,
created, updated, "name", "content", markdown, "content_encrypted",
client, client_sequence
)
SELECT
user_id,
"uuid",
(
WITH RECURSIVE nodes AS (
SELECT
uuid,
COALESCE(parent_uuid, '') AS parent_uuid,
name,
0 AS depth
FROM node
WHERE
uuid = node_uuid
UNION
SELECT
n.uuid,
COALESCE(n.parent_uuid, '') AS parent_uuid,
n.name,
nr.depth+1 AS depth
FROM node n
INNER JOIN nodes nr ON n.uuid = nr.parent_uuid
)
SELECT ARRAY (
SELECT name
FROM nodes
ORDER BY depth DESC
OFFSET 1 /* discard itself */
)
),
created,
updated,
name,
content,
markdown,
content_encrypted,
client,
client_sequence
FROM public."node"
WHERE
user_id = p_user_id AND
uuid = node_uuid
ON CONFLICT (client, client_sequence)
DO NOTHING;
/* Current node in database is updated with incoming data. */
UPDATE public."node"
SET
updated = (node_data->>'Updated')::timestamptz,
updated_seq = nextval('node_updates'),
name = (node_data->>'Name')::varchar,
content = (node_data->>'Content')::text,
markdown = (node_data->>'Markdown')::bool,
client = p_client_uuid,
client_sequence = (node_data->>'ClientSequence')::int
WHERE
user_id = p_user_id AND
uuid = node_uuid;
END IF;
END LOOP;
END
$$;
--
-- Name: node_update_timestamp(); Type: FUNCTION; Schema: public; Owner: postgres
--
CREATE FUNCTION public.node_update_timestamp() RETURNS trigger
LANGUAGE plpgsql
CREATE OR REPLACE FUNCTION node_update_timestamp()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
IF NEW.updated = OLD.updated THEN
@ -226,335 +56,6 @@ BEGIN
END;
$$;
--
-- Name: password_hash(character, bytea); Type: FUNCTION; Schema: public; Owner: postgres
--
CREATE FUNCTION public.password_hash(salt_hex character, pass bytea) RETURNS character
LANGUAGE plpgsql
AS $$
BEGIN
RETURN (
SELECT
salt_hex ||
encode(
sha256(
decode(salt_hex, 'hex') || /* salt in binary */
pass /* password */
),
'hex'
)
);
END;
$$;
--
-- Name: client; Type: TABLE; Schema: public; Owner: postgres
--
CREATE TABLE public.client (
id integer NOT NULL,
user_id integer NOT NULL,
client_uuid uuid DEFAULT '00000000-0000-0000-0000-000000000000'::uuid NOT NULL,
created timestamp with time zone DEFAULT now() NOT NULL,
description character varying DEFAULT ''::character varying NOT NULL
);
--
-- Name: client_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
--
CREATE SEQUENCE public.client_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: client_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres
--
ALTER SEQUENCE public.client_id_seq OWNED BY public.client.id;
--
-- Name: node_updates; Type: SEQUENCE; Schema: public; Owner: postgres
--
CREATE SEQUENCE public.node_updates
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: node; Type: TABLE; Schema: public; Owner: postgres
--
CREATE TABLE public.node (
id integer NOT NULL,
user_id integer NOT NULL,
"uuid" uuid DEFAULT gen_random_uuid() NOT NULL,
parent_uuid uuid,
created timestamp with time zone DEFAULT now() NOT NULL,
updated timestamp with time zone DEFAULT now() NOT NULL,
deleted timestamp with time zone,
created_seq bigint DEFAULT nextval('public.node_updates'::regclass) NOT NULL,
updated_seq bigint DEFAULT nextval('public.node_updates'::regclass) NOT NULL,
deleted_seq bigint,
name character varying(256) DEFAULT ''::character varying NOT NULL,
content text DEFAULT ''::text NOT NULL,
content_encrypted text DEFAULT ''::text NOT NULL,
markdown boolean DEFAULT false NOT NULL,
history boolean DEFAULT false NOT NULL,
client uuid DEFAULT '00000000-0000-0000-0000-000000000000'::uuid NOT NULL,
client_sequence integer,
CONSTRAINT name_length CHECK ((length(TRIM(BOTH FROM name)) > 0))
);
--
-- Name: node_history; Type: TABLE; Schema: public; Owner: postgres
--
CREATE TABLE public.node_history (
id integer NOT NULL,
user_id integer NOT NULL,
"uuid" uuid NOT NULL,
parents character varying[],
created timestamp with time zone NOT NULL,
updated timestamp with time zone NOT NULL,
name character varying(256) NOT NULL,
content text NOT NULL,
content_encrypted text NOT NULL,
markdown boolean DEFAULT false NOT NULL,
client uuid DEFAULT '00000000-0000-0000-0000-000000000000'::uuid NOT NULL,
client_sequence integer
);
--
-- Name: node_history_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
--
CREATE SEQUENCE public.node_history_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: node_history_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres
--
ALTER SEQUENCE public.node_history_id_seq OWNED BY public.node_history.id;
--
-- Name: node_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
--
CREATE SEQUENCE public.node_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: node_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres
--
ALTER SEQUENCE public.node_id_seq OWNED BY public.node.id;
--
-- Name: test_data; Type: SEQUENCE; Schema: public; Owner: postgres
--
CREATE SEQUENCE public.test_data
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: user; Type: TABLE; Schema: public; Owner: postgres
--
CREATE TABLE public."user" (
id integer NOT NULL,
username character varying NOT NULL,
name character varying NOT NULL,
password character varying NOT NULL,
last_login timestamp without time zone DEFAULT now() NOT NULL,
timezone character varying DEFAULT 'UTC'::character varying NOT NULL
);
--
-- Name: user_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
--
CREATE SEQUENCE public.user_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: user_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres
--
ALTER SEQUENCE public.user_id_seq OWNED BY public."user".id;
--
-- Name: client id; Type: DEFAULT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.client ALTER COLUMN id SET DEFAULT nextval('public.client_id_seq'::regclass);
--
-- Name: node id; Type: DEFAULT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.node ALTER COLUMN id SET DEFAULT nextval('public.node_id_seq'::regclass);
--
-- Name: node_history id; Type: DEFAULT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.node_history ALTER COLUMN id SET DEFAULT nextval('public.node_history_id_seq'::regclass);
--
-- Name: user id; Type: DEFAULT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public."user" ALTER COLUMN id SET DEFAULT nextval('public.user_id_seq'::regclass);
--
-- Name: client client_pk; Type: CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.client
ADD CONSTRAINT client_pk PRIMARY KEY (id);
--
-- Name: node_history node_history_pk; Type: CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.node_history
ADD CONSTRAINT node_history_pk PRIMARY KEY (id);
--
-- Name: node node_pk; Type: CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.node
ADD CONSTRAINT node_pk PRIMARY KEY (id);
--
-- Name: user user_pk; Type: CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public."user"
ADD CONSTRAINT user_pk PRIMARY KEY (id);
--
-- Name: client_uuid_idx; Type: INDEX; Schema: public; Owner: postgres
--
CREATE UNIQUE INDEX client_uuid_idx ON public.client USING btree (client_uuid);
--
-- Name: node_history_client_idx; Type: INDEX; Schema: public; Owner: postgres
--
CREATE UNIQUE INDEX node_history_client_idx ON public.node_history USING btree (client, client_sequence);
--
-- Name: node_history_idx; Type: INDEX; Schema: public; Owner: postgres
--
CREATE INDEX node_history_idx ON public.node USING btree (history);
--
-- Name: node_history_uuid_idx; Type: INDEX; Schema: public; Owner: postgres
--
CREATE INDEX node_history_uuid_idx ON public.node USING btree (uuid);
--
-- Name: node_search_index; Type: INDEX; Schema: public; Owner: postgres
--
CREATE INDEX node_search_index ON public.node USING gin (name public.gin_trgm_ops, content public.gin_trgm_ops);
--
-- Name: node_uuid_idx; Type: INDEX; Schema: public; Owner: postgres
--
CREATE UNIQUE INDEX node_uuid_idx ON public.node USING btree (uuid);
--
-- Name: node node_update; Type: TRIGGER; Schema: public; Owner: postgres
--
CREATE TRIGGER node_update AFTER UPDATE ON public.node FOR EACH ROW EXECUTE FUNCTION public.node_update_timestamp();
--
-- Name: node_history node_history_user_fk; Type: FK CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.node_history
ADD CONSTRAINT node_history_user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON UPDATE RESTRICT ON DELETE RESTRICT;
--
-- Name: node node_node_fk; Type: FK CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.node
ADD CONSTRAINT node_node_fk FOREIGN KEY (parent_uuid) REFERENCES public.node(uuid) ON UPDATE SET NULL ON DELETE SET NULL;
--
-- Name: node user_fk; Type: FK CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.node
ADD CONSTRAINT user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON UPDATE RESTRICT ON DELETE RESTRICT;
CREATE OR REPLACE TRIGGER node_update AFTER UPDATE ON node
FOR EACH ROW
EXECUTE PROCEDURE node_update_timestamp();

View file

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

View file

@ -1 +1 @@
ALTER TABLE public.node_history ADD history_uuid uuid NULL;
ALTER TABLE public.node ADD CONSTRAINT node_node_fk FOREIGN KEY (parent_uuid) REFERENCES public.node("uuid") ON DELETE SET NULL ON UPDATE SET NULL;

View file

@ -1,135 +1,2 @@
CREATE UNIQUE INDEX node_history_user_id_idx ON public.node_history (user_id,"uuid",history_uuid);
ALTER TABLE public.node ALTER COLUMN "uuid" TYPE uuid USING "uuid"::uuid::uuid;
ALTER TABLE public.node ALTER COLUMN parent_uuid TYPE uuid USING parent_uuid::uuid::uuid;
ALTER TABLE public.node ALTER COLUMN client TYPE uuid USING client::uuid::uuid;
CREATE OR REPLACE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid uuid, IN p_nodes jsonb)
LANGUAGE plpgsql
AS $procedure$
DECLARE
node_data jsonb;
node_updated timestamptz;
db_updated timestamptz;
db_uuid uuid;
db_client uuid;
db_client_seq int;
db_history_uuid uuid;
node_uuid uuid;
node_parent_uuid uuid;
node_history_uuid uuid;
BEGIN
RAISE NOTICE '--------------------------';
FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes)
LOOP
node_uuid = (node_data->>'UUID')::uuid;
node_history_uuid = (node_data->>'HistoryUUID')::uuid;
node_updated = (node_data->>'Updated')::timestamptz;
-- Frontend is using an all-zero UUID to define the root node.
-- Database is using NULL.
IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' THEN
node_parent_uuid = NULL;
ELSE
node_parent_uuid = (node_data->>'ParentUUID')::uuid;
END IF;
-- Every jode has a new history UUID to keep the history entry uniquely identifiable
-- across clients. A history entry could potentially be sent again, but should be
-- safe to ignore as every change to a node should have a new history UUID.
--
-- The current node is also stored as history.
INSERT INTO node_history(
user_id, "uuid", "history_uuid", parents, created, updated,
"name", "content", markdown, "content_encrypted",
client, client_sequence
)
VALUES(
p_user_id, -- combined key
node_uuid, -- combined key
node_history_uuid, -- combined key
(jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors",
(node_data->>'Created')::timestamptz,
(node_data->>'Updated')::timestamptz,
(node_data->>'Name')::varchar,
(node_data->>'Content')::text,
(node_data->>'Markdown')::bool,
'', /* content_encrypted */
p_client_uuid,
(node_data->>'ClientSequence')::int
)
ON CONFLICT ("user_id", "uuid", "history_uuid")
DO NOTHING;
-- Retrieve the current modified timestamp for this node from the database.
SELECT
uuid, updated, client, client_sequence
INTO
db_uuid, db_updated, db_client, db_client_seq
FROM public."node"
WHERE
user_id = p_user_id AND
uuid::uuid = node_uuid::uuid;
-- Is the node not in database? It needs to be created.
IF db_uuid IS NULL THEN
RAISE NOTICE '01 New node %', node_uuid;
INSERT INTO public."node" (
user_id, "uuid", parent_uuid, created, updated,
"name", "content", markdown, "content_encrypted",
client, client_sequence
)
VALUES(
p_user_id,
node_uuid,
node_parent_uuid,
(node_data->>'Created')::timestamptz,
(node_data->>'Updated')::timestamptz,
(node_data->>'Name')::varchar,
(node_data->>'Content')::text,
(node_data->>'Markdown')::bool,
'', /* content_encrypted */
p_client_uuid,
(node_data->>'ClientSequence')::int
);
CONTINUE;
END IF;
-- Update the public node as well if it was older than incoming node.
IF node_updated > db_updated THEN
UPDATE public."node"
SET
updated = (node_data->>'Updated')::timestamptz,
updated_seq = nextval('node_updates'),
name = (node_data->>'Name')::varchar,
content = (node_data->>'Content')::text,
markdown = (node_data->>'Markdown')::bool,
client = p_client_uuid,
client_sequence = (node_data->>'ClientSequence')::int
WHERE
user_id = p_user_id AND
uuid::uuid = node_uuid::uuid;
END IF;
END LOOP;
END
$procedure$
;
ALTER TABLE public.node ADD COLUMN history bool NOT NULL DEFAULT false;
CREATE INDEX node_history_idx ON public.node (history);

View file

@ -1,129 +1 @@
-- Some cleanup of old columns not used anymore.
DROP INDEX public.node_history_client_idx;
ALTER TABLE public.node_history DROP COLUMN client_sequence;
ALTER TABLE public.node DROP COLUMN markdown;
DROP INDEX public.node_history_idx;
ALTER TABLE public.node DROP COLUMN history;
ALTER TABLE public.node DROP COLUMN client_sequence;
CREATE OR REPLACE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid uuid, IN p_nodes jsonb)
LANGUAGE plpgsql
AS $procedure$
DECLARE
node_data jsonb;
node_updated timestamptz;
db_updated timestamptz;
db_uuid uuid;
db_client uuid;
db_history_uuid uuid;
node_uuid uuid;
node_parent_uuid uuid;
node_history_uuid uuid;
BEGIN
FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes)
LOOP
node_uuid = (node_data->>'UUID')::uuid;
node_history_uuid = (node_data->>'HistoryUUID')::uuid;
node_updated = (node_data->>'Updated')::timestamptz;
-- Frontend is using an all-zero UUID to define the root node.
-- Database is using NULL.
IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' OR node_data->>'ParentUUID' = '' THEN
node_parent_uuid = NULL;
ELSE
node_parent_uuid = (node_data->>'ParentUUID')::uuid;
END IF;
-- Every jode has a new history UUID to keep the history entry uniquely identifiable
-- across clients. A history entry could potentially be sent again, but should be
-- safe to ignore as every change to a node should have a new history UUID.
--
-- The current node is also stored as history.
INSERT INTO node_history(
user_id, "uuid", "history_uuid", parents, created, updated,
"name", "content", "content_encrypted",
client
)
VALUES(
p_user_id, -- combined key
node_uuid, -- combined key
node_history_uuid, -- combined key
(jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors",
COALESCE((node_data->>'Created')::timestamptz, NOW()),
COALESCE((node_data->>'Updated')::timestamptz, NOW()),
(node_data->>'Name')::varchar,
(node_data->>'Content')::text,
'', /* content_encrypted */
p_client_uuid
)
ON CONFLICT ("user_id", "uuid", "history_uuid")
DO NOTHING;
-- Retrieve the current modified timestamp for this node from the database.
SELECT
uuid, updated, client
INTO
db_uuid, db_updated, db_client
FROM public."node"
WHERE
user_id = p_user_id AND
uuid::uuid = node_uuid::uuid;
-- Is the node not in database? It needs to be created.
IF db_uuid IS NULL THEN
RAISE NOTICE '01 New node %', node_uuid;
INSERT INTO public."node" (
user_id, "uuid", parent_uuid, created, updated,
"name", "content", "content_encrypted",
client
)
VALUES(
p_user_id,
node_uuid,
node_parent_uuid,
COALESCE((node_data->>'Created')::timestamptz, NOW()),
COALESCE((node_data->>'Updated')::timestamptz, NOW()),
(node_data->>'Name')::varchar,
(node_data->>'Content')::text,
'', /* content_encrypted */
p_client_uuid
);
CONTINUE;
END IF;
-- Update the public node as well if it was older than incoming node.
IF node_updated > db_updated THEN
UPDATE public."node"
SET
updated = (node_data->>'Updated')::timestamptz,
updated_seq = nextval('node_updates'),
name = (node_data->>'Name')::varchar,
content = (node_data->>'Content')::text,
client = p_client_uuid
WHERE
user_id = p_user_id AND
uuid::uuid = node_uuid::uuid;
END IF;
END LOOP;
END
$procedure$
;
ALTER TABLE public.node ADD COLUMN client bpchar(36) NOT NULL DEFAULT '';

View file

@ -1,119 +1 @@
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$
;
DROP INDEX public.node_uuid_idx;

View file

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

View file

@ -1,123 +1,2 @@
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$
;
ALTER TABLE node ADD COLUMN Client_sequence int NULL;
ALTER TABLE node_history ADD COLUMN Client_sequence int NULL;

View file

@ -1,35 +1 @@
-- 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();
CREATE UNIQUE INDEX node_history_client_idx ON public.node_history (client,client_sequence);

View file

@ -1 +1,10 @@
ALTER TABLE public."user" ADD preferences jsonb DEFAULT '{}' NOT NULL;
CREATE TABLE public.client (
id serial NOT NULL,
user_id int4 NOT NULL,
client_uuid bpchar(36) DEFAULT '' NOT NULL,
created timestamptz DEFAULT NOW() NOT NULL,
description varchar DEFAULT '' NOT NULL,
CONSTRAINT client_pk PRIMARY KEY (id)
);
CREATE UNIQUE INDEX client_uuid_idx ON public.client (client_uuid);

View file

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

View file

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

View file

@ -1,171 +0,0 @@
-- file_node_links Keeps tracks of the files referenced by each node.
CREATE TABLE public.file_node_links (
file_uuid uuid NOT NULL,
node_uuid uuid NOT NULL,
user_id int4 NOT NULL,
CONSTRAINT file_node_pk PRIMARY KEY (file_uuid,node_uuid,user_id),
CONSTRAINT file_node_user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT file_node_node_fk FOREIGN KEY (user_id,node_uuid) REFERENCES public.node(user_id,"uuid") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT file_node_file_fk FOREIGN KEY (user_id,file_uuid) REFERENCES public.file(user_id,"uuid") ON DELETE CASCADE ON UPDATE CASCADE
);
-- add_nodes needs to call the new manage_node_file_links procedure.
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
);
-- Remove/insert file links for the newest content of this node.
CALL manage_node_file_links(p_user_id, node_uuid, (node_data->>'Content')::text);
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;
-- Remove/insert file links for the newest content of this node.
CALL manage_node_file_links(p_user_id, node_uuid, (node_data->>'Content')::text);
END IF;
END LOOP;
END
$procedure$
;
CREATE OR REPLACE PROCEDURE public.manage_node_file_links(IN p_user_id integer, IN p_node_uuid uuid, IN p_node_content text)
LANGUAGE plpgsql
AS $fn$
BEGIN
-- Old file links are purged to get rid of deleted ones.
-- New links will be added from latest node update.
DELETE FROM file_node_links
WHERE
user_id = p_user_id AND
node_uuid = p_node_uuid;
-- db:// URLs are extracted from the content to manage them in a separate table.
WITH file_uuids AS (
SELECT regexp_matches(
p_node_content,
'db://([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})',
'g'
) AS file_uuid
)
INSERT INTO file_node_links(user_id, node_uuid, file_uuid)
SELECT
p_user_id,
p_node_uuid,
(file_uuid[1])::uuid
FROM file_uuids;
END
$fn$
;

View file

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

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View file

@ -8,7 +8,7 @@
version="1.1"
id="svg1"
sodipodi:docname="collapsed.svg"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)"
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"
@ -23,13 +23,13 @@
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="19.349237"
inkscape:cx="11.809251"
inkscape:cy="6.3051583"
inkscape:window-width="1093"
inkscape:window-height="1401"
inkscape:window-x="2560"
inkscape:window-y="0"
inkscape:zoom="4.8373092"
inkscape:cx="6.201795"
inkscape:cy="-12.40359"
inkscape:window-width="1916"
inkscape:window-height="1161"
inkscape:window-x="0"
inkscape:window-y="18"
inkscape:window-maximized="1"
inkscape:current-layer="layer1"
showguides="false" />
@ -42,13 +42,9 @@
transform="translate(-102.39375,-146.31458)">
<title
id="title1">folder-outline</title>
<path
style="opacity:1;fill:#ffffff;stroke-width:0.264583"
d="m 102.7356,147.34014 h 5.83884 v 3.91079 h -5.86619 z"
id="path2" />
<path
d="m 108.34687,150.94479 h -5.29166 v -3.30729 h 5.29166 m 0,-0.66146 h -2.64584 l -0.66145,-0.66146 h -1.98437 c -0.36711,0 -0.66146,0.29435 -0.66146,0.66146 v 3.96875 a 0.66145729,0.66145729 0 0 0 0.66146,0.66146 h 5.29166 a 0.66145729,0.66145729 0 0 0 0.66146,-0.66146 v -3.30729 c 0,-0.36711 -0.29767,-0.66146 -0.66146,-0.66146 z"
id="path1"
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:#ffcc00;fill-opacity:1;stroke-width:0.330728;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none;stop-color:#000000;stop-opacity:1" />
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:#71c837;fill-opacity:1;stroke-width:0.330728;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none;stop-color:#000000;stop-opacity:1" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Before After
Before After

View file

@ -8,7 +8,7 @@
version="1.1"
id="svg1"
sodipodi:docname="expanded.svg"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
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"
@ -23,13 +23,13 @@
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="15.807429"
inkscape:cx="10.533022"
inkscape:cy="16.384701"
inkscape:window-width="1093"
inkscape:window-height="1401"
inkscape:window-x="1463"
inkscape:window-y="0"
inkscape:zoom="11.17754"
inkscape:cx="20.845374"
inkscape:cy="26.929003"
inkscape:window-width="1916"
inkscape:window-height="1161"
inkscape:window-x="0"
inkscape:window-y="18"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" /><defs
id="defs1" /><g
@ -39,9 +39,6 @@
transform="translate(-101.33542,-147.10833)"><title
id="title1">folder-open</title><title
id="title1-1">folder-open-outline</title><path
style="opacity:1;fill:#ffffff;stroke-width:0.264583;fill-opacity:1"
d="m 101.61996,148.02892 5.99218,0.36823 v 1.12144 l 0.16738,0.33476 -0.703,2.32657 -5.22222,-0.11717 z"
id="path2" /><path
d="m 102.69141,149.0927 -0.69454,2.64584 v -3.30729 h 5.62239 a 0.6614573,0.6614573 0 0 0 -0.66146,-0.66146 h -2.3151 l -0.66146,-0.66146 h -1.98437 a 0.6614573,0.6614573 0 0 0 -0.66145,0.66146 v 3.96875 a 0.6614573,0.6614573 0 0 0 0.66145,0.66146 h 4.96093 c 0.29766,0 0.56224,-0.19844 0.62839,-0.4961 l 0.76067,-2.8112 h -5.65545 m 4.26639,2.64584 h -4.29947 l 0.52916,-1.98438 h 4.29948 z"
id="path1"
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:#ffcc00;fill-opacity:1;stroke-width:0.264583;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none;stop-color:#000000;stop-opacity:1" /></g></svg>
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:#71c837;fill-opacity:1;stroke-width:0.264583;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none;stop-color:#000000;stop-opacity:1" /></g></svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Before After
Before After

View file

@ -1,107 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="31.218021"
height="36"
viewBox="0 0 8.2597682 9.5249997"
version="1.1"
id="svg1"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
sodipodi:docname="application_pdf.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="16"
inkscape:cx="11.40625"
inkscape:cy="20.9375"
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(-143.00122,-126.57004)"><title
id="title1">file-outline</title><g
id="g5"
transform="matrix(0.59530539,0,0,0.59530539,142.96121,126.57024)"><g
transform="matrix(0.04589,0,0,0.04589,-0.66877,-0.73379)"
id="g1">
<polygon
points="282.65,102.07 282.65,356.65 51.791,356.65 51.791,23.99 204.5,23.99 "
fill="#ffffff"
stroke-width="212.65"
id="polygon1" />
<path
d="m 201.19,31.99 73.46,73.393 v 243.26 H 59.79 V 31.983 h 141.4 m 6.623,-16 H 43.793 v 348.66 h 246.85 v -265.9 z"
stroke-width="21.791"
id="path1-8" />
</g><g
transform="matrix(0.04589,0,0,0.04589,-0.66877,-0.73379)"
id="g2">
<polygon
points="51.791,23.99 204.5,23.99 206.31,25.8 206.31,100.33 280.9,100.33 282.65,102.07 282.65,356.65 51.791,356.65 "
fill="#ffffff"
stroke-width="212.65"
id="polygon2" />
<path
d="m 198.31,31.99 v 76.337 h 76.337 v 240.32 H 59.787 V 31.987 h 138.52 m 9.5,-16 H 43.787 v 348.66 h 246.85 v -265.9 l -6.43,-6.424 H 214.3 V 22.481 Z"
stroke-width="21.791"
id="path2" />
</g><g
transform="matrix(0.04589,0,0,0.04589,-0.66877,-0.73379)"
stroke-width="21.791"
id="g3">
<polygon
points="219.64,48.667 258.31,86.38 258.31,87.75 219.64,87.75 "
id="polygon3" />
<path
d="M 227.64,67.646 240.05,79.75 H 227.64 V 67.646 M 222.638,40.417 H 211.64 V 95.75 h 54.666 V 83.008 Z"
id="path3" />
</g><g
transform="matrix(0.04589,0,0,0.04589,-0.66877,-0.73379)"
fill="#ed1c24"
stroke-width="212.65"
id="g4">
<polygon
points="22.544,167.68 37.291,152.94 37.291,171.49 297.15,171.49 297.15,152.94 311.89,167.68 311.89,284.49 22.544,284.49 "
id="polygon4" />
<path
d="m 303.65,168.63 1.747,1.747 v 107.62 H 29.047 v -107.62 l 1.747,-1.747 v 9.362 h 272.85 v -9.362 m -12.999,-31.385 v 27.747 H 43.785 v -27.747 l -27.747,27.747 v 126 h 302.35 v -126 z"
id="path4" />
</g><rect
x="1.7219"
y="7.9544001"
width="10.684"
height="4.0307002"
fill="none"
id="rect4" /><g
transform="matrix(0.04589,0,0,0.04589,1.7219,11.733)"
fill="#000000"
stroke-width="21.791"
aria-label="PDF"
id="g7"><path
d="M 9.216,0 V -83.2 H 39.68 q 6.784,0 12.928,1.408 6.144,1.28 10.752,4.608 4.608,3.2 7.296,8.576 2.816,5.248 2.816,13.056 0,7.68 -2.816,13.184 -2.688,5.504 -7.296,9.088 -4.608,3.456 -10.624,5.248 -6.016,1.664 -12.544,1.664 h -8.96 V 0 Z m 22.016,-43.776 h 7.936 q 6.528,0 9.6,-3.072 3.2,-3.072 3.2,-8.704 0,-5.632 -3.456,-7.936 -3.456,-2.304 -9.856,-2.304 h -7.424 z"
id="path5"
style="fill:#ffffff" /><path
d="m 87.04,0 v -83.2 h 24.576 q 9.472,0 17.28,2.304 7.936,2.304 13.568,7.296 5.632,4.992 8.704,12.8 3.2,7.808 3.2,18.816 0,11.008 -3.072,18.944 -3.072,7.936 -8.704,13.056 -5.504,5.12 -13.184,7.552 Q 121.856,0 112.896,0 Z m 22.016,-17.664 h 1.28 q 4.48,0 8.448,-1.024 3.968,-1.152 6.784,-3.84 2.944,-2.688 4.608,-7.424 1.664,-4.736 1.664,-12.032 0,-7.296 -1.664,-11.904 -1.664,-4.608 -4.608,-7.168 -2.816,-2.56 -6.784,-3.456 -3.968,-1.024 -8.448,-1.024 h -1.28 z"
id="path6"
style="fill:#ffffff" /><path
d="m 169.22,0 v -83.2 h 54.272 v 18.432 h -32.256 v 15.872 h 27.648 v 18.432 H 191.236 V 0 Z"
id="path7"
style="fill:#ffffff" /></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 4.6 KiB

View file

@ -1,100 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="25.488195"
height="36"
viewBox="0 0 6.7437517 9.5249997"
version="1.1"
id="svg1"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
sodipodi:docname="application_pdf.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="16"
inkscape:cx="8.53125"
inkscape:cy="17.8125"
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(-143.75929,-126.57004)"><title
id="title1">file-outline</title><g
id="g5"
transform="matrix(0.59530539,0,0,0.59530539,142.96121,126.57024)"><g
transform="matrix(0.04589,0,0,0.04589,-0.66877,-0.73379)"
id="g1">
<polygon
points="51.791,356.65 51.791,23.99 204.5,23.99 282.65,102.07 282.65,356.65 "
fill="#ffffff"
stroke-width="212.65"
id="polygon1" />
<path
d="m 201.19,31.99 73.46,73.393 v 243.26 H 59.79 V 31.983 h 141.4 m 6.623,-16 H 43.793 v 348.66 h 246.85 v -265.9 z"
stroke-width="21.791"
id="path1-8" />
</g><g
transform="matrix(0.04589,0,0,0.04589,-0.66877,-0.73379)"
id="g2">
<polygon
points="206.31,25.8 206.31,100.33 280.9,100.33 282.65,102.07 282.65,356.65 51.791,356.65 51.791,23.99 204.5,23.99 "
fill="#ffffff"
stroke-width="212.65"
id="polygon2" />
<path
d="m 198.31,31.99 v 76.337 h 76.337 v 240.32 H 59.787 V 31.987 h 138.52 m 9.5,-16 H 43.787 v 348.66 h 246.85 v -265.9 l -6.43,-6.424 H 214.3 V 22.481 Z"
stroke-width="21.791"
id="path2" />
</g><g
transform="matrix(0.04589,0,0,0.04589,-0.66877,-0.73379)"
stroke-width="21.791"
id="g3">
<polygon
points="258.31,87.75 219.64,87.75 219.64,48.667 258.31,86.38 "
id="polygon3" />
<path
d="M 227.64,67.646 240.05,79.75 H 227.64 V 67.646 M 222.638,40.417 H 211.64 V 95.75 h 54.666 V 83.008 Z"
id="path3" />
</g><g
transform="matrix(0.04589,0,0,0.04589,-0.66877,-0.73379)"
fill="#ed1c24"
stroke-width="212.65"
id="g4">
</g><rect
x="1.7219"
y="7.9544001"
width="10.684"
height="4.0307002"
fill="none"
id="rect4" /><g
transform="matrix(0.04589,0,0,0.04589,1.7219,11.733)"
fill="#000000"
stroke-width="21.791"
aria-label="PDF"
id="g7"><path
d="M 9.216,0 V -83.2 H 39.68 q 6.784,0 12.928,1.408 6.144,1.28 10.752,4.608 4.608,3.2 7.296,8.576 2.816,5.248 2.816,13.056 0,7.68 -2.816,13.184 -2.688,5.504 -7.296,9.088 -4.608,3.456 -10.624,5.248 -6.016,1.664 -12.544,1.664 h -8.96 V 0 Z m 22.016,-43.776 h 7.936 q 6.528,0 9.6,-3.072 3.2,-3.072 3.2,-8.704 0,-5.632 -3.456,-7.936 -3.456,-2.304 -9.856,-2.304 h -7.424 z"
id="path5"
style="fill:#ffffff" /><path
d="m 87.04,0 v -83.2 h 24.576 q 9.472,0 17.28,2.304 7.936,2.304 13.568,7.296 5.632,4.992 8.704,12.8 3.2,7.808 3.2,18.816 0,11.008 -3.072,18.944 -3.072,7.936 -8.704,13.056 -5.504,5.12 -13.184,7.552 Q 121.856,0 112.896,0 Z m 22.016,-17.664 h 1.28 q 4.48,0 8.448,-1.024 3.968,-1.152 6.784,-3.84 2.944,-2.688 4.608,-7.424 1.664,-4.736 1.664,-12.032 0,-7.296 -1.664,-11.904 -1.664,-4.608 -4.608,-7.168 -2.816,-2.56 -6.784,-3.456 -3.968,-1.024 -8.448,-1.024 h -1.28 z"
id="path6"
style="fill:#ffffff" /></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

View file

@ -1,49 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="23.999962"
height="24"
viewBox="0 0 6.3499898 6.35"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)"
sodipodi:docname="icon_back.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="0.81067621"
inkscape:cx="11.718612"
inkscape:cy="12.335381"
inkscape:window-width="1916"
inkscape:window-height="1161"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-101.82501,-145.32499)">
<title
id="title1">arrow-left-circle</title>
<path
d="m 101.82501,148.49999 a 3.1749998,3.1749998 0 0 1 3.175,-3.175 3.1749998,3.1749998 0 0 1 3.17499,3.175 3.1749998,3.1749998 0 0 1 -3.17499,3.175 3.1749998,3.1749998 0 0 1 -3.175,-3.175 m 5.08,-0.3175 H 104.365 l 1.11125,-1.11125 -0.45084,-0.45085 -1.87961,1.8796 1.87961,1.8796 0.45084,-0.45085 -1.11125,-1.11125 h 2.54001 z"
id="path1"
style="stroke-width:0.3175" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View file

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

Before

Width:  |  Height:  |  Size: 3.1 KiB

View file

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

Before

Width:  |  Height:  |  Size: 3.2 KiB

View file

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

Before

Width:  |  Height:  |  Size: 2.1 KiB

View file

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

Before

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -1,71 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="24"
height="20.000013"
viewBox="0 0 6.3499998 5.2916702"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)"
sodipodi:docname="icon_home.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="34.879392"
inkscape:cx="11.797797"
inkscape:cy="8.3717056"
inkscape:window-width="1916"
inkscape:window-height="1161"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1"
showgrid="true">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="0.26458333"
spacingy="0.26458333"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="5"
enabled="true"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-102.39375,-146.31458)">
<title
id="title1">home-outline</title>
<path
id="path1"
style="stroke-width:0.264583;stroke-dasharray:none;fill:#666666"
d="m 105.56875,146.31458 -3.175,2.64583 0.79375,0 v 2.64584 l 2.11667,0 v -1.5875 h 0.26458 v -0.52917 H 104.775 v 1.5875 l -1.05833,0 0,-2.64583 1.85208,-1.5875 z"
sodipodi:nodetypes="cccccccccccccc" />
<path
id="path2"
style="stroke-width:0.264583;stroke-dasharray:none;fill:#666666"
d="m 105.56875,146.31458 3.175,2.64583 -0.79375,0 v 2.64584 l -2.11667,0 v -1.5875 h -0.26458 v -0.52917 h 0.79375 v 1.5875 l 1.05833,0 0,-2.64583 -1.85208,-1.5875 z"
sodipodi:nodetypes="cccccccccccccc" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View file

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

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Before After
Before After

View file

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

Before

Width:  |  Height:  |  Size: 1.8 KiB

View file

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

Before

Width:  |  Height:  |  Size: 2 KiB

View file

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

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Before After
Before After

View file

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

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Before After
Before After

View file

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

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Before After
Before After

View file

@ -1,49 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="23.350698"
height="23.999699"
viewBox="0 0 6.1782055 6.3499202"
version="1.1"
id="svg1"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
sodipodi:docname="icon_settings.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="1"
inkscape:cx="-144.5"
inkscape:cy="-132"
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(-146.12156,-110.33135)">
<title
id="title1">cog-outline</title>
<path
d="m 149.21069,112.23625 a 1.2700039,1.2700039 0 0 1 1.27001,1.27 1.2700039,1.2700039 0 0 1 -1.27001,1.27001 1.2700039,1.2700039 0 0 1 -1.27,-1.27001 1.2700039,1.2700039 0 0 1 1.27,-1.27 m 0,0.63501 a 0.63500199,0.63500199 0 0 0 -0.635,0.63499 0.63500199,0.63500199 0 0 0 0.635,0.63501 0.63500199,0.63500199 0 0 0 0.635,-0.63501 0.63500199,0.63500199 0 0 0 -0.635,-0.63499 m -0.635,3.81001 c -0.0794,0 -0.14605,-0.0571 -0.15875,-0.13336 l -0.11748,-0.84137 c -0.20002,-0.0793 -0.37147,-0.18733 -0.53658,-0.31433 l -0.79057,0.32068 c -0.0698,0.0254 -0.15557,0 -0.19367,-0.0698 l -0.63501,-1.09855 c -0.0413,-0.0699 -0.0222,-0.15557 0.038,-0.2032 l 0.66993,-0.52706 -0.0222,-0.30798 0.0222,-0.31749 -0.66993,-0.51753 c -0.0604,-0.0476 -0.0793,-0.13336 -0.038,-0.2032 l 0.63501,-1.09855 c 0.0382,-0.0698 0.12382,-0.0984 0.19367,-0.0698 l 0.79057,0.3175 c 0.16511,-0.12382 0.33656,-0.23177 0.53658,-0.31115 l 0.11748,-0.84137 c 0.0127,-0.0762 0.0793,-0.13336 0.15875,-0.13336 h 1.27 c 0.0793,0 0.14606,0.0571 0.15875,0.13336 l 0.11748,0.84137 c 0.20003,0.0794 0.37148,0.18733 0.53657,0.31115 l 0.79059,-0.3175 c 0.0698,-0.0286 0.15557,0 0.19367,0.0698 l 0.635,1.09855 c 0.0413,0.0698 0.0222,0.15557 -0.0382,0.2032 l -0.66992,0.51753 0.0222,0.31749 -0.0222,0.31751 0.66992,0.51753 c 0.0604,0.0476 0.0793,0.13334 0.0382,0.2032 l -0.635,1.09855 c -0.0382,0.0698 -0.12383,0.0984 -0.19367,0.0698 l -0.79059,-0.31751 c -0.16509,0.12383 -0.33654,0.23178 -0.53657,0.31116 l -0.11748,0.84137 c -0.0127,0.0762 -0.0793,0.13336 -0.15875,0.13336 h -1.27 m 0.39688,-5.71502 -0.11748,0.82868 c -0.381,0.0794 -0.71755,0.28257 -0.96203,0.56515 l -0.76517,-0.3302 -0.23813,0.41275 0.66993,0.49212 c -0.127,0.37149 -0.127,0.77471 0,1.143 l -0.67311,0.49531 0.23813,0.41275 0.77153,-0.33019 c 0.24448,0.27939 0.57785,0.48259 0.95567,0.55879 l 0.11748,0.83185 h 0.48261 l 0.11748,-0.82867 c 0.37783,-0.0793 0.7112,-0.28258 0.95568,-0.56197 l 0.77153,0.33019 0.23812,-0.41275 -0.6731,-0.49213 c 0.127,-0.37147 0.127,-0.77469 0,-1.14618 l 0.66993,-0.49212 -0.23813,-0.41275 -0.76518,0.3302 c -0.24447,-0.28258 -0.58102,-0.48577 -0.96202,-0.56197 l -0.11748,-0.83186 z"
id="path1"
style="stroke-width:0.317501;fill:#000000;fill-opacity:1" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -1,49 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="21.333317"
height="24"
viewBox="0 0 5.64444 6.35"
version="1.1"
id="svg1"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
sodipodi:docname="icon_storage.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="1"
inkscape:cx="-56"
inkscape:cy="-9.5"
inkscape:window-width="2190"
inkscape:window-height="1401"
inkscape:window-x="1463"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-288.92499,-115.8875)">
<title
id="title1">database</title>
<path
d="m 291.74722,115.8875 c -1.55928,0 -2.82223,0.63146 -2.82223,1.4111 0,0.77964 1.26295,1.41112 2.82223,1.41112 1.55927,0 2.82221,-0.63148 2.82221,-1.41112 0,-0.77964 -1.26294,-1.4111 -2.82221,-1.4111 m -2.82223,2.11666 v 1.05834 c 0,0.77964 1.26295,1.4111 2.82223,1.4111 1.55927,0 2.82221,-0.63146 2.82221,-1.4111 v -1.05834 c 0,0.77964 -1.26294,1.41111 -2.82221,1.41111 -1.55928,0 -2.82223,-0.63147 -2.82223,-1.41111 m 0,1.7639 v 1.05833 c 0,0.77964 1.26295,1.41111 2.82223,1.41111 1.55927,0 2.82221,-0.63147 2.82221,-1.41111 v -1.05833 c 0,0.77964 -1.26294,1.4111 -2.82221,1.4111 -1.55928,0 -2.82223,-0.63146 -2.82223,-1.4111 z"
id="path1"
style="stroke-width:0.264583" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2 KiB

View file

@ -1,49 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="7.1437349mm"
height="6.3499999mm"
viewBox="0 0 7.1437349 6.3499999"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)"
sodipodi:docname="icon_table.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="0.81067621"
inkscape:cx="69.078134"
inkscape:cy="155.4258"
inkscape:window-width="1916"
inkscape:window-height="1161"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-86.666022,-107.47612)">
<title
id="title1">table</title>
<path
d="m 87.459775,107.47612 h 5.556243 a 0.79374826,0.79374826 0 0 1 0.793739,0.79375 v 4.7625 a 0.79374826,0.79374826 0 0 1 -0.793739,0.79375 h -5.556243 a 0.79374826,0.79374826 0 0 1 -0.793753,-0.79375 v -4.7625 a 0.79374826,0.79374826 0 0 1 0.793753,-0.79375 m 0,1.5875 v 1.58749 h 2.381245 v -1.58749 h -2.381245 m 3.174998,0 v 1.58749 h 2.381245 v -1.58749 h -2.381245 m -3.174998,2.38125 v 1.5875 h 2.381245 v -1.5875 h -2.381245 m 3.174998,0 v 1.5875 h 2.381245 v -1.5875 z"
id="path1"
style="stroke-width:0.396873" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View file

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

Before

Width:  |  Height:  |  Size: 2 KiB

View file

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

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

View file

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

Before

Width:  |  Height:  |  Size: 3.3 KiB

View file

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

Before

Width:  |  Height:  |  Size: 3.2 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

View file

@ -1,69 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="28.593159"
height="20"
viewBox="0 0 7.5652731 5.2916666"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)"
sodipodi:docname="logo_small.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="8.2386085"
inkscape:cx="48.491198"
inkscape:cy="5.219328"
inkscape:window-width="1916"
inkscape:window-height="1161"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-126.73541,-178.59375)">
<rect
style="fill:#a02c2c;stroke:none;stroke-width:0.113818"
id="rect5"
width="7.5652733"
height="5.2916665"
x="126.73541"
y="178.59375"
ry="1.0060203" />
<path
d="m 114.29355,207.99469 h -0.57869 l -0.72335,-1.79391 v 1.79391 h -0.49188 v -3.03326 h 0.50153 l 0.79568,1.99645 v -1.99645 h 0.49671 z m 2.34365,-0.50635 v 0.50635 h -1.85178 v -0.99823 q 0,-0.78122 0.72817,-0.78122 h 0.48224 q 0.16396,-0.005 0.16396,-0.18325 v -0.40507 q 0,-0.17361 -0.13985,-0.17361 h -0.64137 q -0.0964,0 -0.0964,0.10609 v 0.21701 h -0.4967 v -0.14949 q 0,-0.67996 0.59797,-0.67996 h 0.59797 q 0.65584,0 0.65584,0.63655 v 0.43402 q 0,0.33756 -0.15431,0.52081 h 0.005 q -0.15432,0.18325 -0.6028,0.18325 h -0.40507 q -0.19772,0 -0.19772,0.25076 v 0.51599 z"
id="text5"
style="font-weight:bold;font-size:4.82235px;font-family:'Forgotten Futurist';-inkscape-font-specification:'Forgotten Futurist, Bold';fill:#ffffff;stroke-width:0.40186"
transform="scale(1.1392149,0.87779751)"
aria-label="N2" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:4.82235px;font-family:'Forgotten Futurist';-inkscape-font-specification:'Forgotten Futurist, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;writing-mode:lr-tb;direction:ltr;fill:#ffffff;stroke:none;stroke-width:0.40186"
x="112.25369"
y="213.81186"
id="text1"
transform="scale(1.1392149,0.87779751)"><tspan
sodipodi:role="line"
id="tspan1"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:4.82235px;font-family:'Forgotten Futurist';-inkscape-font-specification:'Forgotten Futurist, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ffffff;stroke:none;stroke-width:0.40186"
x="112.25369"
y="213.81186">N2</tspan></text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -1,7 +1,7 @@
export class API {
// query resolves into the JSON data produced by the application, or an exception with 'type' and 'error' properties.
static async query(method, path, request) {
try {
return new Promise((resolve, reject) => {
const body = JSON.stringify(request)
const headers = {}
@ -12,65 +12,33 @@ export class API {
headers.Authorization = `Bearer ${token}`
}
const res = await fetch(path, { method, headers, body })
fetch(path, { method, headers, body })
.then(response => {
// An HTTP communication level error occured.
if (!res.ok || res.status != 200)
throw new Error('HTTP error', { cause: { type: 'http', error: res, } })
if (!response.ok || response.status != 200)
return reject({
type: 'http',
error: response,
})
return response.json()
})
.then(json => {
// Application level response are handled here.
const json = await res.json()
if (!json.OK)
throw new Error(json.Error, { cause: { type: 'application', application: json, } })
return json
} catch (err) {
return reject({
type: 'application',
error: json.Error,
application: json,
})
resolve(json)
})
.catch(err =>
// Catch any other errors from fetch.
throw new Error(err.message, { cause: { type: 'http', error: err, } })
}
}
// Sends a block of binary data to server.
static async upload(uuid, data, sentBytes, metadata) {
try {
const path = `/sync/file/upload/${uuid}`
const headers = {
'X-File-Sent-Bytes': sentBytes,
'X-File-Total-Bytes': metadata.size,
}
// Metadata is needed for the database.
// No need to send it every block.
if (sentBytes === 0) {
headers['X-File-Name'] = metadata.name
headers['X-File-Size'] = metadata.size
headers['X-File-Type'] = metadata.type
headers['X-File-Modified'] = metadata.modified
}
// Authentication is done with a bearer token.
// Here provided to the backend if set.
const token = localStorage.getItem('token')
if (token) {
headers.Authorization = `Bearer ${token}`
}
const res = await fetch(path, { method: 'POST', headers, body: data })
// An HTTP communication level error occured.
if (!res.ok || res.status != 200)
throw new Error('HTTP error', { cause: { type: 'http', error: res, } })
// Application level response are handled here.
const json = await res.json()
if (!json.OK)
throw new Error(json.Error, { cause: { type: 'application', application: json, } })
return json
} catch (err) {
// Catch any other errors from fetch.
console.error(err)
throw new Error(err, { cause: { type: 'http', error: err } })
}
reject({
type: 'http',
error: err,
}))
})
}
static hasAuthenticationToken() {//{{{

View file

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

72
static/js/crypto.mjs Normal file
View file

@ -0,0 +1,72 @@
export default class Crypto {
constructor(key) {//{{{
if(key === null)
throw new Error("No key provided")
if(typeof key === 'string')
this.key = sjcl.codec.base64.toBits(base64_key)
else
this.key = key
this.aes = new sjcl.cipher.aes(this.key)
}//}}}
static generate_key() {//{{{
return sjcl.random.randomWords(8)
}//}}}
static pass_to_key(pass, salt = null) {//{{{
if(salt === null)
salt = sjcl.random.randomWords(4) // 128 bits (16 bytes)
let key = sjcl.misc.pbkdf2(pass, salt, 10000)
return {
salt,
key,
}
}//}}}
encrypt(plaintext_data_in_bits, counter, return_encoded = true) {//{{{
// 8 bytes of random data, (1 word = 4 bytes) * 2
// with 8 bytes of byte encoded counter is used as
// IV to guarantee a non-repeated IV (which is a catastrophe).
// Assumes counter value is kept unique. Counter is taken from
// Postgres sequence.
let random_bits = sjcl.random.randomWords(2)
let iv_bytes = sjcl.codec.bytes.fromBits(random_bits)
for (let i = 0; i < 8; ++i) {
let mask = 0xffn << BigInt(i*8)
let counter_i_byte = (counter & mask) >> BigInt(i*8)
iv_bytes[15-i] = Number(counter_i_byte)
}
let iv = sjcl.codec.bytes.toBits(iv_bytes)
let encrypted = sjcl.mode['ccm'].encrypt(
this.aes,
plaintext_data_in_bits,
iv,
)
// Returning 16 bytes (4 words) IV + encrypted data.
if(return_encoded)
return sjcl.codec.base64.fromBits(
iv.concat(encrypted)
)
else
return iv.concat(encrypted)
}//}}}
decrypt(encrypted_base64_data) {//{{{
try {
let encoded = sjcl.codec.base64.toBits(encrypted_base64_data)
let iv = encoded.slice(0, 4) // in words (4 bytes), not bytes
let encrypted_data = encoded.slice(4)
return sjcl.mode['ccm'].decrypt(this.aes, encrypted_data, iv)
} catch(err) {
if(err.message == `ccm: tag doesn't match`)
throw('Decryption failed')
else
throw(err)
}
}//}}}
}
// vim: foldmethod=marker

View file

@ -1,158 +0,0 @@
import { CustomHTMLElement } from "./lib/custom_html_element.mjs";
import { API } from 'api'
export class N2File extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<style>
:host {
display: inline-grid;
grid-template-columns: min-content min-content;
align-items: end;
white-space: nowrap;
cursor: pointer;
.el-image {
max-width: var(--thumbnail-width);
max-height: var(--thumbnail-height);
}
.el-filename {
display: none;
font-weight: bold;
background-color: #ddd;
border-radius: 4px;
padding: 4px 8px;
margin-left: 8px;
}
}
</style>
<img data-el="image" src="/images/${_VERSION}/file_icons/generic.svg">
<div data-el="filename"></div>
`
}// }}}
constructor() {// {{{
super(true)
this.streamLocalFile = false
this.uuid = null
this.addEventListener('click', event => {
event.preventDefault()
event.stopPropagation()
// Download to disk.
if (event.shiftKey) {
this.downloadToDisk()
return
}
// Open in browser.
this.openInBrowser(event)
})
this.render()
}// }}}
openInBrowser(event) {// {{{
if (this.streamLocalFile)
window.open(
`/stream-local?uuid=${this.metadata.UUID}`,
event.ctrlKey ? '_blank' : '_self',
)
else {
const jwt = localStorage.getItem('token')
window.open(
`/file/uuid/${this.uuid}?jwt=${jwt}`,
event.ctrlKey ? '_blank' : '_self',
)
}
}// }}}
async downloadToDisk() {// {{{
try {
// TODO: implement download of remote content.
if (this.streamLocalFile === false)
throw new Error('This file is only available online. Download not implemented yet.')
const handle = await window.showSaveFilePicker({
suggestedName: this.metadata.name,
})
const writable = await handle.createWritable()
const blobStream = await globalThis.nodeStore.fileSegments.getStream(this.uuid)
await blobStream.pipeTo(writable)
} catch (err) {
if (err.name == 'AbortError')
return
console.error(err)
alert(err.message)
}
}// }}}
async render() {// {{{
const src = this.getAttribute('src')
// N2's db:// URLs are fetched from IndexedDB.
if (src.toLowerCase().match('^db://[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')) {
// image population has to happen asynchronously,
// while Marked lib has to be returned a string when exiting this function.
// populateImg makes sure this returned img element exists and then populates it
// with the image from IndexedDB.
const fileUUID = src.slice(5)
this.uuid = fileUUID
const md = await globalThis.nodeStore.filesMetadata.get(fileUUID)
if (md) {
this.metadata = md
this.streamLocalFile = true
this.renderLocalFile(md)
} else {
/* <file> can be undefined when a node is retrieved but files aren't synced.
* Try to show from server.
*
* Metadata is needed to know whether to display an image or the file representation. */
try {
const res = await API.query('GET', `/file/uuid/${fileUUID}/metadata`)
this.renderRemoteFile(res.Metadata)
} catch (err) {
console.error(err)
}
}
} else
// Probably an external URL.
this.elImage.src = src
}// }}}
async renderLocalFile(fileMetadata) {// {{{
if (fileMetadata.type.startsWith('image/'))
this.elImage.src = `/stream-local?uuid=${fileMetadata.UUID}`
else {
// Check for and use an existing MIME type icon.
// Place them in static/images/file_icons/ and replace the slash with an underscore.
const url = `/images/${_VERSION}/file_icons/${fileMetadata.type.replaceAll('/', '_')}.svg`
const res = await fetch(url)
if (res.ok)
this.elImage.src = url
this.elFilename.innerText = fileMetadata.name
this.elFilename.style.display = 'block'
}
}// }}}
async renderRemoteFile(md) {// {{{
if (md.Type.startsWith('image/')) {
const jwt = localStorage.getItem('token')
this.elImage.src = `/file/uuid/${md.UUID}?jwt=${jwt}`
} else {
// Check for and use an existing MIME type icon.
// Place them in static/images/file_icons/ and replace the slash with an underscore.
const url = `/images/${_VERSION}/file_icons/${md.Type.replaceAll('/', '_')}.svg`
const res = await fetch(url)
if (res.ok)
this.elImage.src = url
this.elFilename.innerText = md.Name
this.elFilename.style.display = 'block'
}
}// }}}
}
customElements.define('n2-file', N2File)

240
static/js/key.mjs Normal file
View file

@ -0,0 +1,240 @@
import { h, Component } from 'preact'
import htm from 'htm'
import Crypto from 'crypto'
const html = htm.bind(h)
export class Keys extends Component {
constructor(props) {//{{{
super(props)
this.state = {
create: false,
}
props.nodeui.retrieveKeys()
}//}}}
render({ nodeui }, { create }) {//{{{
let keys = nodeui.keys.value
.sort((a,b)=>{
if(a.description < b.description) return -1
if(a.description > b.description) return 1
return 0
})
.map(key=>
html`<${KeyComponent} key=${`key-${key.ID}`} model=${key} />`
)
let createButton = ''
let createComponents = ''
if(create) {
createComponents = html`
<div id="key-create">
<h2>New key</h2>
<div class="fields">
<input type="text" id="key-description" placeholder="Name" />
<input type="password" id="key-pass1" placeholder="Password" />
<input type="password" id="key-pass2" placeholder="Repeat password" />
<textarea id="key-key" placeholder="Key"></textarea>
<div>
<button class="generate" onclick=${()=>this.generateKey()}>Generate</button>
<button class="create" onclick=${()=>this.createKey()}>Create</button>
</div>
</div>
</div>
`
} else {
createButton = html`<div style="margin-top: 16px;"><button onclick=${()=>this.setState({ create: true })}>Create new key</button></div>`
}
return html`
<div id="keys">
<h1>Encryption keys</h1>
<p>
Unlock a key by clicking its name. Lock it by clicking it again.
</p>
<p>
Copy the key and store it in a very secure place to have a way to access notes
in case the password is forgotten, or database is corrupted.
</p>
<p>Click "View key" after unlocking it.</p>
${createButton}
${createComponents}
<h2>Keys</h2>
<div class="key-list">
${keys}
</div>
</div>`
}//}}}
generateKey() {//{{{
let keyTextarea = document.getElementById('key-key')
let key = sjcl.codec.hex.fromBits(Crypto.generate_key()).replace(/(....)/g, '$1 ').trim()
keyTextarea.value = key
}//}}}
validateNewKey() {//{{{
let keyDescription = document.getElementById('key-description').value
let keyTextarea = document.getElementById('key-key').value
let pass1 = document.getElementById('key-pass1').value
let pass2 = document.getElementById('key-pass2').value
if(keyDescription.trim() == '')
throw new Error('The key has to have a description')
if(pass1.trim() == '' || pass1.length < 4)
throw new Error('The password has to be at least 4 characters long.')
if(pass1 != pass2)
throw new Error(`Passwords doesn't match`)
let cleanKey = keyTextarea.replace(/\s+/g, '')
if(!cleanKey.match(/^[0-9a-f]{64}$/i))
throw new Error('Invalid key - has to be 64 characters of 0-9 and A-F')
}//}}}
createKey() {//{{{
try {
this.validateNewKey()
let description = document.getElementById('key-description').value
let keyAscii = document.getElementById('key-key').value
let pass1 = document.getElementById('key-pass1').value
// Key in hex taken from user.
let actual_key = sjcl.codec.hex.toBits(keyAscii.replace(/\s+/g, ''))
// Key generated from password, used to encrypt the actual key.
let pass_gen = Crypto.pass_to_key(pass1)
let crypto = new Crypto(pass_gen.key)
let encrypted_actual_key = crypto.encrypt(actual_key, 0x1n, false)
// Database value is salt + actual key, needed to generate the same key from the password.
let db_encoded = sjcl.codec.hex.fromBits(
pass_gen.salt.concat(encrypted_actual_key)
)
// Create on server.
window._app.current.request('/key/create', {
description,
key: db_encoded,
})
.then(res=>{
let key = new Key(res.Key, this.props.nodeui.keyCounter)
this.props.nodeui.keys.value = this.props.nodeui.keys.value.concat(key)
})
.catch(window._app.current.responseError)
} catch(err) {
alert(err.message)
return
}
}//}}}
}
export class Key {
constructor(data, counter_callback) {//{{{
this.ID = data.ID
this.description = data.Description
this.encryptedKey = data.Key
this.key = null
this._counter_cbk = counter_callback
let hex_key = window.sessionStorage.getItem(`key-${this.ID}`)
if(hex_key)
this.key = sjcl.codec.hex.toBits(hex_key)
}//}}}
status() {//{{{
if(this.key === null)
return 'locked'
return 'unlocked'
}//}}}
lock() {//{{{
this.key = null
window.sessionStorage.removeItem(`key-${this.ID}`)
}//}}}
unlock(password) {//{{{
let db = sjcl.codec.hex.toBits(this.encryptedKey)
let salt = db.slice(0, 4)
let pass_key = Crypto.pass_to_key(password, salt)
let crypto = new Crypto(pass_key.key)
this.key = crypto.decrypt(sjcl.codec.base64.fromBits(db.slice(4)))
window.sessionStorage.setItem(`key-${this.ID}`, sjcl.codec.hex.fromBits(this.key))
}//}}}
async counter() {//{{{
return this._counter_cbk()
}//}}}
}
export class KeyComponent extends Component {
constructor({ model }) {//{{{
super({ model })
this.state = {
show_key: false,
}
}//}}}
render({ model }, { show_key }) {//{{{
let status = ''
switch(model.status()) {
case 'locked':
status = html`<div class="status locked"><img src="/images/${window._VERSION}/padlock-closed.svg" /></div>`
break
case 'unlocked':
status = html`<div class="status unlocked"><img src="/images/${window._VERSION}/padlock-open.svg" /></div>`
break
}
let hex_key = ''
if(show_key) {
if(model.status() == 'locked')
hex_key = html`<div class="hex-key">Unlock key first</div>`
else {
let key = sjcl.codec.hex.fromBits(model.key)
key = key.replace(/(....)/g, "$1 ").trim()
hex_key = html`<div class="hex-key">${key}</div>`
}
}
let unlocked = model.status()=='unlocked'
return html`
<div class="status" onclick=${()=>this.toggle()}>${status}</div>
<div class="description" onclick=${()=>this.toggle()}>${model.description}</div>
<div class="view" onclick=${()=>this.toggleViewKey()}>${unlocked ? 'View key' : ''}</div>
${hex_key}
`
}//}}}
toggle() {//{{{
if(this.props.model.status() == 'locked')
this.unlock()
else
this.lock()
}//}}}
lock() {//{{{
this.props.model.lock()
this.forceUpdate()
}//}}}
unlock() {//{{{
let pass = prompt("Password")
if(!pass)
return
try {
this.props.model.unlock(pass)
this.forceUpdate()
} catch(err) {
alert(err)
}
}//}}}
toggleViewKey() {//{{{
this.setState({ show_key: !this.state.show_key })
}//}}}
}
// vim: foldmethod=marker

View file

@ -1,207 +0,0 @@
export class Color {
constructor(r, g, b) { this.set(r, g, b); }
toString() { return `rgb(${Math.round(this.r)}, ${Math.round(this.g)}, ${Math.round(this.b)})`; }
set(r, g, b) {
this.r = this.clamp(r);
this.g = this.clamp(g);
this.b = this.clamp(b);
}
hueRotate(angle = 0) {
angle = angle / 180 * Math.PI;
let sin = Math.sin(angle);
let cos = Math.cos(angle);
this.multiply([
0.213 + cos * 0.787 - sin * 0.213, 0.715 - cos * 0.715 - sin * 0.715, 0.072 - cos * 0.072 + sin * 0.928,
0.213 - cos * 0.213 + sin * 0.143, 0.715 + cos * 0.285 + sin * 0.140, 0.072 - cos * 0.072 - sin * 0.283,
0.213 - cos * 0.213 - sin * 0.787, 0.715 - cos * 0.715 + sin * 0.715, 0.072 + cos * 0.928 + sin * 0.072
]);
}
grayscale(value = 1) {
this.multiply([
0.2126 + 0.7874 * (1 - value), 0.7152 - 0.7152 * (1 - value), 0.0722 - 0.0722 * (1 - value),
0.2126 - 0.2126 * (1 - value), 0.7152 + 0.2848 * (1 - value), 0.0722 - 0.0722 * (1 - value),
0.2126 - 0.2126 * (1 - value), 0.7152 - 0.7152 * (1 - value), 0.0722 + 0.9278 * (1 - value)
]);
}
sepia(value = 1) {
this.multiply([
0.393 + 0.607 * (1 - value), 0.769 - 0.769 * (1 - value), 0.189 - 0.189 * (1 - value),
0.349 - 0.349 * (1 - value), 0.686 + 0.314 * (1 - value), 0.168 - 0.168 * (1 - value),
0.272 - 0.272 * (1 - value), 0.534 - 0.534 * (1 - value), 0.131 + 0.869 * (1 - value)
]);
}
saturate(value = 1) {
this.multiply([
0.213 + 0.787 * value, 0.715 - 0.715 * value, 0.072 - 0.072 * value,
0.213 - 0.213 * value, 0.715 + 0.285 * value, 0.072 - 0.072 * value,
0.213 - 0.213 * value, 0.715 - 0.715 * value, 0.072 + 0.928 * value
]);
}
multiply(matrix) {
let newR = this.clamp(this.r * matrix[0] + this.g * matrix[1] + this.b * matrix[2]);
let newG = this.clamp(this.r * matrix[3] + this.g * matrix[4] + this.b * matrix[5]);
let newB = this.clamp(this.r * matrix[6] + this.g * matrix[7] + this.b * matrix[8]);
this.r = newR; this.g = newG; this.b = newB;
}
brightness(value = 1) { this.linear(value); }
contrast(value = 1) { this.linear(value, -(0.5 * value) + 0.5); }
linear(slope = 1, intercept = 0) {
this.r = this.clamp(this.r * slope + intercept * 255);
this.g = this.clamp(this.g * slope + intercept * 255);
this.b = this.clamp(this.b * slope + intercept * 255);
}
invert(value = 1) {
this.r = this.clamp((value + (this.r / 255) * (1 - 2 * value)) * 255);
this.g = this.clamp((value + (this.g / 255) * (1 - 2 * value)) * 255);
this.b = this.clamp((value + (this.b / 255) * (1 - 2 * value)) * 255);
}
hsl() { // Code taken from https://stackoverflow.com/a/9493060/2688027, licensed under CC BY-SA.
let r = this.r / 255;
let g = this.g / 255;
let b = this.b / 255;
let max = Math.max(r, g, b);
let min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
let d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
} h /= 6;
}
return {
h: h * 100,
s: s * 100,
l: l * 100
};
}
clamp(value) {
if (value > 255) { value = 255; }
else if (value < 0) { value = 0; }
return value;
}
}
export class Solver {
constructor(target) {
this.target = target;
this.targetHSL = target.hsl();
this.reusedColor = new Color(0, 0, 0); // Object pool
}
solve() {
let result = this.solveNarrow(this.solveWide());
return {
values: result.values,
loss: result.loss,
filter: this.css(result.values)
};
}
solveWide() {
const A = 5;
const c = 15;
const a = [60, 180, 18000, 600, 1.2, 1.2];
let best = { loss: Infinity };
for (let i = 0; best.loss > 25 && i < 3; i++) {
let initial = [50, 20, 3750, 50, 100, 100];
let result = this.spsa(A, a, c, initial, 1000);
if (result.loss < best.loss) { best = result; }
} return best;
}
solveNarrow(wide) {
const A = wide.loss;
const c = 2;
const A1 = A + 1;
const a = [0.25 * A1, 0.25 * A1, A1, 0.25 * A1, 0.2 * A1, 0.2 * A1];
return this.spsa(A, a, c, wide.values, 500);
}
spsa(A, a, c, values, iters) {
const alpha = 1;
const gamma = 0.16666666666666666;
let best = null;
let bestLoss = Infinity;
let deltas = new Array(6);
let highArgs = new Array(6);
let lowArgs = new Array(6);
for (let k = 0; k < iters; k++) {
let ck = c / Math.pow(k + 1, gamma);
for (let i = 0; i < 6; i++) {
deltas[i] = Math.random() > 0.5 ? 1 : -1;
highArgs[i] = values[i] + ck * deltas[i];
lowArgs[i] = values[i] - ck * deltas[i];
}
let lossDiff = this.loss(highArgs) - this.loss(lowArgs);
for (let i = 0; i < 6; i++) {
let g = lossDiff / (2 * ck) * deltas[i];
let ak = a[i] / Math.pow(A + k + 1, alpha);
values[i] = fix(values[i] - ak * g, i);
}
let loss = this.loss(values);
if (loss < bestLoss) { best = values.slice(0); bestLoss = loss; }
} return { values: best, loss: bestLoss };
function fix(value, idx) {
let max = 100;
if (idx === 2 /* saturate */) { max = 7500; }
else if (idx === 4 /* brightness */ || idx === 5 /* contrast */) { max = 200; }
if (idx === 3 /* hue-rotate */) {
if (value > max) { value = value % max; }
else if (value < 0) { value = max + value % max; }
} else if (value < 0) { value = 0; }
else if (value > max) { value = max; }
return value;
}
}
loss(filters) { // Argument is array of percentages.
let color = this.reusedColor;
color.set(0, 0, 0);
color.invert(filters[0] / 100);
color.sepia(filters[1] / 100);
color.saturate(filters[2] / 100);
color.hueRotate(filters[3] * 3.6);
color.brightness(filters[4] / 100);
color.contrast(filters[5] / 100);
let colorHSL = color.hsl();
return Math.abs(color.r - this.target.r)
+ Math.abs(color.g - this.target.g)
+ Math.abs(color.b - this.target.b)
+ Math.abs(colorHSL.h - this.targetHSL.h)
+ Math.abs(colorHSL.s - this.targetHSL.s)
+ Math.abs(colorHSL.l - this.targetHSL.l);
}
css(filters) {
function fmt(idx, multiplier = 1) { return Math.round(filters[idx] * multiplier); }
return `invert(${fmt(0)}%) sepia(${fmt(1)}%) saturate(${fmt(2)}%) hue-rotate(${fmt(3, 3.6)}deg) brightness(${fmt(4)}%) contrast(${fmt(5)}%)`;
}
}

View file

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

6
static/js/lib/fullcalendar.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2574
static/js/lib/sjcl.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -92,77 +92,30 @@ function escapeHtmlEntities(html, encode) {// {{{
export class MarkedPosition {
constructor() {// {{{
window.marked_setpos = (event) => this.setpos(event)
window.marked_changecheckbox = (event) => this.changecheckbox(event)
window.marked_copy_to_clipboard = (event, tagname) => this.copy_to_clipboard(event, tagname)
this.render()
}// }}}
setpos(event) {// {{{
window.setpos = (event) => {
event.stopPropagation()
event.preventDefault()
_mbus.dispatch('MARKDOWN_EDIT', {
position: {
start: event.target.closest('[data-offset-start]').dataset.offsetStart,
end: event.target.closest('[data-offset-start]').dataset.offsetEnd,
start: event.target.dataset.offsetStart,
end: event.target.dataset.offsetEnd,
}
})
}// }}}
changecheckbox(event) {// {{{
event.stopPropagation()
event.preventDefault()
_mbus.dispatch('MARKDOWN_CHANGE_CHECKBOX', {
checkbox: event.target,
position: {
start: event.target.closest('[data-offset-start]').dataset.offsetStart,
end: event.target.closest('[data-offset-start]').dataset.offsetEnd,
}
})
}// }}}
async copy_to_clipboard(event, tagname) {// {{{
if (!event.shiftKey)
return
try {
// Stop text selections on the page to the mouse pointer.
// Old selections are remove as well to give a cleaner view
// of the copied text/highlighting.
event.preventDefault()
event.stopPropagation()
window.getSelection().removeAllRanges()
const text = event.target.innerText
await navigator.clipboard.writeText(text)
const tagClasslist = event.target.closest(tagname).classList
tagClasslist.add('copy')
setTimeout(()=>tagClasslist.remove('copy'), 250)
} catch (err) {
console.error('Failed to copy: ', err)
alert('Failed to copy: ', err)
}
}// }}}
render() {// {{{
this.marked = new Marked()
this.marked.use(markedTokenPosition())
this.marked.use({
renderer: {
heading(token) {
const content = this.parser.parseInline(token.tokens)
return `
<div class="heading-container" data-heading="${token.depth}">
<h${token.depth} ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${content}</h${token.depth}>\n
<div class="line"></div>\n
</div>
`
return `<h${token.depth} onclick="setpos(event)" onclick="setpos(this)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${content}</h${token.depth}>\n`
},
paragraph(token) {
const content = this.parser.parseInline(token.tokens)
return `<p ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${content}</p>\n`
return `<p onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${content}</p>\n`
},
list(token) {
@ -181,7 +134,7 @@ export class MarkedPosition {
},
listitem(token) {
return `<li ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${this.parser.parse(token.tokens)}</li>\n`
return `<li onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${this.parser.parse(token.tokens)}</li>\n`
},
code(token) {
@ -190,12 +143,12 @@ export class MarkedPosition {
const code = token.text.replace(other.endingNewline, '') + '\n'
if (!langString) {
return `<pre ondblclick="marked_setpos(event)" onmousedown="marked_copy_to_clipboard(event, 'pre')" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"><code>`
return `<pre onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"><code>`
+ (token.escaped ? code : escapeHtmlEntities(code, true))
+ '</code></pre>\n'
}
return `<pre ondblclick="marked_setpos(event)" onmousedown="marked_copy_to_clipboard(event, 'pre')" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"><code class="language-`
return `<pre onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"><code class="language-`
+ escapeHtmlEntities(langString)
+ '">'
+ (token.escaped ? code : escapeHtmlEntities(code, true))
@ -204,7 +157,7 @@ export class MarkedPosition {
blockquote(token) {
const body = this.parser.parse(token.tokens)
return `<blockquote ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">\n${body}</blockquote>\n`
return `<blockquote onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">\n${body}</blockquote>\n`
},
html(token) {
@ -216,13 +169,13 @@ export class MarkedPosition {
},
hr(token) {
return `<hr ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">\n`
return `<hr onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">\n`
},
checkbox(token) {
return `<input ondblclick="marked_setpos(event)" onchange="marked_changecheckbox(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"`
return `<input onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"`
+ (token.checked ? 'checked="" ' : '')
+ 'type="checkbox"> '
+ 'disabled="" type="checkbox"> '
},
table(token) {
@ -265,7 +218,7 @@ export class MarkedPosition {
if (token.tokens.length > 0) {
const start = token.tokens[0].position.start.offset
const end = token.tokens[0].position.end.offset
ofs = `ondblclick="marked_setpos(event)" data-offset-start="${start}" data-offset-end="${end}"`
ofs = `onclick="setpos(event)" data-offset-start="${start}" data-offset-end="${end}"`
}
const content = this.parser.parseInline(token.tokens);
@ -277,23 +230,23 @@ export class MarkedPosition {
},
strong(token) {
return `<strong ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${this.parser.parseInline(token.tokens)}</strong>`
return `<strong onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${this.parser.parseInline(token.tokens)}</strong>`
},
em(token) {
return `<em ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${this.parser.parseInline(token.tokens)}</em>`
return `<em onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${this.parser.parseInline(token.tokens)}</em>`
},
codespan(token) {
return `<code ondblclick="marked_setpos(event)" onmousedown="marked_copy_to_clipboard(event, 'code')" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${escapeHtmlEntities(token.text, true)}</code>`
return `<code onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${escapeHtmlEntities(token.text, true)}</code>`
},
br(token) {
return `<br ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">`
return `<br onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">`
},
del(token) {
return `<del ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${this.parser.parseInline(token.tokens)}</del>`
return `<del onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${this.parser.parseInline(token.tokens)}</del>`
},
link(token) {
@ -303,7 +256,7 @@ export class MarkedPosition {
return text
}
token.href = cleanHref
let out = '<a ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}" href="' + token.href + '"'
let out = '<a onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}" href="' + token.href + '"'
if (token.title) {
out += ' title="' + (escapeHtmlEntities(token.title)) + '"'
}
@ -312,6 +265,7 @@ export class MarkedPosition {
},
image(token) {
if (token.tokens) {
token.text = this.parser.parseInline(token.tokens, this.parser.textRenderer)
}
@ -320,11 +274,12 @@ export class MarkedPosition {
return escapeHtmlEntities(token.text)
}
token.href = cleanHref
let out = `<n2-file ondblclick="marked_setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}" src="${token.href}" alt="${escapeHtmlEntities(token.text)}"`
let out = `<img onclick="setpos(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}" src="${token.href}" alt="${escapeHtmlEntities(token.text)}"`
if (token.title) {
out += ` title="${escapeHtmlEntities(token.title)}"`
}
out += '></n2-file>'
out += '>'
return out
},
@ -336,34 +291,8 @@ export class MarkedPosition {
}
})
}// }}}}}}
}// }}}
parse(text) {// {{{
return this.marked.parse(text)
}// }}}
async whenElementExist(id) {// {{{
// The element could have already been created.
const element = document.getElementById(id)
if (element) {
return element
}
const observer = new MutationObserver((_mutations, observer) => {
const target = document.getElementById(id)
if (target) {
observer.disconnect()
return target
}
})
observer.observe(document.documentElement, {
childList: true,
subtree: true
})
}// }}}
async populateImg(fileID, elementID) {// {{{
let img = await globalThis.nodeStore.files.get(fileID)
const el = await this.whenElementExist(elementID)
el.src = URL.createObjectURL(img.file)
}// }}}
}

286
static/js/node.mjs Normal file
View file

@ -0,0 +1,286 @@
import { ROOT_NODE } from 'node_store'
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
import { MarkedPosition } from './marked_position.mjs'
export class N2NodeUI extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<style>
.el-functions {
display: grid;
grid-template-columns: 1fr min-content;
grid-gap: 8px;
align-items: center;
justify-items: end;
img {
height: 24px;
}
}
</style>
<div data-el="name"></div>
<textarea data-el="node-content" required rows=1></textarea>
<div data-el="node-markdown" tabindex=1></div>
<div data-el="functions">
<img data-el="icon-markdown">
<img data-el="icon-save" src="/images/${_VERSION}/icon_save_disabled.svg">
</div>
`
}// }}}
constructor() {// {{{
super()
this.node = null
this.style.display = 'contents'
this.classList.add('show-markdown') // TODO Should probably be moved to settings.
this.marked = new MarkedPosition()
_mbus.subscribe('NODE_UI_OPEN', event => {
this.node = event.detail.data
this.showMarkdown(true)
this.render()
})
_mbus.subscribe('NODE_MODIFIED', () => {
document.querySelector('#crumbs .crumbs')?.classList.add('node-modified')
this.elIconSave.src = `/images/${_VERSION}/icon_save.svg`
this.render()
})
_mbus.subscribe('NODE_UNMODIFIED', () => {
document.querySelector('#crumbs .crumbs')?.classList.remove('node-modified')
this.elIconSave.src = `/images/${_VERSION}/icon_save_disabled.svg`
})
_mbus.subscribe('MARKDOWN_TOGGLE', () => this.showMarkdown(!this.showMarkdown()))
_mbus.subscribe('MARKDOWN_EDIT', ({ detail }) => this.editMarkdown(detail.data))
this.elName.addEventListener('click', () => {
const name = prompt('Change title', this.node.data.Name)
if (name === null)
return
try {
this.node.setName(name)
} catch (err) {
console.error(err)
alert(err)
}
})
this.elNodeContent.addEventListener('input', event => this.contentChanged(event))
this.elIconMarkdown.addEventListener('click', () => this.showMarkdown(!this.showMarkdown()))
this.showMarkdown(true)
}// }}}
render() {// {{{
this.elName.innerText = this.node?.get('Name') ?? ''
this.elNodeContent.value = this.node?.get('Content') ?? ''
this.elNodeMarkdown.innerHTML = this.marked.parse(this.elNodeContent.value)
}// }}}
takeFocus() {// {{{
if (this.showMarkdown()) {
this.elNodeMarkdown.focus()
} else
this.elNodeContent.focus()
}// }}}
contentChanged(event) {//{{{
this.node.setContent(event.target.value)
}//}}}
isModified() {// {{{
return this.node?.isModified()
}// }}}
showMarkdown(state) {// {{{
// No point in showing markdown if there is no data.
// If there is no data, it will show a blank page regardless, and the user will most
// likely want to edit content, which can't be done in markdown.
const show = this.node?.content().trim() !== '' && state
switch (show) {
case true:
this.elNodeMarkdown.innerHTML = this.marked.parse(this.elNodeContent.value)
this.elIconMarkdown.src = `/images/${_VERSION}/icon_markdown.svg`
this.classList.add('show-markdown')
break
case false:
this.elIconMarkdown.src = `/images/${_VERSION}/icon_markdown_hollow.svg`
this.classList.remove('show-markdown')
break
case null:
case undefined:
return this.classList.contains('show-markdown')
}
}// }}}
editMarkdown(data) {// {{{
this.showMarkdown(false)
this.elNodeContent.selectionStart = data.position.start
this.elNodeContent.selectionEnd = data.position.end
this.elNodeContent.focus()
}// }}}
}
customElements.define('n2-nodeui', N2NodeUI)
export class Node {
static sort(a, b) {//{{{
if (a.data.Name < b.data.Name) return -1
if (a.data.Name > b.data.Name) return 0
return 0
}//}}}
static create(name, parentUUID) {// {{{
return new Node({
UUID: uuidv7(),
Created: (new Date()).toISOString(),
Content: '',
Name: name,
ParentUUID: parentUUID,
Markdown: false,
History: false,
})
}// }}}
constructor(nodeData, level) {//{{{
this.Level = level
this.data = nodeData
this.UUID = nodeData.UUID
// Toplevel nodes are normalized to have the ROOT_NODE as parent.
if (nodeData.UUID !== ROOT_NODE && nodeData.ParentUUID === '') {
this.ParentUUID = ROOT_NODE
this.data.ParentUUID = ROOT_NODE
} else
this.ParentUUID = nodeData.ParentUUID
this._children_fetched = false
this.Children = []
this.Ancestors = []
this._sibling_before = null
this._sibling_after = null
this._parent = null
this.reset()
}//}}}
reset() {// {{{
this._content = this.data.Content
this._modified = false
}// }}}
get(prop) {//{{{
return this.data[prop]
}//}}}
updated() {//{{{
// '2024-12-17T17:33:48.85939Z
return new Date(Date.parse(this.data.Updated))
}//}}}
isModified() {// {{{
return this._modified
}// }}}
hasFetchedChildren() {//{{{
return this._children_fetched
}//}}}
async fetchChildren() {//{{{
this.Children = await nodeStore.getTreeNodes(this.UUID, this.Level + 1)
this._children_fetched = true
// Children are sorted to allow for storing siblings befare and after.
// These are used with keyboard navigation in the tree.
this.Children.sort(Node.sort)
const numChildren = this.Children.length
for (let i = 0; i < numChildren; i++) {
if (i > 0)
this.Children[i]._sibling_before = this.Children[i - 1]
if (i < numChildren - 1)
this.Children[i]._sibling_after = this.Children[i + 1]
this.Children[i]._parent = this
}
// Notify the tree that all children are fetched and ready to process.
//_notes2.current.tree.fetchChildrenOn(this.UUID)
_mbus.dispatch(`NODE_CHILDREN_FETCHED_${this.UUID}`)
return this.Children
}//}}}
hasChildren() {//{{{
return this.Children.length > 0
}//}}}
getSiblingBefore() {// {{{
return this._sibling_before
}// }}}
getSiblingAfter() {// {{{
return this._sibling_after
}// }}}
getParent() {//{{{
return this._parent
}//}}}
isLastSibling() {//{{{
return this._sibling_after === null
}//}}}
isFirstSibling() {//{{{
return this._sibling_before === null
}//}}}
content() {//{{{
/* TODO - implement crypto
if (this.CryptoKeyID != 0 && !this._decrypted)
this.#decrypt()
*/
return this._content
}//}}}
setContent(new_content) {//{{{
this._content = new_content
this._modified = true
_mbus.dispatch('NODE_MODIFIED', { node: this })
}//}}}
setName(new_name) {// {{{
if (new_name.trim() === '')
throw new Error(`The name can't be empty`)
this.data.Name = new_name
this._modified = true
_mbus.dispatch('NODE_MODIFIED', { node: this })
}// }}}
async save() {//{{{
this.data.Content = this._content
this.data.Updated = new Date().toISOString()
this._modified = false
_mbus.dispatch('NODE_UNMODIFIED')
// When stored into database and ancestry was changed,
// the ancestry path could be interesting.
const ancestors = await nodeStore.getNodeAncestry(this)
this.data.Ancestors = ancestors.map(a => a.get('Name')).reverse()
}//}}}
}
function uuidv7() {
// random bytes
const value = new Uint8Array(16)
crypto.getRandomValues(value)
// current timestamp in ms
const timestamp = BigInt(Date.now())
// timestamp
value[0] = Number((timestamp >> 40n) & 0xffn)
value[1] = Number((timestamp >> 32n) & 0xffn)
value[2] = Number((timestamp >> 24n) & 0xffn)
value[3] = Number((timestamp >> 16n) & 0xffn)
value[4] = Number((timestamp >> 8n) & 0xffn)
value[5] = Number(timestamp & 0xffn)
// version and variant
value[6] = (value[6] & 0x0f) | 0x70
value[8] = (value[8] & 0x3f) | 0x80
const str = Array.from(value)
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
return `${str.slice(0, 8)}-${str.slice(8, 12)}-${str.slice(12, 16)}-${str.slice(16, 20)}-${str.slice(20)}`
}
// vim: foldmethod=marker

View file

@ -1,218 +1,10 @@
import { Node } from 'node'
export const ROOT_NODE = '00000000-0000-0000-0000-000000000000'
export const ORPHANED_NODE = '00000000-0000-0000-0000-000000000001'
export const DELETED_NODE = '00000000-0000-0000-0000-000000000002'
export class Node {
static sort(a, b) {//{{{
// Nodes with children ("folders") are sorted first.
if (a._has_children && !b._has_children) return -1
if (!a._has_children && b._has_children) return 1
// Otherwise sort by lowercased name.
const an = a.data.Name.toLowerCase()
const bn = b.data.Name.toLowerCase()
if (an < bn) return -1
if (an > bn) return 1
return 0
}//}}}
static create(name, parentUUID) {// {{{
const node = new Node({
UUID: uuidv7(),
Created: (new Date()).toISOString(),
Content: '',
Name: name,
ParentUUID: parentUUID,
Markdown: false,
})
// Newly created node (not constructed from existing data) is considered modified
// since node.save returns early if it isn't modified.
node._modified = true
return node
}// }}}
constructor(nodeData, level) {//{{{
this.Level = level
this.data = nodeData
this.UUID = nodeData.UUID
// Toplevel nodes are normalized to have the ROOT_NODE as parent.
if (nodeData.UUID !== ROOT_NODE && nodeData.ParentUUID === '') {
this.ParentUUID = ROOT_NODE
this.data.ParentUUID = ROOT_NODE
} else
this.ParentUUID = nodeData.ParentUUID
this._children_fetched = false
this._has_children = null // this will be set by nodeStore.getTreeNodes
this.Children = []
this.Ancestors = []
this._sibling_before = null
this._sibling_after = null
this._parent = null
this.reset()
}//}}}
reset() {// {{{
// this._content is the runtime content in the editor.
// this.data.Content is the original data.
this._content = this.data.Content
this._modified = false
}// }}}
get(prop) {//{{{
return this.data[prop]
}//}}}
updated() {//{{{
// '2024-12-17T17:33:48.85939Z
return new Date(Date.parse(this.data.Updated))
}//}}}
isModified() {// {{{
return this._modified
}// }}}
hasFetchedChildren() {//{{{
return this._children_fetched
}//}}}
async fetchChildren() {//{{{
this.Children = await globalThis.nodeStore.getTreeNodes(this.UUID, this.Level + 1)
this._children_fetched = true
// Children are sorted to allow for storing siblings befare and after.
// These are used with keyboard navigation in the tree.
this.Children.sort(Node.sort)
const numChildren = this.Children.length
this.setHasChildren(numChildren > 0)
for (let i = 0; i < numChildren; i++) {
if (i > 0)
this.Children[i]._sibling_before = this.Children[i - 1]
if (i < numChildren - 1)
this.Children[i]._sibling_after = this.Children[i + 1]
this.Children[i]._parent = this
}
return this.Children
}//}}}
setHasChildren(v) {// {{{
this._has_children = v
}// }}}
hasChildren() {//{{{
return this._has_children
}//}}}
getSiblingBefore() {// {{{
return this._sibling_before
}// }}}
getSiblingAfter() {// {{{
return this._sibling_after
}// }}}
getParent() {//{{{
return this._parent
}//}}}
moveToParent(newParentUUID) {// {{{
if (this.UUID === newParentUUID)
throw new Error("New parent UUID is the same as node UUID. Can't be your own parent.")
this.ParentUUID = newParentUUID
this.data.ParentUUID = newParentUUID
this._modified = true
}// }}}
isLastSibling() {//{{{
return this._sibling_after === null
}//}}}
isFirstSibling() {//{{{
return this._sibling_before === null
}//}}}
isSpecial() {// {{{
return this.data.Special
}// }}}
content() {//{{{
// TODO - implement crypto
return this._content
}//}}}
setContent(new_content) {//{{{
this._content = new_content
this._modified = true
_mbus.dispatch('NODE_MODIFIED', { node: this })
}//}}}
setName(new_name) {// {{{
if (new_name.trim() === '')
throw new Error(`The name can't be empty`)
this.data.Name = new_name
this._modified = true
_mbus.dispatch('NODE_MODIFIED', { node: this })
}// }}}
async save() {//{{{
try {
const dblink = /db:\/\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi
// Just safeguarding not using the root node,
// which sort of exist but isn't supposed to communicate to server.
if (this.UUID == ROOT_NODE)
return
// File links from the original, unmodified content are found to compare to links from the new content.
// All files (images) needs to be indexed to nodes in order to track orphaned files.
const before = new Set([...this.data.Content.matchAll(dblink)].map(m => m[1]))
const after = new Set([...this._content.matchAll(dblink)].map(m => m[1]))
const removed = before.difference(after)
const added = after.difference(before)
await globalThis.nodeStore.filesNodelink.removeFileUUIDs(this.UUID, removed)
await globalThis.nodeStore.filesNodelink.addFileUUIDs(this.UUID, added)
// Authoritative content and metadata is set to prepare it for IndexedDB update.
this.data.Content = this._content
this.data.Updated = new Date().toISOString()
this.data.HistoryUUID = uuidv7() // every time the node is saved a new history UUID identifies the changed node.
_mbus.dispatch('NODE_UNMODIFIED')
this._modified = false
// When stored into database and ancestry was changed,
// the ancestry path could be interesting.
/*
const ancestors = await nodeStore.getNodeAncestry(this)
this.data.Ancestors = ancestors.map(a => a.get('Name')).reverse()
*/
/* The node history is a local store for node history.
* This could be provisioned from the server or cleared if
* deemed unnecessary.
*
* The send queue is what will be sent back to the server
* to have a recorded history of the notes.
*
* A setting to be implemented in the future could be to
* not save the history locally at all. */
// Current node is added to history. It will be duplicated with the "nodes" store
// for simplicity, to hopefully avoid bugs.
const history = globalThis.nodeStore.nodesHistory.add(this)
// Updated node is added to the send queue to be stored on server.
const sendQueue = globalThis.nodeStore.sendQueue.add(this)
// Updated node is saved to the primary node store.
const nodeStoreAdding = globalThis.nodeStore.add([this])
await Promise.all([history, sendQueue, nodeStoreAdding])
} catch (err) {
console.error(err)
alert(err.message)
}
return
}//}}}
}
export class NodeStore {
constructor() {//{{{
if (!('indexedDB' in self)) {
if (!('indexedDB' in window)) {
throw 'Missing IndexedDB'
}
@ -220,14 +12,10 @@ export class NodeStore {
this.nodes = {}
this.sendQueue = null
this.nodesHistory = null
this.files = null
this.filesMetadata = null
this.initializeSpecialNodes()
}//}}}
initializeDB() {//{{{
return new Promise((resolve, reject) => {
const req = indexedDB.open('notes', 14)
const req = indexedDB.open('notes', 7)
// Schema upgrades for IndexedDB.
// These can start from different points depending on updates to Notes2 since a device was online.
@ -236,7 +24,6 @@ export class NodeStore {
let appState
let sendQueue
let nodesHistory
let files
const db = event.target.result
const trx = event.target.transaction
@ -268,40 +55,12 @@ export class NodeStore {
break
case 6:
nodesHistory = db.createObjectStore('nodes_history', { keyPath: ['UUID', 'HistoryUUID'] })
nodesHistory = db.createObjectStore('nodes_history', { keyPath: ['UUID', 'Updated'] })
break
case 7:
trx.objectStore('nodes_history').createIndex('byUUID', 'UUID', { unique: false })
break
case 8:
files = db.createObjectStore('files', { keyPath: 'UUID' })
break
case 9:
db.createObjectStore('files_metadata', { keyPath: 'UUID' })
break
case 10:
trx.objectStore('files_metadata').createIndex('byUploaded', 'uploaded', { unique: false })
break
case 11:
db.createObjectStore('files_nodelink', { keyPath: 'UUID' })
break
case 12:
trx.objectStore('files_nodelink').createIndex('byNodeCount', 'NodeCount', { unique: false })
break
case 13:
db.createObjectStore('file_segments', { keyPath: ['UUID', 'Sequence'] })
break
case 14:
db.deleteObjectStore('files')
break
}
}
}
@ -309,12 +68,9 @@ export class NodeStore {
req.onsuccess = (event) => {
this.db = event.target.result
this.sendQueue = new SimpleNodeStore(this.db, 'send_queue')
this.nodesHistory = new NodeHistoryStore(this.db, 'nodes_history')
this.files = new SimpleNodeStore(this.db, 'files')
this.fileSegments = new FileSegmentsStore(this.db, 'file_segments')
this.filesMetadata = new FilesMetadataStore(this.db, 'files_metadata')
this.filesNodelink = new FileNodesLinkStore(this.db, 'files_nodelink')
resolve()
this.nodesHistory = new SimpleNodeStore(this.db, 'nodes_history')
this.initializeRootNode()
.then(() => resolve())
}
req.onerror = (event) => {
@ -322,10 +78,39 @@ export class NodeStore {
}
})
}//}}}
initializeSpecialNodes() {// {{{
this.nodes[ROOT_NODE] = new Node({ UUID: ROOT_NODE, Name: 'Start', Special: true }, -1)
this.nodes[DELETED_NODE] = new Node({ UUID: DELETED_NODE, Name: 'Deleted nodes', Special: true }, -1)
this.nodes[ORPHANED_NODE] = new Node({ UUID: ORPHANED_NODE, Name: 'Orphaned nodes', Special: true }, -1)
initializeRootNode() {//{{{
return new Promise((resolve, reject) => {
// The root node is a magical node which displays as the first node if none is specified.
// If not already existing, it will be created.
const trx = this.db.transaction('nodes', 'readwrite')
const nodes = trx.objectStore('nodes')
const getRequest = nodes.get(ROOT_NODE)
getRequest.onsuccess = (event) => {
// Root node exists - nice!
if (event.target.result !== undefined) {
resolve(event.target.result)
return
}
const putRequest = nodes.put({
UUID: ROOT_NODE,
Name: 'Notes2',
Content: 'Hello, World!',
Updated: new Date().toISOString(),
ParentUUID: '',
})
putRequest.onsuccess = (event) => {
resolve(event.target.result)
}
putRequest.onerror = (event) => {
reject(event.target.error)
}
}
getRequest.onerror = (event) => reject(event.target.error)
})
}//}}}
purgeCache() {//{{{
this.nodes = {}
}//}}}
node(uuid, dataIfUndefined, newLevel) {//{{{
@ -374,6 +159,39 @@ export class NodeStore {
})
}//}}}
/*
upsertNodeRecords(records) {//{{{
return new Promise((resolve, reject) => {
const t = this.db.transaction('nodes', 'readwrite')
const nodeStore = t.objectStore('nodes')
t.onerror = (event) => {
console.log('transaction error', event.target.error)
reject(event.target.error)
}
t.oncomplete = () => {
resolve()
}
// records is an object, not an array.
for (const i in records) {
const record = records[i]
let addReq
let op
if (record.Deleted) {
op = 'deleting'
addReq = nodeStore.delete(record.UUID)
} else {
op = 'upserting'
// 'modified' is a local property for tracking
// nodes needing to be synced to backend.
record.modified = 0
addReq = nodeStore.put(record)
}
}
})
}//}}}
*/
getTreeNodes(parent, newLevel) {//{{{
return new Promise((resolve, reject) => {
// Parent of toplevel nodes is ROOT_NODE in indexedDB.
@ -384,30 +202,14 @@ export class NodeStore {
const nodeStore = trx.objectStore('nodes')
const index = nodeStore.index('byParent')
const req = index.getAll(storeParent)
const hasChildrenPromises = []
req.onsuccess = (event) => {
const nodes = []
for (const i in event.target.result) {
const nodeData = event.target.result[i]
const node = this.node(nodeData.UUID, nodeData, newLevel)
// Look for the key of any children, a hopefully fast way
// to tell if any children exists at all and this node is a
// "folder". Needed quite early on for sorting.
const promise = new Promise((resolve, reject) => {
const countReq = index.getKey(nodeData.UUID)
countReq.onsuccess = event => {
node.setHasChildren(event.target.result !== undefined)
resolve()
}
})
hasChildrenPromises.push(promise)
nodes.push(node)
}
Promise.all(hasChildrenPromises)
.then(() => resolve(nodes))
resolve(nodes)
}
req.onerror = (event) => reject(event.target.error)
})
@ -479,14 +281,6 @@ export class NodeStore {
}//}}}
get(uuid, suppliedNodestore) {//{{{
return new Promise((resolve, reject) => {
switch (uuid) {
case ROOT_NODE:
case DELETED_NODE:
case ORPHANED_NODE:
resolve(this.nodes[uuid])
return
}
// A nodestore can be provided in order to
// avoid creating new transactions.
let trx
@ -524,16 +318,6 @@ export class NodeStore {
return
}
if (node.UUID === DELETED_NODE || node.ParentUUID === DELETED_NODE) {
resolve(accumulated)
return
}
if (node.UUID === ORPHANED_NODE || node.ParentUUID === ORPHANED_NODE) {
resolve(accumulated)
return
}
const getRequest = nodeParentIndex.get(node.ParentUUID)
getRequest.onsuccess = (event) => {
// Node not found in IndexedDB.
@ -565,77 +349,6 @@ export class NodeStore {
}
})
}//}}}
async storeFile(file, reader, progressCallback) {// {{{
const metadata = new FileMetadata(file)
let processedBytes = 0
let stream
try {
// Do potentially larger blocks.
const BLOCKSIZE = 1048576
let buffer = new Uint8Array(BLOCKSIZE)
// Files from server got through fetch() will bring its own
// reader without going through a stream.
if (!reader) {
stream = file.stream()
reader = stream.getReader({ mode: 'byob' })
}
let seq = 0
while (true) {
const { done, value } = await reader.read(buffer)
if (done) break
processedBytes += value.length
if (progressCallback)
progressCallback(processedBytes)
// Block is added as a file segment.
await this.fileSegments.add({
data: {
UUID: metadata.UUID,
Sequence: seq,
Data: value,
}
})
buffer = new Uint8Array(value.buffer)
seq++
}
reader.releaseLock()
} catch (e) {
console.error(e)
alert(e.message)
}
await this.filesMetadata.add({ data: metadata })
return metadata
}// }}}
nextFileToSync() {// {{{
return new Promise((resolve, reject) => {
const req = this.db
.transaction('files_metadata', 'readonly')
.objectStore('files_metadata')
.index('byUploaded')
.get('only_on_device')
req.onerror = (e) => reject(e)
req.onsuccess = (e) => {
const data = e.target.result
// No more files to sync.
if (data === undefined) {
resolve(null)
return
}
resolve(new FileMetadata(data))
}
})
}// }}}
}
class SimpleNodeStore {
@ -663,20 +376,8 @@ class SimpleNodeStore {
}
})
}//}}}
get(key) {//{{{
return new Promise((resolve, _reject) => {
const req = this.db
.transaction(['nodes', this.storeName], 'readonly')
.objectStore(this.storeName)
.get(key)
req.onsuccess = (event) => {
resolve(event.target.result)
}
})
}//}}}
retrieve(limit) {//{{{
return new Promise((resolve, _reject) => {
return new Promise((resolve, reject) => {
const cursorReq = this.db
.transaction(['nodes', this.storeName], 'readonly')
.objectStore(this.storeName)
@ -732,227 +433,4 @@ class SimpleNodeStore {
}//}}}
}
class NodeHistoryStore extends SimpleNodeStore {
constructor(db, storeName) {//{{{
super(db, storeName)
}//}}}
count(uuid) {//{{{
if (uuid === undefined)
return super.count()
const index = this.db
.transaction(['nodes', this.storeName], 'readonly')
.objectStore(this.storeName)
.index('byUUID')
return new Promise((resolve, reject) => {
const request = index.count(uuid)
request.onsuccess = (event) => resolve(event.target.result)
request.onerror = (event) => reject(event.target.error)
})
}//}}}
hasNode(uuid, updated) {// {{{
return new Promise((resolve, reject) => {
const req = this.db
.transaction(['nodes', this.storeName], 'readonly')
.objectStore(this.storeName)
.getKey([uuid, updated])
req.onsuccess = (event) => {
resolve(event.target.result !== undefined)
}
req.onerror = (event) => {
console.error(event.target.error)
reject(event.target.error)
}
})
}// }}}
retrievePage(uuid, perPage, page) {// {{{
return new Promise((resolve, _reject) => {
const lowerBound = [uuid, '00000000-0000-0000-0000-000000000000']
const upperBound = [uuid, 'ffffffff-ffff-ffff-ffff-ffffffffffff']
const range = IDBKeyRange.bound(lowerBound, upperBound)
const cursor = this.db
.transaction(['nodes', this.storeName], 'readonly')
.objectStore(this.storeName)
.openCursor(range, 'prev')
let retrieved = 0
let first = true
const nodes = []
cursor.onsuccess = (event) => {
const cursor = event.target.result
if (!cursor) {
resolve(nodes)
return
}
// openCursor returns the first value which is only useful
// if the first page is requested.
if (page == 1 || !first) {
retrieved++
nodes.push(new Node(cursor.value))
if (retrieved === perPage) {
resolve(nodes)
return
}
cursor.continue()
return
}
// Jump to the start of the requested page.
// Minus one since the first record was already returned.
if (page > 1 && first) {
first = false
cursor.advance((perPage * (page - 1)))
return
}
}
})
}// }}}
}
class FileSegmentsStore extends SimpleNodeStore {
constructor(db, storeName) {//{{{
super(db, storeName)
}//}}}
async getStream(uuid) {// {{{
let seq = 0
const meself = this
return new ReadableStream({
type: 'bytes',
async pull(controller) {
try {
while (true) {
const chunk = await meself.get([uuid, seq])
if (!chunk) {
controller.close()
return
}
controller.enqueue(chunk.Data)
seq++
}
} catch (e) {
console.error(e)
}
}
})
}// }}}
}
class FilesMetadataStore extends SimpleNodeStore {
countToUpload() {// {{{
const store = this.db
.transaction(this.storeName, 'readonly')
.objectStore(this.storeName)
.index('byUploaded')
return new Promise((resolve, reject) => {
const request = store.count('only_on_device')
request.onsuccess = (event) => resolve(event.target.result)
request.onerror = (event) => reject(event.target.error)
})
}// }}}
}
class FileNodesLinkStore extends SimpleNodeStore {
async removeFileUUIDs(nodeUUID, fileUUIDs) {// {{{
for (const fileUUID of fileUUIDs) {
let nodelink = await this.get(fileUUID)
// Nothing to remove from.
if (nodelink === undefined)
continue
const idx = nodelink.Nodes.indexOf(nodeUUID)
if (idx > -1)
nodelink.Nodes.splice(idx, 1)
// Maintains an index to quickly find orphaned files.
nodelink.NodeCount = nodelink.Nodes.length
await this.add({ data: nodelink })
}
}// }}}
async addFileUUIDs(nodeUUID, fileUUIDs) {// {{{
for (const fileUUID of fileUUIDs) {
let nodelink = await this.get(fileUUID)
// Needs to be initialized if not existing already.
if (nodelink === undefined)
nodelink = {
UUID: fileUUID,
Nodes: [nodeUUID],
}
// Can get called with the same UUID as already there.
// No duplicates, only append if missing.
if (!nodelink.Nodes.includes(nodeUUID))
nodelink.Nodes.push(nodeUUID)
// Maintains an index to quickly find orphaned files.
nodelink.NodeCount = nodelink.Nodes.length
await this.add({ data: nodelink })
}
}// }}}
}
class FileMetadata {
constructor(data) {// {{{
this.UUID = data.UUID || uuidv7()
this.uploaded = data.uploaded || 'only_on_device'
this.name = data.name
this.size = data.size
this.type = data.type || 'application/octet-stream'
this.modified = data.modified || data.lastModified || Date.now()
}// }}}
setUploaded() {// {{{
this.uploaded = 'on_server'
}// }}}
get data() {// {{{
return {
UUID: this.UUID,
uploaded: this.uploaded,
name: this.name,
size: this.size,
type: this.type,
modified: this.modified,
}
}// }}}
}
export function uuidv7() {// {{{
// random bytes
const value = new Uint8Array(16)
crypto.getRandomValues(value)
// current timestamp in ms
const timestamp = BigInt(Date.now())
// timestamp
value[0] = Number((timestamp >> 40n) & 0xffn)
value[1] = Number((timestamp >> 32n) & 0xffn)
value[2] = Number((timestamp >> 24n) & 0xffn)
value[3] = Number((timestamp >> 16n) & 0xffn)
value[4] = Number((timestamp >> 8n) & 0xffn)
value[5] = Number(timestamp & 0xffn)
// version and variant
value[6] = (value[6] & 0x0f) | 0x70
value[8] = (value[8] & 0x3f) | 0x80
const str = Array.from(value)
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
return `${str.slice(0, 8)}-${str.slice(8, 12)}-${str.slice(12, 16)}-${str.slice(16, 20)}-${str.slice(20)}`
}// }}}
// vim: foldmethod=marker

View file

@ -69,7 +69,7 @@ export class Notes2 extends Component {
}
if (!dontPush)
history.pushState({ nodeUUID }, '', `/#${nodeUUID}`)
history.pushState({ nodeUUID }, '', `/notes2#${nodeUUID}`)
// New node is fetched in order to retrieve content and files.
// Such data is unnecessary to transfer for tree/navigational purposes.
@ -86,6 +86,404 @@ export class Notes2 extends Component {
}//}}}
}
class Tree extends Component {
constructor(props) {//{{{
super(props)
console.log('new tree')
this.treeNodeComponents = {}
this.treeTrunk = []
this.selectedNode = null
this.expandedNodes = {} // keyed on UUID
this.treeDiv = createRef()
// childrenFetchedCallbacks is keyed on a UUID and each
// item is an array with callbacks called when a UUID has
// had all children fetched.
this.childrenFetchedCallbacks = {}
this.props.app.tree = this
this.populateFirstLevel()
}//}}}
render({ app }) {//{{{
const renderedTreeTrunk = this.treeTrunk.map(node => {
this.treeNodeComponents[node.UUID] = createRef()
return html`<${TreeNode} key=${`treenode_${node.UUID}`} tree=${this} node=${node} ref=${this.treeNodeComponents[node.UUID]} selected=${node.UUID === app.state.startNode?.UUID} />`
})
return html`
<div id="tree" ref=${this.treeDiv} tabindex="0">
<div id="logo" onclick=${() => _notes2.current.goToNode(ROOT_NODE)}><img src="/images/${_VERSION}/logo.svg" /></div>
<div class="icons">
<img src="/images/${_VERSION}/icon_search.svg" style="height: 22px" onclick=${() => _mbus.dispatch('op-search')} />
<img src="/images/${_VERSION}/icon_refresh.svg" onclick=${() => _sync.run()} />
</div>
${renderedTreeTrunk}
</div>`
}//}}}
componentDidMount() {//{{{
//this.treeDiv.current.addEventListener('keydown', event => this.keyHandler(event))
// This will show and select the treenode that is selected in the node UI.
const node = _notes2.current?.nodeUI.current?.node.value
if (node === null)
return
_notes2.current.tree.expandToTrunk(node)
this.setSelected(node)
}//}}}
fetchChildrenNotify(uuid, fn) {//{{{
if (this.childrenFetchedCallbacks[uuid] === undefined)
this.childrenFetchedCallbacks[uuid] = [fn]
else
this.childrenFetchedCallbacks[uuid].push(fn)
}//}}}
fetchChildrenOn(uuid) {//{{{
if (this.childrenFetchedCallbacks[uuid] === undefined)
return
for (const fn of this.childrenFetchedCallbacks[uuid])
fn(uuid)
delete this.childrenFetchedCallbacks[uuid]
}//}}}
populateFirstLevel(callback = null) {//{{{
nodeStore.get(ROOT_NODE)
.then(node => node.fetchChildren())
.then(children => {
this.treeNodeComponents = {}
this.treeTrunk = []
for (const node of children) {
// The root node isn't supposed to be shown in the tree.
if (node.UUID === ROOT_NODE)
continue
if (node.ParentUUID === ROOT_NODE)
this.treeTrunk.push(node)
}
this.forceUpdate()
if (callback)
callback()
})
.catch(e => { console.log(e); console.log(e.type, e.error); alert(e.error) })
}//}}}
setSelected(node, dontExpand) {//{{{
// The previously selected node, if any, needs to be rerendered
// to not retain its 'selected' class.
const prevUUID = this.selectedNode?.UUID
this.selectedNode = node
if (prevUUID)
this.treeNodeComponents[prevUUID]?.current.forceUpdate()
// And now the newly selected node is rerendered.
this.treeNodeComponents[node.UUID]?.current.forceUpdate()
if (!dontExpand)
this.setNodeExpanded(node, true)
}//}}}
isSelected(node) {//{{{
return this.selectedNode?.UUID === node.UUID
}//}}}
async expandToTrunk(node) {//{{{
// Get all ancestors from a certain node up to the highest grandparent.
const ancestry = await nodeStore.getNodeAncestry(node, [])
for (const i in ancestry) {
await nodeStore.node(ancestry[i].UUID).fetchChildren()
this.setNodeExpanded(ancestry[i], true)
}
// Already a top node, no need to expand anything.
if (ancestry.length === 0)
return
// Start the chain of by expanding the top node.
this.setNodeExpanded(ancestry[ancestry.length - 1], true)
}//}}}
getNodeExpanded(UUID) {//{{{
if (this.expandedNodes[UUID] === undefined)
this.expandedNodes[UUID] = signal(false)
return this.expandedNodes[UUID].value
}//}}}
async setNodeExpanded(node, value) {//{{{
return new Promise((resolve, reject) => {
const work = uuid => {
// Creating a default value if it doesn't exist already.
this.getNodeExpanded(uuid)
this.expandedNodes[uuid].value = value
resolve()
}
if (node.hasFetchedChildren()) {
work(node.UUID)
return
} else {
this.fetchChildrenNotify(node.UUID, uuid => work(uuid))
}
})
}//}}}
getParentWithNextSibling(node) {//{{{
let currNode = node
while (currNode !== null && currNode.UUID !== ROOT_NODE && currNode.getSiblingAfter() === null) {
currNode = currNode.getParent()
}
return currNode?.getSiblingAfter()
}//}}}
getLastExpandedNode(node) {//{{{
let currNode = node
while (this.getNodeExpanded(currNode.UUID) && currNode.hasChildren()) {
currNode = currNode.Children[currNode.Children.length - 1]
}
return currNode
}//}}}
async recursiveExpand(node, state) {//{{{
if (state)
await this.setNodeExpanded(node, true)
for (const child of node.Children)
await this.recursiveExpand(child, state)
if (!state)
await this.setNodeExpanded(node, false)
}//}}}
async keyHandler(event) {//{{{
let handled = true
const n = this.selectedNode
const Space = ' '
// This handler would otherwise react to stuff like Ctrl+L.
if (event.ctrlKey || event.altKey)
return
switch (event.key) {
// Space and enter is toggling expansion.
// Holding shift down does it recursively.
case Space:
case 'Enter':
const expanded = this.getNodeExpanded(n.UUID)
if (event.shiftKey) {
this.recursiveExpand(n, !expanded)
} else {
this.setNodeExpanded(n, !expanded)
}
break
case 'g':
case 'Home':
this.navigateTop()
break
case 'G':
case 'End':
this.navigateBottom()
break
case 'j':
case 'ArrowDown':
await this.navigateDown(this.selectedNode)
break
case 'k':
case 'ArrowUp':
await this.navigateUp(this.selectedNode)
break
case 'h':
case 'ArrowLeft':
await this.navigateLeft(this.selectedNode)
break
case 'l':
case 'ArrowRight':
await this.navigateRight(this.selectedNode)
break
default:
// nonsole.log(event.key)
handled = false
}
if (handled) {
event.preventDefault()
event.stopPropagation()
}
}//}}}
async navigateLeft(n) {//{{{
if (n === null)
return
const expanded = this.getNodeExpanded(n.UUID)
if (expanded && n.hasChildren()) {
this.setNodeExpanded(n, false)
return
}
if (n.isFirstSibling() && n.getParent().UUID !== ROOT_NODE) {
await _notes2.current.goToNode(n.getParent()?.UUID, true, true)
return
}
const siblingBefore = n.getSiblingBefore()
const siblingExpanded = this.getNodeExpanded(siblingBefore?.UUID)
if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) {
const siblingAbove = this.getLastExpandedNode(siblingBefore)
await _notes2.current.goToNode(siblingAbove?.UUID, true, true)
return
}
await _notes2.current.goToNode(n.getSiblingBefore()?.UUID, true, true)
}//}}}
async navigateRight(n) {//{{{
if (n === null)
return
const siblingAfter = n.getSiblingAfter()
const expanded = this.getNodeExpanded(n.UUID)
if (!expanded && n.hasChildren()) {
this.setNodeExpanded(n, true)
return
}
if (expanded && n.hasChildren()) {
await _notes2.current.goToNode(n.Children[0]?.UUID, true, true)
return
}
if (n.isLastSibling()) {
const nextNode = this.getParentWithNextSibling(n)
await _notes2.current.goToNode(nextNode?.UUID, true, true)
return
}
await _notes2.current.goToNode(n.getSiblingAfter()?.UUID, true, true)
}//}}}
async navigateUp(n) {//{{{
if (n === null)
return
let parent = null
const siblingBefore = n.getSiblingBefore()
let siblingExpanded = false
if (siblingBefore !== null)
siblingExpanded = this.getNodeExpanded(siblingBefore.UUID)
if (n.isFirstSibling()) {
parent = n.getParent()
if (parent?.UUID === ROOT_NODE)
return
await _notes2.current.goToNode(parent?.UUID, true, true)
return
}
if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) {
await _notes2.current.goToNode(siblingBefore.Children[siblingBefore.Children.length - 1]?.UUID, true, true)
return
}
if (siblingBefore) {
await _notes2.current.goToNode(siblingBefore.UUID, true, true)
return
}
}//}}}
async navigateDown(n) {//{{{
if (n === null)
return
const nodeExpanded = this.getNodeExpanded(n.UUID)
// Last node, not expanded, so it matters not whether it has children or not.
// Traverse upward to nearest parent with next sibling.
if (!nodeExpanded && n.isLastSibling()) {
const wantedNode = this.getParentWithNextSibling(n)
await _notes2.current.goToNode(wantedNode?.UUID, true, true)
return
}
if (nodeExpanded && n.isLastSibling() && !n.hasChildren()) {
const wantedNode = this.getParentWithNextSibling(n)
await _notes2.current.goToNode(wantedNode?.UUID, true, true)
return
}
// Node not expanded. Go to this node's next sibling.
// GoToNode will abort if given null.
if (!nodeExpanded || !n.hasChildren()) {
await _notes2.current.goToNode(n.getSiblingAfter()?.UUID, true, true)
return
}
// Node is expanded.
// Children will be visually beneath this node, if any.
if (nodeExpanded && n.hasChildren()) {
await _notes2.current.goToNode(n.Children[0].UUID, true, true)
return
}
}//}}}
async navigateTop() {//{{{
const root = await nodeStore.get(ROOT_NODE)
if (root.Children.length === 0)
return
await _notes2.current.goToNode(root.Children[0]?.UUID, true, true)
}//}}}
async navigateBottom() {//{{{
const root = await nodeStore.get(ROOT_NODE)
if (root.Children.length === 0)
return
const toplevel = root.Children[root.Children.length - 1]
const toplevelExpanded = this.getNodeExpanded(toplevel?.UUID)
if (toplevelExpanded) {
const lastnode = this.getLastExpandedNode(toplevel)
await _notes2.current.goToNode(lastnode?.UUID, true, true)
} else
await _notes2.current.goToNode(root.Children[root.Children.length - 1]?.UUID, true, true)
}//}}}
}
class TreeNode extends Component {
constructor(props) {//{{{
super(props)
this.children_populated = signal(false)
if (this.props.node.Level === 0 || this.props.tree.getNodeExpanded(this.props.node.UUID))
this.fetchChildren()
}//}}}
render({ tree, node, parent }) {//{{{
// Fetch the next level of children if the parent tree node is expanded and our children thus will be visible.
const selected = tree.isSelected(node) ? 'selected' : ''
if (!this.children_populated.value && tree.getNodeExpanded(parent?.props.node.UUID))
this.fetchChildren()
const children = node.Children.map(node => {
tree.treeNodeComponents[node.UUID] = createRef()
return html`<${TreeNode} key=${`treenode_${node.UUID}`} tree=${tree} node=${node} parent=${this} ref=${tree.treeNodeComponents[node.UUID]} selected=${node.UUID === tree.props.app.startNode?.UUID} />`
})
let expandImg = ''
if (node.Children.length === 0)
expandImg = html`<img src="/images/${window._VERSION}/leaf.svg" />`
else {
if (tree.getNodeExpanded(node.UUID))
expandImg = html`<img src="/images/${window._VERSION}/expanded.svg" />`
else
expandImg = html`<img src="/images/${window._VERSION}/collapsed.svg" />`
}
return html`
<div class="node">
<div class="expand-toggle" onclick=${() => { tree.setNodeExpanded(node, !tree.getNodeExpanded(node.UUID)) }}>${expandImg}</div>
<div class="name ${selected}" onclick=${() => window._notes2.current.goToNode(node.UUID)}>${node.get('Name')}</div>
<div class="children ${node.Children.length > 0 && tree.getNodeExpanded(node.UUID) ? 'expanded' : 'collapsed'}">${children}</div>
</div>`
}//}}}
async fetchChildren() {//{{{
await this.props.node.fetchChildren()
this.children_populated.value = true
}//}}}
}
class Op {
constructor(id) {
this.id = id

View file

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

View file

@ -1,400 +0,0 @@
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
import { MarkedPosition } from './marked_position.mjs'
class N2NodeMenu extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<style>
n2-nodemenu {
margin: 8px 0;
padding: 0;
position-anchor: --node-menu;
box-shadow: rgba(0, 0, 0, 0.05) 0px 6px 24px 0px, rgba(0, 0, 0, 0.08) 0px 0px 0px 1px;
top: anchor(bottom);
right: anchor(right);
left: auto;
white-space: nowrap;
.menu-item {
padding: 8px 16px 8px 8px;
border-bottom: 1px solid var(--line-color);
display: grid;
grid-template-columns: min-content 1fr;
grid-gap: 16px;
align-items: center;
&:last-child {
border-bottom: unset;
}
&:hover {
background-color: var(--menu-item-hover-color);
}
}
}
</style>
<div class="node-menu">
<div class="menu-item" data-el="format-tables">
<img class="colorize" src="/images/${_VERSION}/icon_table.svg">
<div>Format tables</div>
</div>
<div class="menu-item" data-el="history">
<img class="colorize" src="/images/${_VERSION}/icon_history.svg">
<div>History</div>
</div>
</div>
`
}// }}}
constructor() {// {{{
super()
}// }}}
}
customElements.define('n2-nodemenu', N2NodeMenu)
export class N2PageNodeUI extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<style>
n2-nodeui > .el-functions {
display: grid;
grid-template-columns: 1fr repeat(3, min-content);
grid-gap: 8px;
align-items: center;
justify-items: end;
cursor: pointer;
img {
height: 24px;
}
.el-menu {
anchor-name: --node-menu;
border: 0;
padding: 0;
background-color: unset;
}
}
</style>
<div data-el="name"></div>
<textarea data-el="node-content" required rows=1></textarea>
<div data-el="node-markdown" tabindex=1></div>
<div data-el="functions">
<img data-el="icon-save" src="/images/${_VERSION}/icon_save_disabled.svg">
<img data-el="icon-markdown">
<img data-el="icon-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>
`
}// }}}
constructor() {// {{{
super()
this.node = null
this.style.display = 'contents'
this.marked = new MarkedPosition()
_mbus.subscribe('NODE_UI_OPEN', event => {
this.node = event.detail.data
if (!this.node.isSpecial())
this.showMarkdown(true)
this.render()
})
_mbus.subscribe('NODE_MODIFIED', () => {
this.classList.add('node-modified')
this.elIconSave.src = `/images/${_VERSION}/icon_save.svg`
this.elIconSave.classList.add('colorize')
this.renderName()
})
_mbus.subscribe('NODE_UNMODIFIED', () => {
this.classList.remove('node-modified')
this.elIconSave.src = `/images/${_VERSION}/icon_save_disabled.svg`
this.elIconSave.classList.remove('colorize')
})
_mbus.subscribe('MARKDOWN_TOGGLE', () => this.showMarkdown(!this.showMarkdown()))
_mbus.subscribe('MARKDOWN_EDIT', ({ detail }) => this.editMarkdown(detail.data))
_mbus.subscribe('MARKDOWN_CHANGE_CHECKBOX', ({ detail }) => this.checkboxUpdated(detail.data))
// Binding the node rename handler.
this.elName.addEventListener('click', async () => this.renameNode())
// Bind handlers for content keyboard input and paste.
this.elNodeContent.addEventListener('input', event => this.contentChanged(event))
this.elNodeContent.addEventListener('paste', async (event) => this.pasteHandler(event))
// Bind node icon handlers.
this.elIconSave.addEventListener('click', () => this.saveNode())
this.elIconMarkdown.addEventListener('click', () => this.showMarkdown(!this.showMarkdown()))
this.elIconNewDocument.addEventListener('click', event => {
if (event.shiftKey)
_app.createNode(this.node.ParentUUID)
else
_app.createNode()
})
// Bind node menu items to handlers.
this.elNodeMenu.elFormatTables.addEventListener('click', event => {
this.elNodeMenu.hidePopover()
if (!event.shiftKey)
this.elNodeContent.value = this.formatAllTables(this.elNodeContent.value)
else {
const from = this.elNodeContent.selectionStart
const to = this.elNodeContent.selectionEnd
const text = this.elNodeContent.value.slice(from, to)
const formatted = this.formatAllTables(text)
this.elNodeContent.setRangeText(formatted, from, to, 'select');
}
this.node.setContent(this.elNodeContent.value)
})
this.elNodeMenu.elHistory.addEventListener('click', () => {
_mbus.dispatch('SHOW_PAGE', { page: 'history' })
})
// Default is to always show markdown.
this.classList.add('show-markdown') // TODO Should probably be moved to settings.
this.showMarkdown(true)
}// }}}
renderName() {// {{{
this.elName.innerText = this.node?.get('Name') ?? ''
}// }}}
render() {// {{{
this.elName.innerText = this.node?.get('Name') ?? ''
this.elNodeContent.value = this.node?.get('Content') ?? ''
this.elNodeMarkdown.innerHTML = this.marked.parse(this.elNodeContent.value)
}// }}}
takeFocus() {// {{{
if (this.showMarkdown()) {
this.elNodeMarkdown.focus({ preventScroll: true })
} else
this.elNodeContent.focus({ preventScroll: true })
}// }}}
async renameNode() {// {{{
const name = prompt('Change title', this.node.data.Name)
if (name === null)
return
try {
// Document isn't only renamed, but also saved at once.
// Not really correct, but good enough to not have to implement
// a separate way to only rename the document. Since history is
// preserved it shouldn't be that horrible.
this.node.setName(name)
await this.node.save()
// Re-render the parent treenode forcefully to sort it again.
const parentUUID = this.node.ParentUUID
if (!parentUUID)
return
const parentTreeNode = _app.sidebar.getTreeNode(parentUUID)
parentTreeNode?.render(true, true)
} catch (err) {
console.error(err)
alert(err)
}
}// }}}
async saveNode() {// {{{
if (!this.node.isModified())
return
// node.save takes care of both "nodes" and "nodes_history" stores, also adds it to send queue.
// Sets "Updated" value to current date and time and generates a new history UUID.
await this.node.save()
}// }}}
contentChanged(event) {//{{{
this.node.setContent(event.target.value)
}//}}}
isModified() {// {{{
return this.node?.isModified()
}// }}}
showMarkdown(state) {// {{{
// No point in showing markdown if there is no data.
// If there is no data, it will show a blank page regardless, and the user will most
// likely want to edit content, which can't be done in markdown.
const show = this.node?.content().trim() !== '' && state
switch (show) {
case true:
this.elNodeMarkdown.innerHTML = this.marked.parse(this.elNodeContent.value)
this.elIconMarkdown.src = `/images/${_VERSION}/icon_markdown.svg`
this.elIconMarkdown.classList.add('colorize')
this.classList.add('show-markdown')
break
case false:
this.elIconMarkdown.src = `/images/${_VERSION}/icon_markdown_hollow.svg`
this.elIconMarkdown.classList.remove('colorize')
this.classList.remove('show-markdown')
break
case null:
case undefined:
return this.classList.contains('show-markdown')
}
}// }}}
async pasteHandler(event) {// {{{
const clipboardItems = event.clipboardData?.items
if (!clipboardItems)
return
for (const item of clipboardItems) {
switch (item.kind) {
case 'string':
continue
case 'file':
const file = item.getAsFile()
if (!file)
throw new Error("Couldn't convert image to file object.")
const fileMetadata = await globalThis.nodeStore.storeFile(file)
const [start, end] = [this.elNodeContent.selectionStart, this.elNodeContent.selectionEnd]
this.elNodeContent.setRangeText(`![${file.name}](db://${fileMetadata.UUID})`, start, end, 'select');
// Editing the textarea programatically doesn't generate the events it usually gets when edited interactively.
this.node.setContent(this.elNodeContent.value)
break
default:
alert(`Unknown paste type of '${item.kind}'`)
}
}
}// }}}
editMarkdown(data) {// {{{
this.showMarkdown(false)
this.elNodeContent.selectionStart = data.position.start
this.elNodeContent.selectionEnd = data.position.end
this.elNodeContent.focus()
}// }}}
findTables(lines) {// {{{
let tables = []
let curr = { from: -1, to: -1 }
for (let i = 0; i < lines.length; i++) {
const linecols = lines[i].split('|').length - 2 // Gives empty value in front of first pipe and after last one.
if (linecols >= 1) {
if (curr.from == -1)
curr.from = i
curr.to = i
} else if (linecols < 1 && curr.to > -1) {
tables.push(curr)
curr = { from: -1, to: -1 }
}
}
if (curr.from > -1)
tables.push(curr)
return tables
}// }}}
formatAllTables(text) {// {{{
const lines = text.split(/\r?\n/)
const tables = this.findTables(lines)
for (const table of tables) {
const formattedLines = this.formatTable(lines.slice(table.from, table.to + 1))
lines.splice(table.from, formattedLines.length, ...formattedLines)
}
return lines.join("\n")
}// }}}
formatTable(lines) {// {{{
let numColumns = 0
let colwidth = []
for (let i = 0; i < lines.length; i++) {
// -1 for split, -1 because number of columns are one less than number of pipes.
const columns = lines[i].split('|').slice(1)
const linecols = columns.length - 2
numColumns = Math.max(numColumns, linecols)
// Keep count of column width.
for (let j = 0; j < columns.length - 1; j++) {
colwidth[j] = Math.max(colwidth[j] || 0, columns[j].trim().length)
}
}
// Build up each line correct.
let extendHeader
for (let i = 0; i < lines.length; i++) {
// Build lines with columns.
const cols = lines[i].split('|').slice(1, -1)
// Second line should be headers.
if (i === 1) {
extendHeader = true
for (let j = 0; j < colwidth.length; j++) {
extendHeader &= ((cols[j] || '').match(/^\s*[-]*\s*$/) !== null)
}
}
if (i === 1 && extendHeader) {
for (let j = 0; j < colwidth.length; j++)
cols[j] = '-'.repeat(colwidth[j])
} else {
for (let j = 0; j < colwidth.length; j++) {
cols[j] = (cols[j] || '').trim()
const cw = colwidth[j]
const padWidth = cw - (cols[j]?.length || 0) // may be a column that doesn't exist on this line.
cols[j] = cols[j] + ' '.repeat(padWidth > 0 ? padWidth : 0)
}
}
lines[i] = '| ' + cols.join(' | ') + ' |'
}
return lines
}// }}}
// "marked" sends a messagebus event when checking/unchecking a checkbox.
// Updates node and content textarea.
checkboxUpdated(eventData) {// {{{
const checkbox = eventData.checkbox
const pos = eventData.position
const content = this.node.content()
// Basic validation to verify that Marked does what is known and expected at this writing.
const mdCheckboxStr = content.slice(pos.start, pos.end)
if (!mdCheckboxStr.match(/^\[[ xX]\] $/)) {
alert(`Checkbox string didn't pass validation: '${mdCheckboxStr}'`)
console.error(`Checkbox string didn't pass validation: '${mdCheckboxStr}'`)
}
// Node is modified with the new value. User has to save manually, otherwise other changes could be saved
// when a save wasn't expected.
const newValue = `[${checkbox.checked ? 'x' : ' '}] `
const modifiedContent = this.node.content().slice(0, pos.start) + newValue + this.node.content().slice(pos.end)
this.node.setContent(modifiedContent)
// Also update the textarea since the node model doesn't know about it.
this.elNodeContent.setRangeText(newValue, pos.start, pos.end, 'select')
}// }}}
}
customElements.define('n2-nodeui', N2PageNodeUI)
// vim: foldmethod=marker

View file

@ -1,285 +0,0 @@
import { CustomHTMLElement } from "./lib/custom_html_element.mjs"
import { API } from './api.mjs'
export class N2PagePreferences extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<style>
.el-sets {
display: grid;
grid-template-columns: min-content;
grid-gap: 32px;
}
:host > div {
margin-bottom: 32px;
}
.dev-pref-set {
display: grid;
grid-template-columns: min-content min-content;
grid-gap: 16px;
align-items: center;
white-space: nowrap;
}
</style>
<h1>Preferences</h1>
<div>Changes preferences to not download images or files on the device doesn't remove the already downloaded data.</div>
<div class="dev-pref-set">
<div>Device preference set</div>
<select data-el="dev-preference-set"></select>
</div>
<div data-el="sets"></div>
<button data-el="new-set">New set</button>
<button data-el="save" disabled>Save</button>
`
}// }}}
constructor() {// {{{
super(true)
this.sets = []
this.elNewSet.addEventListener('click', () => this.newSet())
this.elSave.addEventListener('click', () => this.save())
this.elDevPreferenceSet.addEventListener('change', ()=>this.changePreferenceSet())
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(([_idx, 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() {// {{{
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)
// Application is told that reloading preferences is to be done.
_mbus.dispatch('DEVICE_PREFERENCE_SET_UPDATED')
} catch (e) {
console.error(e)
alert(e.message)
} finally {
this.elSave.setAttribute('disabled', true)
}
}// }}}
}
customElements.define('n2-pagepreferences', N2PagePreferences)
// Preferences is a set of preferences, of which there can be many named.
export class N2PreferenceSet extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<style>
:host {
border: 1px solid var(--line-color);
padding: 16px;
display: grid;
grid-template-columns: min-content 1fr;
justify-items: start;
align-items: center;
grid-gap: 8px 16px;
white-space: nowrap;
user-select: none;
.header {
grid-column: 1 / -1;
width: 100%;
display: grid;
grid-template-columns: 1fr min-content;
.el-name {
font-weight: bold;
margin-bottom: 32px;
cursor: pointer;
color: var(--color1);
}
.el-delete {
cursor: pointer;
}
}
}
</style>
<div class="header">
<div data-el="name"></div>
<div data-el="delete"></div>
</div>
<div><label for="download-images">Download images on device</label></div>
<input data-field="download-images" type="checkbox" id="download-images">
<div><label for="download-files">Download files on device</label></div>
<input data-field="download-files" type="checkbox" id="download-files">
`
}// }}}
constructor(name, data) {// {{{
super(true)
this.name = name
this.data = data
this.render()
// Enable the save button when settings are modified.
this.allFields().forEach(f =>
f.addEventListener('input', () => _mbus.dispatch('PREFERENCE_SET_MODIFIED'))
)
this.elName.addEventListener('click', () => this.updateName())
this.elDelete.addEventListener('click', () => this.deleteSet())
}// }}}
updateName() {// {{{
if (this.name == 'default') {
alert('Can not change name of the default profile.')
return
}
const name = prompt("Change name", this.name)
if (!name)
return
this.name = name
this.render()
_mbus.dispatch('PREFERENCE_SET_MODIFIED')
}// }}}
deleteSet() {// {{{
_mbus.dispatch('PREFERENCE_SET_DELETE', { set: this })
}// }}}
render() {// {{{
this.elName.innerText = this.name
this.fieldDownloadImages.checked = this.data.DownloadImages
this.fieldDownloadFiles.checked = this.data.DownloadFiles
}// }}}
getState() {// {{{
const name = this.name.trim()
if (name === '')
throw new Error('Name can not be empty.')
return {
name: this.name.trim(),
state: this.fieldValues(),
}
}// }}}
}
customElements.define('n2-preferenceset', N2PreferenceSet)

View file

@ -1,85 +0,0 @@
import { CustomHTMLElement } from "./lib/custom_html_element.mjs"
import { API } from 'api'
export class N2PageStorage extends CustomHTMLElement {
static {
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<style>
.table {
display: grid;
grid-template-columns: min-content min-content;
grid-gap: 4px 16px;
align-items: center;
white-space: nowrap;
}
.error {
background-color: #ff2a2a;
color: #fff;
padding: 4px 8px;
border-radius: 4px;
}
h2 {
margin-top: 24px;
margin-bottom: 8px;
}
</style>
<h1>Local storage</h1>
<h2>Nodes</h2>
<div class="table">
<div>Local nodes</div>
<div data-el="count-nodes"></div>
<div>Queued to sync</div>
<div data-el="count-queued-nodes"></div>
<div>History nodes</div>
<div data-el="count-history-nodes"></div>
</div>
<h2>Files</h2>
<div class="table">
<div>Files on server</div>
<div data-el="count-files-on-server"></div>
<div>Files on device</div>
<div data-el="count-files-on-device"></div>
<div>Files to send</div>
<div data-el="count-queued-files"></div>
</div>
`
}
constructor() {
super(true)
window._mbus.subscribe('SHOW_PAGE', event => {
if (event.detail.data?.page == 'storage')
this.render()
})
}
async render() {
API.query('GET', '/file/count')
.then(res => {
this.elCountFilesOnServer.innerText = res.Count
})
.catch(err => {
this.elCountFilesOnServer.classList.add('error')
console.error(err)
this.elCountFilesOnServer.innerText = err.message
})
this.elCountNodes.innerText = await globalThis.nodeStore.nodeCount()
this.elCountQueuedNodes.innerText = await globalThis.nodeStore.sendQueue.count()
this.elCountHistoryNodes.innerText = await globalThis.nodeStore.nodesHistory.count()
this.elCountQueuedFiles.innerText = await globalThis.nodeStore.filesMetadata.countToUpload()
this.elCountFilesOnDevice.innerText = await globalThis.nodeStore.filesMetadata.count()
}
}
customElements.define('n2-pagestorage', N2PageStorage)

View file

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

View file

@ -1,5 +1,5 @@
import { API } from 'api'
import { Node } from 'node_store'
import { Node } from 'node'
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
export class Sync {
@ -10,26 +10,24 @@ export class Sync {
async run() {//{{{
try {
let duration = 0 // in ms
// The latest sync node value is used to retrieve the changes
// from the backend.
const state = await nodeStore.getAppState('latest_sync_node')
const oldMax = (state?.value ? state.value : 0)
let nodeCountDownload = await this.getNodeCount(oldMax)
let nodeCountUpload = await nodeStore.sendQueue.count()
let nodeCount = await this.getNodeCount(oldMax)
nodeCount += await nodeStore.sendQueue.count()
_mbus.dispatch('SYNC_START')
_mbus.dispatch('SYNC_DOWNLOAD_COUNT', { count: nodeCountDownload })
_mbus.dispatch('SYNC_UPLOAD_COUNT', { count: nodeCountUpload })
_mbus.dispatch('SYNC_COUNT', { count: nodeCount })
const durationNodes = await this.nodesFromServer(oldMax)
await this.nodesFromServer(oldMax)
.then(durationNodes => {
duration = durationNodes // in ms
console.log(`Total time: ${Math.round(1000 * durationNodes) / 1000}s`)
})
// Files are uploaded first since nodes can have references to them
// and the server requires linking a reference to the node and the file table.
await this.filesToServer()
// Uploads of modified nodes to server.
await this.nodesToServer()
} finally {
_mbus.dispatch('SYNC_DONE')
@ -79,18 +77,16 @@ export class Sync {
await this.handleNode(backendNode, objstore)
handled++
// XXX - should this be back for performance or not?
// if (handled % 100 === 0)
_mbus.dispatch('SYNC_DOWNLOADED', { handled })
if (handled % 100 === 0)
_mbus.dispatch('SYNC_HANDLED', { handled })
}
} while (res.Continue)
_mbus.dispatch('SYNC_DOWNLOADED', { handled })
_mbus.dispatch('SYNC_HANDLED', { handled })
nodeStore.setAppState('latest_sync_node', currMax)
} catch (e) {
console.error('sync node tree', e)
alert(e.message)
console.log('sync node tree', e)
} finally {
syncEnd = Date.now()
const duration = (syncEnd - syncStart) / 1000
@ -158,189 +154,71 @@ export class Sync {
_mbus.dispatch('SYNC_UPLOADED', { count: nodesToSend.length })
} catch (e) {
console.error(e)
alert(e.message)
console.trace(e)
alert(e)
return
}
}
}//}}}
async filesToServer() {// {{{
try {
const BLOCKSIZE = 128 * 1024
let sentBytes = 0
const metadata = await nodeStore.nextFileToSync()
if (metadata === null)
return
const stream = await globalThis.nodeStore.fileSegments.getStream(metadata.UUID)
const reader = stream.getReader({ mode: 'byob' }) // Bring Your Own Buffer
let buffer = new Uint8Array(BLOCKSIZE)
while (true) {
const { value, done } = await reader.read(buffer)
await API.upload(metadata.UUID, value, sentBytes, metadata)
metadata.setUploaded()
nodeStore.filesMetadata.add(metadata)
if (done)
break
sentBytes += value.length
buffer = new Uint8Array(value.buffer)
}
reader.releaseLock()
} catch (e) {
console.error(e)
alert(e.message)
}
}// }}}
async filesFromServer() {// {{{
let offset = 0
let more = true
try {
while (more) {
const res = await API.query('GET', `/file/server_uuids/${offset}`)
more = res.MoreRowsExist
offset++
for (const uuid of res.UUIDs) {
const md = await globalThis.nodeStore.filesMetadata.get(uuid)
if (!md) {
await this.storeFileFromServer(uuid)
}
}
}
} catch (e) {
console.error(e)
alert(e.message)
}
}// }}}
async storeFileFromServer(uuid) {// {{{
const headers = {}
const token = localStorage.getItem('token')
if (token) {
headers.Authorization = `Bearer ${token}`
}
const res = await fetch(`/file/uuid/${uuid}`, { headers })
const size = parseInt(res.headers.get('content-length'))
const fileData = {
UUID: uuid,
uploaded: 'on_server',
name: res.headers.get('x-file-name'),
size: res.headers.get('content-length'),
type: res.headers.get('content-type'),
modified: res.headers.get('x-file-modified'),
}
_mbus.dispatch('FILE_SYNC', {
direction: 'download',
name: fileData.name,
})
let prevPercent = 0
await globalThis.nodeStore.storeFile(fileData, res.body.getReader({ mode: 'byob' }), (downloaded) => {
const percent = Math.round((downloaded / size) * 100)
if (prevPercent != percent) {
_mbus.dispatch('FILE_DOWNLOAD_PROGRESS', {
uuid,
downloaded,
size,
percent,
done: false,
})
prevPercent = percent
}
})
_mbus.dispatch('FILE_DOWNLOAD_PROGRESS', {
uuid,
done: true,
})
}// }}}
}
export class N2SyncProgress extends CustomHTMLElement {
static {// {{{
static {
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<img src="/images/${_VERSION}/icon_transfer.svg">
<div data-el="download-transferred" class="count">0</div> <div>/</div> <div data-el="download-total">0</div>
<div data-el="upload-transferred" class="count">0</div> <div>/</div> <div data-el="upload-total">0</div>
<progress data-el="progress" min=0 max=137 value=0></progress>
<div data-el="count" class="count">0 / 0</div>
`
}// }}}
}
constructor() {//{{{
super()
this.reset()
_mbus.subscribe('SYNC_START', () => this.reset())
_mbus.subscribe('SYNC_DOWNLOAD_COUNT', event => this.progressHandler(event))
_mbus.subscribe('SYNC_UPLOAD_COUNT', event => this.progressHandler(event))
_mbus.subscribe('SYNC_DOWNLOADED', event => this.progressHandler(event))
_mbus.subscribe('SYNC_UPLOADED', event => this.progressHandler(event))
_mbus.subscribe('SYNC_COUNT', event => this.progressHandler(event))
_mbus.subscribe('SYNC_HANDLED', event => this.progressHandler(event))
_mbus.subscribe('SYNC_DONE', event => this.progressHandler(event))
}//}}}
reset() {//{{{
this.classList.remove('ok')
this.state = {
nodesToDownload: 0,
nodesToUpload: 0,
nodesDowloaded: 0,
nodesUploaded: 0,
nodesToSync: 0,
nodesSynced: 0,
}
this.render()
}//}}}
progressHandler(event) {//{{{
const eventData = event.detail.data
switch (event.type) {
case 'SYNC_DOWNLOAD_COUNT':
this.state.nodesToDownload = eventData.count
case 'SYNC_COUNT':
this.state.nodesToSync = eventData.count
this.setSyncState(true)
break
case 'SYNC_UPLOAD_COUNT':
this.state.nodesToUpload = eventData.count
this.setSyncState(true)
break
case 'SYNC_DOWNLOADED':
this.state.nodesDowloaded = eventData.handled
break
case 'SYNC_UPLOADED':
this.state.nodesUploaded += eventData.count
case 'SYNC_HANDLED':
this.state.nodesSynced = eventData.handled
break
case 'SYNC_DONE':
this.classList.add('ok')
// Hides the progress bar.
this.setSyncState(false)
// Don't update anything if nothing was synced.
if (this.state.nodesDowloaded === 0)
if (this.state.nodesSynced === 0)
break
// Reload the tree nodes to reflect the new/updated nodes.
window._app.sidebar.reset()
window._app.tree.reset()
break
}
this.render()
}//}}}
render() {//{{{
this.elDownloadTransferred.innerText = this.state.nodesDowloaded
this.elDownloadTotal.innerText = this.state.nodesToDownload
this.elUploadTransferred.innerText = this.state.nodesUploaded
this.elUploadTotal.innerText = this.state.nodesToUpload
this.elProgress.max = this.state.nodesToSync
this.elProgress.value = this.state.nodesSynced
this.elCount.innerText = `${this.state.nodesSynced} / ${this.state.nodesToSync}`
}//}}}
setSyncState(state) {// {{{
if (state)
this.classList.add('show')
else
// Give the user a chance to see what it ended on.
setTimeout(() => this.classList.remove('show'), 1500)
}// }}}
}

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

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

View file

@ -1,55 +1,41 @@
import { NodeStore } from '/js/{{ .VERSION }}/node_store.mjs'
const CACHE_NAME = 'notes2-{{ .VERSION }}'
const CACHED_ASSETS = [
'/',
'/notes2',
'/offline',
'/css/{{ .VERSION }}/main.css',
'/css/{{ .VERSION }}/markdown.css',
'/css/{{ .VERSION }}/notes2.css',
'/css/{{ .VERSION }}/page_history.css',
'/css/{{ .VERSION }}/theme.css',
'/images/{{ .VERSION }}/collapsed.svg',
'/images/{{ .VERSION }}/expanded.svg',
'/images/{{ .VERSION }}/icon_back.svg',
'/images/{{ .VERSION }}/icon_history.svg',
'/images/{{ .VERSION }}/icon_home.svg',
'/images/{{ .VERSION }}/icon_markdown_hollow.svg',
'/images/{{ .VERSION }}/icon_markdown.svg',
'/images/{{ .VERSION }}/icon_menu.svg',
'/images/{{ .VERSION }}/icon_new_document.svg',
'/images/{{ .VERSION }}/icon_refresh.svg',
'/images/{{ .VERSION }}/icon_save_disabled.svg',
'/images/{{ .VERSION }}/icon_save.svg',
'/images/{{ .VERSION }}/icon_search.svg',
'/images/{{ .VERSION }}/icon_settings.svg',
'/images/{{ .VERSION }}/icon_storage.svg',
'/images/{{ .VERSION }}/icon_table.svg',
'/images/{{ .VERSION }}/icon_transfer.svg',
'/images/{{ .VERSION }}/leaf.svg',
'/images/{{ .VERSION }}/logo_small.svg',
'/images/{{ .VERSION }}/logo.svg',
'/js/{{ .VERSION }}/api.mjs',
'/js/{{ .VERSION }}/app.mjs',
'/js/{{ .VERSION }}/checklist.mjs',
'/js/{{ .VERSION }}/file.mjs',
'/js/{{ .VERSION }}/lib/css_colorize.mjs',
'/js/{{ .VERSION }}/crypto.mjs',
'/js/{{ .VERSION }}/key.mjs',
'/js/{{ .VERSION }}/lib/custom_html_element.mjs',
'/js/{{ .VERSION }}/lib/fullcalendar.min.js',
'/js/{{ .VERSION }}/lib/node_modules/marked/lib/marked.esm.js',
'/js/{{ .VERSION }}/lib/node_modules/marked-token-position/lib/index.esm.js',
'/js/{{ .VERSION }}/lib/sjcl.js',
'/js/{{ .VERSION }}/marked_position.mjs',
'/js/{{ .VERSION }}/mbus.mjs',
'/js/{{ .VERSION }}/node.mjs',
'/js/{{ .VERSION }}/node_store.mjs',
'/js/{{ .VERSION }}/notes2.mjs',
'/js/{{ .VERSION }}/page_history.mjs',
'/js/{{ .VERSION }}/page_node.mjs',
'/js/{{ .VERSION }}/page_preferences.mjs',
'/js/{{ .VERSION }}/page_storage.mjs',
'/js/{{ .VERSION }}/sidebar.mjs',
'/js/{{ .VERSION }}/sync.mjs',
'/js/{{ .VERSION }}/tree.mjs',
]
async function precache() {
@ -125,55 +111,10 @@ self.addEventListener('activate', event => {
})
self.addEventListener('fetch', event => {
const url = new URL(event.request.url)
// console.debug('SERVICE WORKER: fetch', event.request.url)
// The fetch event is also seeing requests to other domains.
// Just let the browser handle those for itself.
const ourDomain = (url.host == self.location.host)
if (!ourDomain)
if ({{ .DevMode }})
return event
if (url.pathname == '/stream-local') {
event.respondWith(indexeddbFile(event))
return
}
if (`{{ .DevMode }}` == 'true')
return event
else
event.respondWith(fetchAsset(event))
})
let nodestore = null
async function getNodestore() {
if (nodestore)
return nodestore
// When setting nodestore directly, then waiting on initializeDB,
// a second call for getNodestore can return the nodestore before
// initialized.
const initializingNodeStore = new NodeStore()
await initializingNodeStore.initializeDB()
nodestore = initializingNodeStore
return nodestore
}
async function indexeddbFile(event) {
const url = new URL(event.request.url)
const uuid = url.searchParams.get('uuid')
try {
const nodeStore = await getNodestore()
const md = await nodeStore.filesMetadata.get(uuid)
const stream = await nodeStore.fileSegments.getStream(uuid)
return new Response(stream, {
headers: {
'Content-Type': md.type,
}
})
} catch (err) {
console.error(err)
}
}

27
user.go Normal file
View file

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

View file

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

View file

@ -10,23 +10,31 @@
"imports": {
"api": "/js/{{ .VERSION }}/api.mjs",
"sync": "/js/{{ .VERSION }}/sync.mjs",
"key": "/js/{{ .VERSION }}/key.mjs",
"checklist": "/js/{{ .VERSION }}/checklist.mjs",
"crypto": "/js/{{ .VERSION }}/crypto.mjs",
"node_store": "/js/{{ .VERSION }}/node_store.mjs",
"node": "/js/{{ .VERSION }}/page_node.mjs",
"sidebar": "/js/{{ .VERSION }}/sidebar.mjs"
"node": "/js/{{ .VERSION }}/node.mjs",
"tree": "/js/{{ .VERSION }}/tree.mjs"
{{/*
"session": "/js/{{ .VERSION }}/session.mjs",
"ws": "/_js/{{ .VERSION }}/websocket.mjs"
*/}}
}
}
</script>
<script>
globalThis._VERSION = "{{ .VERSION }}"
window._VERSION = "{{ .VERSION }}"
if (navigator.serviceWorker)
navigator.serviceWorker.register('/service_worker.js', { type: 'module' })
navigator.serviceWorker.register('/service_worker.js')
</script>
<script type="module" defer>
import { MessageBus } from '/js/{{ .VERSION }}/mbus.mjs'
globalThis._mbus = new MessageBus()
window._mbus = new MessageBus()
</script>
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/sjcl.js"></script>
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/fullcalendar.min.js"></script>
</head>
<body>
<div id="app">{{ block "page" . }}{{ end }}</div>

View file

@ -4,7 +4,7 @@
<img id="logo" src="/images/v1/logo.svg">
<input type="text" id="username" placeholder="Username">
<input type="password" id="password" placeholder="Password">
<button onclick="globalThis._login.authenticate()">Log in</button>
<button onclick=window._login.authenticate()>Log in</button>
<div id="error"></div>
</div>
@ -29,7 +29,7 @@ class Login {
const password = document.getElementById('password').value
API.authenticate(username, password)
.then(ans=>{
location.href = '/'
location.href = '/notes2'
})
.catch(e=>{
setTimeout(()=>this.errorDiv.innerText = e, 75)
@ -42,6 +42,6 @@ class Login {
}
}
globalThis._login = new Login()
window._login = new Login()
</script>
{{ end }}

View file

@ -1,67 +1,28 @@
{{ define "page" }}
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/notes2.css">
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/page_history.css">
<!-- Drag and drop elements -->
<!-- page-node -->
<div id="notes2" class="page-node">
<div id="tree-expander" onclick="globalThis._mbus.dispatch('TREE_EXPANSION', { expand: true })">&gt;</div>
<div id="notes2">
<div id="tree" tabindex=0></div>
<div id="main-page">
<!-- Storage stats -->
<div id="page-storage">
<n2-pagestorage></n2-pagestorage>
</div>
<div id="page-root">
<div>
<img src="/images/{{ .VERSION }}/logo.svg">
<div> {{ .VERSION }}</div>
<div class="create">Create note</div>
</div>
</div>
<!-- Node editing -->
<div id="page-node">
<div id="crumbs"></div>
<n2-syncprogress></n2-syncprogress>
<n2-nodeui id="note"></n2-nodeui>
</div>
<!-- History -->
<n2-pagehistory id="page-history"></n2-pagehistory>
<!-- Preferences -->
<n2-pagepreferences id="page-preferences"></n2-pagepreferences>
</div>
<n2-syncprogress></n2-syncprogress>
</div>
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/notes2.css">
<script type="module">
import {NodeStore} from '/js/{{ .VERSION }}/node_store.mjs'
import {NodeStore} from 'node_store'
import {App} from "/js/{{ .VERSION }}/app.mjs"
import {API} from 'api'
import {Sync} from 'sync'
import { } from '/js/{{ .VERSION }}/page_node.mjs'
import { } from '/js/{{ .VERSION }}/page_preferences.mjs'
import { } from '/js/{{ .VERSION }}/page_storage.mjs'
import { } from '/js/{{ .VERSION }}/page_history.mjs'
import { } from '/js/{{ .VERSION }}/file.mjs'
globalThis.Sync = Sync
window.Sync = Sync
if (!API.hasAuthenticationToken()) {
location.href = '/login'
} else {
try {
globalThis.nodeStore = new NodeStore()
globalThis.nodeStore.initializeDB().then(() => {
globalThis._app = new App()
window.nodeStore = new NodeStore()
window.nodeStore.initializeDB().then(() => {
window._app = new App()
})
} catch (e) {
alert(e)

View file

@ -5,9 +5,9 @@
<script type="module">
import { NodeStore } from 'node_store'
import { Sync } from 'sync'
globalThis.Sync = Sync
globalThis.nodeStore = new NodeStore()
globalThis.nodeStore.initializeDB().then(()=>{
window.Sync = Sync
window.nodeStore = new NodeStore()
window.nodeStore.initializeDB().then(()=>{
Sync.tree()
})
</script>