Compare commits
7 commits
74851b9c4d
...
fdc0fc8b97
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdc0fc8b97 | ||
|
|
16992db6b1 | ||
|
|
65f8cd14a7 | ||
|
|
6df2be7944 | ||
|
|
f3ca7b9a44 | ||
|
|
70b5285e16 | ||
|
|
c8308664d3 |
26 changed files with 1244 additions and 3244 deletions
|
|
@ -24,6 +24,10 @@ type Config struct {
|
||||||
Secret string
|
Secret string
|
||||||
ExpireDays int
|
ExpireDays int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Storage struct {
|
||||||
|
Files string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func readConfig(fname string) (err error) {
|
func readConfig(fname string) (err error) {
|
||||||
|
|
|
||||||
138
file.go
138
file.go
|
|
@ -5,35 +5,47 @@ import (
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
|
|
||||||
// Standard
|
// Standard
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type File struct {
|
type File struct {
|
||||||
ID int
|
ID int
|
||||||
UserID int `db:"user_id"`
|
UUID string
|
||||||
NodeID int `db:"node_id"`
|
UserID int `db:"user_id"`
|
||||||
Filename string
|
Name string `db:"name"`
|
||||||
Size int64
|
Size int
|
||||||
MIME string
|
Type string `db:"type"`
|
||||||
MD5 string
|
Modified time.Time
|
||||||
Uploaded time.Time
|
UploadComplete bool `db:"upload_complete"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func AddFile(userID int, file *File) (err error) { // {{{
|
var (
|
||||||
|
fileUUIDCache map[string]int
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
fileUUIDCache = make(map[string]int)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddFileToDb(userID int, file *File) (err error) { // {{{
|
||||||
file.UserID = userID
|
file.UserID = userID
|
||||||
|
|
||||||
var rows *sqlx.Rows
|
var rows *sqlx.Rows
|
||||||
rows, err = db.Queryx(`
|
rows, err = db.Queryx(`
|
||||||
INSERT INTO file(user_id, node_id, filename, size, mime, md5)
|
INSERT INTO public.file(user_id, uuid, name, size, type, modified, upload_complete)
|
||||||
VALUES($1, $2, $3, $4, $5, $6)
|
VALUES($1, $2, $3, $4, $5, $6, false)
|
||||||
|
ON CONFLICT (user_id, uuid) DO UPDATE set name = public.file.name
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
file.UserID,
|
file.UserID,
|
||||||
file.NodeID,
|
file.UUID,
|
||||||
file.Filename,
|
file.Name,
|
||||||
file.Size,
|
file.Size,
|
||||||
file.MIME,
|
file.Type,
|
||||||
file.MD5,
|
file.Modified,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
|
|
@ -76,5 +88,101 @@ func Files(userID int, nodeUUID string, fileID int) (files []File, err error) {
|
||||||
|
|
||||||
return
|
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
|
// vim: foldmethod=marker
|
||||||
|
|
|
||||||
134
main.go
134
main.go
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"notes2/authentication"
|
"notes2/authentication"
|
||||||
"notes2/html_template"
|
"notes2/html_template"
|
||||||
appUser "notes2/user"
|
appUser "notes2/user"
|
||||||
"os"
|
|
||||||
|
|
||||||
// Standard
|
// Standard
|
||||||
"bufio"
|
"bufio"
|
||||||
|
|
@ -17,14 +16,16 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const VERSION = "v29"
|
const VERSION = "v30"
|
||||||
const CONTEXT_USER = 1
|
const CONTEXT_USER = 1
|
||||||
const SYNC_PAGINATION = 200
|
const SYNC_PAGINATION = 200
|
||||||
|
|
||||||
|
|
@ -71,7 +72,7 @@ func init() { // {{{
|
||||||
} // }}}
|
} // }}}
|
||||||
func initLog() { // {{{
|
func initLog() { // {{{
|
||||||
opts := slog.HandlerOptions{}
|
opts := slog.HandlerOptions{}
|
||||||
opts.Level = slog.LevelDebug
|
opts.Level = slog.LevelInfo
|
||||||
Log = slog.New(slog.NewJSONHandler(os.Stdout, &opts))
|
Log = slog.New(slog.NewJSONHandler(os.Stdout, &opts))
|
||||||
} // }}}
|
} // }}}
|
||||||
func main() { // {{{
|
func main() { // {{{
|
||||||
|
|
@ -141,11 +142,17 @@ func main() { // {{{
|
||||||
http.HandleFunc("/sync/from_server/count/{sequence}", authenticated(actionSyncFromServerCount))
|
http.HandleFunc("/sync/from_server/count/{sequence}", authenticated(actionSyncFromServerCount))
|
||||||
http.HandleFunc("/sync/from_server/{sequence}/{offset}", authenticated(actionSyncFromServer))
|
http.HandleFunc("/sync/from_server/{sequence}/{offset}", authenticated(actionSyncFromServer))
|
||||||
http.HandleFunc("/sync/to_server", authenticated(actionSyncToServer))
|
http.HandleFunc("/sync/to_server", authenticated(actionSyncToServer))
|
||||||
|
http.HandleFunc("/sync/file/upload/{uuid}", authenticated(actionSyncFileUpload))
|
||||||
|
|
||||||
http.HandleFunc("/node/retrieve/{uuid}", authenticated(actionNodeRetrieve))
|
http.HandleFunc("/node/retrieve/{uuid}", authenticated(actionNodeRetrieve))
|
||||||
http.HandleFunc("/node/history/retrieve/{uuid}/{offset}", authenticated(actionNodeHistoryRetrieve))
|
http.HandleFunc("/node/history/retrieve/{uuid}/{offset}", authenticated(actionNodeHistoryRetrieve))
|
||||||
http.HandleFunc("/node/history/count/{uuid}", authenticated(actionNodeHistoryCount))
|
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)
|
http.HandleFunc("/service_worker.js", pageServiceWorker)
|
||||||
|
|
||||||
listen := fmt.Sprintf("%s:%d", config.Network.Address, config.Network.Port)
|
listen := fmt.Sprintf("%s:%d", config.Network.Address, config.Network.Port)
|
||||||
|
|
@ -166,6 +173,11 @@ func authenticated(fn func(http.ResponseWriter, *http.Request)) func(http.Respon
|
||||||
|
|
||||||
// The Bearer token is extracted.
|
// The Bearer token is extracted.
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
|
||||||
|
if authHeader == "" {
|
||||||
|
authHeader = "Bearer " + r.URL.Query().Get("jwt")
|
||||||
|
}
|
||||||
|
|
||||||
authParts := RxpBearerToken.FindStringSubmatch(authHeader)
|
authParts := RxpBearerToken.FindStringSubmatch(authHeader)
|
||||||
if len(authParts) != 2 {
|
if len(authParts) != 2 {
|
||||||
failed(fmt.Errorf("Authorization missing or invalid"))
|
failed(fmt.Errorf("Authorization missing or invalid"))
|
||||||
|
|
@ -391,6 +403,63 @@ func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
"OK": true,
|
"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) { // {{{
|
func actionUserGetPreferences(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
user := getUserSession(r)
|
user := getUserSession(r)
|
||||||
|
|
@ -433,6 +502,65 @@ func actionUserSetPreferences(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
})
|
})
|
||||||
} // }}}
|
} // }}}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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) { // {{{
|
func createNewUser(username string) { // {{{
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
|
||||||
|
|
|
||||||
61
node.go
61
node.go
|
|
@ -57,67 +57,6 @@ type Node struct {
|
||||||
Special bool
|
Special bool
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
(
|
|
||||||
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) { // {{{
|
func Nodes(userID, offset int, synced uint64, clientUUID string) (nodes []Node, maxSeq uint64, moreRowsExist bool, err error) { // {{{
|
||||||
var rows *sqlx.Rows
|
var rows *sqlx.Rows
|
||||||
rows, err = db.Queryx(`
|
rows, err = db.Queryx(`
|
||||||
|
|
|
||||||
13
sql/00011.sql
Normal file
13
sql/00011.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.file ADD CONSTRAINT file_user_uuid_unique UNIQUE (user_id,"uuid");
|
||||||
1
sql/00012.sql
Normal file
1
sql/00012.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE public.file ADD upload_complete bool DEFAULT false NOT NULL;
|
||||||
171
sql/00013.sql
Normal file
171
sql/00013.sql
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
-- 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$
|
||||||
|
;
|
||||||
49
static/images/icon_storage.svg
Normal file
49
static/images/icon_storage.svg
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="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>
|
||||||
|
After Width: | Height: | Size: 2 KiB |
|
|
@ -15,18 +15,61 @@ export class API {
|
||||||
const res = await fetch(path, { method, headers, body })
|
const res = await fetch(path, { method, headers, body })
|
||||||
// An HTTP communication level error occured.
|
// An HTTP communication level error occured.
|
||||||
if (!res.ok || res.status != 200)
|
if (!res.ok || res.status != 200)
|
||||||
throw new Error('HTTP error', { cause: { type: 'http', error: res, }})
|
throw new Error('HTTP error', { cause: { type: 'http', error: res, } })
|
||||||
|
|
||||||
// Application level response are handled here.
|
// Application level response are handled here.
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
if (!json.OK)
|
if (!json.OK)
|
||||||
throw new Error(json.Error, { cause: { type: 'application', application: json, }})
|
throw new Error(json.Error, { cause: { type: 'application', application: json, } })
|
||||||
|
|
||||||
|
return 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
|
return json
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Catch any other errors from fetch.
|
// Catch any other errors from fetch.
|
||||||
throw new Error(err.message, { cause: { type: 'http', error: err, }})
|
console.error(err)
|
||||||
|
throw new Error(err, { cause: { type: 'http', error: err } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { ROOT_NODE } from 'node_store'
|
import { ROOT_NODE, Node } from 'node_store'
|
||||||
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
||||||
import { N2Sidebar } from 'sidebar'
|
import { N2Sidebar } from 'sidebar'
|
||||||
import { Node } from 'node'
|
|
||||||
import { N2PreferenceSet } from './page_preferences.mjs'
|
import { N2PreferenceSet } from './page_preferences.mjs'
|
||||||
|
|
||||||
export class App {
|
export class App {
|
||||||
|
|
@ -66,11 +65,10 @@ export class App {
|
||||||
|
|
||||||
_mbus.subscribe('DEVICE_PREFERENCE_SET_UPDATED', ()=>{
|
_mbus.subscribe('DEVICE_PREFERENCE_SET_UPDATED', ()=>{
|
||||||
this.preferences = this.getPreferences()
|
this.preferences = this.getPreferences()
|
||||||
console.log(this.preferences.data)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
window.addEventListener('keydown', event => this.keyHandler(event))
|
globalThis.addEventListener('keydown', event => this.keyHandler(event))
|
||||||
window.addEventListener('popstate', event => this.popState(event))
|
globalThis.addEventListener('popstate', event => this.popState(event))
|
||||||
document.getElementById('notes2').addEventListener('click', event => {
|
document.getElementById('notes2').addEventListener('click', event => {
|
||||||
if (event.target.id === 'notes2')
|
if (event.target.id === 'notes2')
|
||||||
document.getElementById('node-content')?.focus()
|
document.getElementById('node-content')?.focus()
|
||||||
|
|
@ -81,12 +79,12 @@ export class App {
|
||||||
|
|
||||||
_mbus.dispatch('SHOW_PAGE', { page: 'node' })
|
_mbus.dispatch('SHOW_PAGE', { page: 'node' })
|
||||||
|
|
||||||
window._sync = new Sync()
|
globalThis._sync = new Sync()
|
||||||
|
|
||||||
// I think it is uncomfortable having the sync running as soon as the page load.
|
// 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.
|
// I haven't gotten the time to look at the page before stuff jumps around.
|
||||||
// There a slight delay to initiate sync seems reasonable.
|
// There a slight delay to initiate sync seems reasonable.
|
||||||
setTimeout(() => window._sync.run(), 1000)
|
setTimeout(() => globalThis._sync.run(), 1000)
|
||||||
}// }}}
|
}// }}}
|
||||||
keyHandler(event) {//{{{
|
keyHandler(event) {//{{{
|
||||||
let handled = true
|
let handled = true
|
||||||
|
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { CustomHTMLElement } from "./lib/custom_html_element.mjs";
|
import { CustomHTMLElement } from "./lib/custom_html_element.mjs";
|
||||||
|
import { API } from 'api'
|
||||||
|
|
||||||
export class N2File extends CustomHTMLElement {
|
export class N2File extends CustomHTMLElement {
|
||||||
static {
|
static {// {{{
|
||||||
this.tmpl = document.createElement('template')
|
this.tmpl = document.createElement('template')
|
||||||
this.tmpl.innerHTML = `
|
this.tmpl.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -30,24 +31,64 @@ export class N2File extends CustomHTMLElement {
|
||||||
<img data-el="image" src="/images/${_VERSION}/file_icons/generic.svg">
|
<img data-el="image" src="/images/${_VERSION}/file_icons/generic.svg">
|
||||||
<div data-el="filename"></div>
|
<div data-el="filename"></div>
|
||||||
`
|
`
|
||||||
}
|
}// }}}
|
||||||
constructor() {
|
constructor() {// {{{
|
||||||
super(true)
|
super(true)
|
||||||
|
|
||||||
|
this.streamLocalFile = false
|
||||||
|
this.uuid = null
|
||||||
|
|
||||||
this.addEventListener('click', event => {
|
this.addEventListener('click', event => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|
||||||
window.open(
|
// Download to disk.
|
||||||
URL.createObjectURL(this.file),
|
if (event.shiftKey) {
|
||||||
(event.ctrlKey || event.shiftKey) ? '_blank' : '_self',
|
this.downloadToDisk()
|
||||||
)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open in browser.
|
||||||
|
this.openInBrowser(event)
|
||||||
})
|
})
|
||||||
|
|
||||||
this.render()
|
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.')
|
||||||
|
|
||||||
async render() {
|
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')
|
const src = this.getAttribute('src')
|
||||||
|
|
||||||
// N2's db:// URLs are fetched from IndexedDB.
|
// N2's db:// URLs are fetched from IndexedDB.
|
||||||
|
|
@ -56,26 +97,62 @@ export class N2File extends CustomHTMLElement {
|
||||||
// while Marked lib has to be returned a string when exiting this function.
|
// 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
|
// populateImg makes sure this returned img element exists and then populates it
|
||||||
// with the image from IndexedDB.
|
// with the image from IndexedDB.
|
||||||
const file = await globalThis.nodeStore.files.get(src.slice(5))
|
const fileUUID = src.slice(5)
|
||||||
if (!file)
|
|
||||||
return
|
|
||||||
this.file = file.file
|
|
||||||
|
|
||||||
if (file.file.type.startsWith('image/'))
|
this.uuid = fileUUID
|
||||||
this.elImage.src = URL.createObjectURL(file.file)
|
const md = await globalThis.nodeStore.filesMetadata.get(fileUUID)
|
||||||
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/${file.file.type.replaceAll('/', '_')}.svg`
|
|
||||||
const res = await fetch(url)
|
|
||||||
if (res.ok)
|
|
||||||
this.elImage.src = url
|
|
||||||
|
|
||||||
this.elFilename.innerText = file.file.name
|
if (md) {
|
||||||
this.elFilename.style.display = 'block'
|
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
|
} else
|
||||||
|
// Probably an external URL.
|
||||||
this.elImage.src = src
|
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)
|
customElements.define('n2-file', N2File)
|
||||||
|
|
|
||||||
|
|
@ -1,240 +0,0 @@
|
||||||
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
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,12 +1,218 @@
|
||||||
import { Node } from 'node'
|
|
||||||
|
|
||||||
export const ROOT_NODE = '00000000-0000-0000-0000-000000000000'
|
export const ROOT_NODE = '00000000-0000-0000-0000-000000000000'
|
||||||
export const ORPHANED_NODE = '00000000-0000-0000-0000-000000000001'
|
export const ORPHANED_NODE = '00000000-0000-0000-0000-000000000001'
|
||||||
export const DELETED_NODE = '00000000-0000-0000-0000-000000000002'
|
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 {
|
export class NodeStore {
|
||||||
constructor() {//{{{
|
constructor() {//{{{
|
||||||
if (!('indexedDB' in window)) {
|
if (!('indexedDB' in self)) {
|
||||||
throw 'Missing IndexedDB'
|
throw 'Missing IndexedDB'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -15,12 +221,13 @@ export class NodeStore {
|
||||||
this.sendQueue = null
|
this.sendQueue = null
|
||||||
this.nodesHistory = null
|
this.nodesHistory = null
|
||||||
this.files = null
|
this.files = null
|
||||||
|
this.filesMetadata = null
|
||||||
|
|
||||||
this.initializeSpecialNodes()
|
this.initializeSpecialNodes()
|
||||||
}//}}}
|
}//}}}
|
||||||
initializeDB() {//{{{
|
initializeDB() {//{{{
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const req = indexedDB.open('notes', 8)
|
const req = indexedDB.open('notes', 14)
|
||||||
|
|
||||||
// Schema upgrades for IndexedDB.
|
// Schema upgrades for IndexedDB.
|
||||||
// These can start from different points depending on updates to Notes2 since a device was online.
|
// These can start from different points depending on updates to Notes2 since a device was online.
|
||||||
|
|
@ -71,6 +278,30 @@ export class NodeStore {
|
||||||
case 8:
|
case 8:
|
||||||
files = db.createObjectStore('files', { keyPath: 'UUID' })
|
files = db.createObjectStore('files', { keyPath: 'UUID' })
|
||||||
break
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -80,6 +311,9 @@ export class NodeStore {
|
||||||
this.sendQueue = new SimpleNodeStore(this.db, 'send_queue')
|
this.sendQueue = new SimpleNodeStore(this.db, 'send_queue')
|
||||||
this.nodesHistory = new NodeHistoryStore(this.db, 'nodes_history')
|
this.nodesHistory = new NodeHistoryStore(this.db, 'nodes_history')
|
||||||
this.files = new SimpleNodeStore(this.db, 'files')
|
this.files = new SimpleNodeStore(this.db, 'files')
|
||||||
|
this.fileSegments = new FileSegmentsStore(this.db, 'file_segments')
|
||||||
|
this.filesMetadata = new FilesMetadataStore(this.db, 'files_metadata')
|
||||||
|
this.filesNodelink = new FileNodesLinkStore(this.db, 'files_nodelink')
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -221,7 +455,6 @@ export class NodeStore {
|
||||||
nodeStore = t.objectStore('nodes')
|
nodeStore = t.objectStore('nodes')
|
||||||
|
|
||||||
t.oncomplete = (_event) => {
|
t.oncomplete = (_event) => {
|
||||||
console.log('complete')
|
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -332,6 +565,66 @@ export class NodeStore {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}//}}}
|
}//}}}
|
||||||
|
|
||||||
|
async storeFile(file) {// {{{
|
||||||
|
const metadata = new FileMetadata(file)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Do potentially larger blocks.
|
||||||
|
const BLOCKSIZE = 1048576
|
||||||
|
const stream = file.stream()
|
||||||
|
const reader = stream.getReader({ mode: 'byob' })
|
||||||
|
let buffer = new Uint8Array(BLOCKSIZE)
|
||||||
|
|
||||||
|
let seq = 0
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read(buffer)
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
// 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 {
|
class SimpleNodeStore {
|
||||||
|
|
@ -351,7 +644,6 @@ class SimpleNodeStore {
|
||||||
// Node to be moved is first stored in the new queue.
|
// Node to be moved is first stored in the new queue.
|
||||||
const req = store.put(node.data)
|
const req = store.put(node.data)
|
||||||
req.onsuccess = () => {
|
req.onsuccess = () => {
|
||||||
console.log('here')
|
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
req.onerror = (event) => {
|
req.onerror = (event) => {
|
||||||
|
|
@ -460,7 +752,7 @@ class NodeHistoryStore extends SimpleNodeStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
req.onerror = (event) => {
|
req.onerror = (event) => {
|
||||||
console.log(event.target.error)
|
console.error(event.target.error)
|
||||||
reject(event.target.error)
|
reject(event.target.error)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -513,6 +805,120 @@ class NodeHistoryStore extends SimpleNodeStore {
|
||||||
}// }}}
|
}// }}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {// {{{
|
||||||
|
console.log('md', 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() {// {{{
|
export function uuidv7() {// {{{
|
||||||
// random bytes
|
// random bytes
|
||||||
const value = new Uint8Array(16)
|
const value = new Uint8Array(16)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
||||||
import { Node } from './page_node.mjs'
|
import { Node } from 'node_store'
|
||||||
import { MarkedPosition } from './marked_position.mjs'
|
import { MarkedPosition } from './marked_position.mjs'
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { ROOT_NODE, uuidv7 } from 'node_store'
|
|
||||||
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
||||||
import { MarkedPosition } from './marked_position.mjs'
|
import { MarkedPosition } from './marked_position.mjs'
|
||||||
|
|
||||||
|
|
@ -267,11 +266,11 @@ export class N2PageNodeUI extends CustomHTMLElement {
|
||||||
const file = item.getAsFile()
|
const file = item.getAsFile()
|
||||||
if (!file)
|
if (!file)
|
||||||
throw new Error("Couldn't convert image to file object.")
|
throw new Error("Couldn't convert image to file object.")
|
||||||
const uuid = uuidv7()
|
|
||||||
await globalThis.nodeStore.files.add({ data: { UUID: uuid, file: file } })
|
const fileMetadata = await globalThis.nodeStore.storeFile(file)
|
||||||
|
|
||||||
const [start, end] = [this.elNodeContent.selectionStart, this.elNodeContent.selectionEnd]
|
const [start, end] = [this.elNodeContent.selectionStart, this.elNodeContent.selectionEnd]
|
||||||
this.elNodeContent.setRangeText(``, start, end, 'select');
|
this.elNodeContent.setRangeText(``, start, end, 'select');
|
||||||
|
|
||||||
// Editing the textarea programatically doesn't generate the events it usually gets when edited interactively.
|
// Editing the textarea programatically doesn't generate the events it usually gets when edited interactively.
|
||||||
this.node.setContent(this.elNodeContent.value)
|
this.node.setContent(this.elNodeContent.value)
|
||||||
|
|
@ -398,196 +397,4 @@ export class N2PageNodeUI extends CustomHTMLElement {
|
||||||
}
|
}
|
||||||
customElements.define('n2-nodeui', N2PageNodeUI)
|
customElements.define('n2-nodeui', N2PageNodeUI)
|
||||||
|
|
||||||
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 = 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
|
|
||||||
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
|
|
||||||
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() {//{{{
|
|
||||||
// Just safeguarding not using the root node,
|
|
||||||
// which sort of exist but isn't supposed to communicate to server.
|
|
||||||
if (this.UUID == ROOT_NODE)
|
|
||||||
return
|
|
||||||
|
|
||||||
this.data.Content = this._content
|
|
||||||
this.data.Updated = new Date().toISOString()
|
|
||||||
this.data.HistoryUUID = uuidv7() // every time the node is saved a new history UUID identifies the changed node.
|
|
||||||
this._modified = false
|
|
||||||
|
|
||||||
_mbus.dispatch('NODE_UNMODIFIED')
|
|
||||||
|
|
||||||
// When stored into database and ancestry was changed,
|
|
||||||
// the ancestry path could be interesting.
|
|
||||||
/*
|
|
||||||
const ancestors = await nodeStore.getNodeAncestry(this)
|
|
||||||
this.data.Ancestors = ancestors.map(a => a.get('Name')).reverse()
|
|
||||||
*/
|
|
||||||
/* The node history is a local store for node history.
|
|
||||||
* This could be provisioned from the server or cleared if
|
|
||||||
* deemed unnecessary.
|
|
||||||
*
|
|
||||||
* The send queue is what will be sent back to the server
|
|
||||||
* to have a recorded history of the notes.
|
|
||||||
*
|
|
||||||
* A setting to be implemented in the future could be to
|
|
||||||
* not save the history locally at all. */
|
|
||||||
|
|
||||||
// Current node is added to history. It will be duplicated with the "nodes" store
|
|
||||||
// for simplicity, to hopefully avoid bugs.
|
|
||||||
const history = nodeStore.nodesHistory.add(this)
|
|
||||||
|
|
||||||
// Updated node is added to the send queue to be stored on server.
|
|
||||||
|
|
||||||
const sendQueue = nodeStore.sendQueue.add(this)
|
|
||||||
|
|
||||||
// Updated node is saved to the primary node store.
|
|
||||||
const nodeStoreAdding = nodeStore.add([this])
|
|
||||||
|
|
||||||
console.log('waiting')
|
|
||||||
await Promise.all([history, sendQueue, nodeStoreAdding])
|
|
||||||
console.log('waiting done')
|
|
||||||
|
|
||||||
return
|
|
||||||
}//}}}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// vim: foldmethod=marker
|
// vim: foldmethod=marker
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ export class N2PagePreferences extends CustomHTMLElement {
|
||||||
|
|
||||||
this.elNewSet.addEventListener('click', () => this.newSet())
|
this.elNewSet.addEventListener('click', () => this.newSet())
|
||||||
this.elSave.addEventListener('click', () => this.save())
|
this.elSave.addEventListener('click', () => this.save())
|
||||||
this.elDevPreferenceSet.addEventListener('change', event=>this.changePreferenceSet(event))
|
this.elDevPreferenceSet.addEventListener('change', ()=>this.changePreferenceSet())
|
||||||
|
|
||||||
window._mbus.subscribe('SHOW_PAGE', async event => {
|
window._mbus.subscribe('SHOW_PAGE', async event => {
|
||||||
if (event.detail.data?.page == 'preferences') {
|
if (event.detail.data?.page == 'preferences') {
|
||||||
|
|
@ -71,7 +71,7 @@ export class N2PagePreferences extends CustomHTMLElement {
|
||||||
this.sets.sort(this.sortSets)
|
this.sets.sort(this.sortSets)
|
||||||
this.elSets.replaceChildren(...this.sets)
|
this.elSets.replaceChildren(...this.sets)
|
||||||
|
|
||||||
const setNames = this.sets.entries().map(([i, set]) => {
|
const setNames = this.sets.entries().map(([_idx, set]) => {
|
||||||
const optn = document.createElement('option')
|
const optn = document.createElement('option')
|
||||||
optn.innerText = set.name
|
optn.innerText = set.name
|
||||||
return optn
|
return optn
|
||||||
|
|
@ -106,7 +106,7 @@ export class N2PagePreferences extends CustomHTMLElement {
|
||||||
alert(`Error retrieving preferences: ${e.message}`)
|
alert(`Error retrieving preferences: ${e.message}`)
|
||||||
}
|
}
|
||||||
}// }}}
|
}// }}}
|
||||||
changePreferenceSet(event) {// {{{
|
changePreferenceSet() {// {{{
|
||||||
this.preferencesModified()
|
this.preferencesModified()
|
||||||
}// }}}
|
}// }}}
|
||||||
newSet() {// {{{
|
newSet() {// {{{
|
||||||
|
|
@ -168,6 +168,8 @@ export class N2PagePreferences extends CustomHTMLElement {
|
||||||
user.Preferences = newPrefs
|
user.Preferences = newPrefs
|
||||||
localStorage.setItem('user', JSON.stringify(user))
|
localStorage.setItem('user', JSON.stringify(user))
|
||||||
localStorage.setItem('device_preference_set', this.elDevPreferenceSet.value)
|
localStorage.setItem('device_preference_set', this.elDevPreferenceSet.value)
|
||||||
|
|
||||||
|
// Application is told that reloading preferences is to be done.
|
||||||
_mbus.dispatch('DEVICE_PREFERENCE_SET_UPDATED')
|
_mbus.dispatch('DEVICE_PREFERENCE_SET_UPDATED')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,51 @@
|
||||||
import { CustomHTMLElement } from "./lib/custom_html_element.mjs"
|
import { CustomHTMLElement } from "./lib/custom_html_element.mjs"
|
||||||
|
import { API } from 'api'
|
||||||
|
|
||||||
export class N2PageStorage extends CustomHTMLElement {
|
export class N2PageStorage extends CustomHTMLElement {
|
||||||
static {
|
static {
|
||||||
this.tmpl = document.createElement('template')
|
this.tmpl = document.createElement('template')
|
||||||
this.tmpl.innerHTML = `
|
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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<h1>Local storage</h1>
|
<h1>Local storage</h1>
|
||||||
<div data-el="count-nodes"></div>
|
<div class="table">
|
||||||
<div data-el="count-queued-nodes"></div>
|
<div>Local nodes</div>
|
||||||
<div data-el="count-history-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>
|
||||||
|
|
||||||
|
<h1>Files</h1>
|
||||||
|
<div class="table">
|
||||||
|
<div>Files on server</div>
|
||||||
|
<div data-el="count-files-on-server"></div>
|
||||||
|
|
||||||
|
<div>Files to send</div>
|
||||||
|
<div data-el="count-queued-files"></div>
|
||||||
|
</div>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super(true)
|
||||||
|
|
||||||
window._mbus.subscribe('SHOW_PAGE', event => {
|
window._mbus.subscribe('SHOW_PAGE', event => {
|
||||||
if (event.detail.data?.page == 'storage')
|
if (event.detail.data?.page == 'storage')
|
||||||
|
|
@ -19,13 +53,21 @@ export class N2PageStorage extends CustomHTMLElement {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
async render() {
|
async render() {
|
||||||
const countNodes = await globalThis.nodeStore.nodeCount()
|
API.query('GET', '/file/count')
|
||||||
const countQueuedNodes = await globalThis.nodeStore.sendQueue.count()
|
.then(res => {
|
||||||
const countHistoryNodes = await globalThis.nodeStore.nodesHistory.count()
|
this.elCountFilesOnServer.innerText = res.Count
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
this.elCountFilesOnServer.classList.add('error')
|
||||||
|
console.error(err)
|
||||||
|
this.elCountFilesOnServer.innerText = err.message
|
||||||
|
})
|
||||||
|
|
||||||
this.elCountNodes.innerText = countNodes
|
|
||||||
this.elCountQueuedNodes.innerText = countQueuedNodes
|
this.elCountNodes.innerText = await globalThis.nodeStore.nodeCount()
|
||||||
this.elCountHistoryNodes.innerText = countHistoryNodes
|
this.elCountQueuedNodes.innerText = await globalThis.nodeStore.sendQueue.count()
|
||||||
|
this.elCountHistoryNodes.innerText = await globalThis.nodeStore.nodesHistory.count()
|
||||||
|
this.elCountQueuedFiles.innerText = await globalThis.nodeStore.filesMetadata.countToUpload()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
customElements.define('n2-pagestorage', N2PageStorage)
|
customElements.define('n2-pagestorage', N2PageStorage)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { ROOT_NODE, ORPHANED_NODE, DELETED_NODE } from 'node_store'
|
import { ROOT_NODE, ORPHANED_NODE, DELETED_NODE, Node } from 'node_store'
|
||||||
import { Node } from 'node'
|
|
||||||
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
||||||
import { Color, Solver } from './lib/css_colorize.mjs'
|
import { Color, Solver } from './lib/css_colorize.mjs'
|
||||||
|
|
||||||
|
|
@ -82,11 +81,16 @@ export class N2Sidebar extends CustomHTMLElement {
|
||||||
|
|
||||||
.icons {
|
.icons {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: start;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
gap: 8px;
|
gap: 8px 16px;
|
||||||
|
padding-left: 40px;
|
||||||
padding-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
border-bottom: 1px solid var(--line-color);
|
border-bottom: 1px solid var(--line-color);
|
||||||
|
|
||||||
|
img {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -104,6 +108,7 @@ export class N2Sidebar extends CustomHTMLElement {
|
||||||
</div>
|
</div>
|
||||||
<div class="icons">
|
<div class="icons">
|
||||||
<img data-el="sync" class='sync colorize' src="/images/${_VERSION}/icon_refresh.svg" />
|
<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="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" />
|
<img data-el="settings" class='settings colorize' src="/images/${_VERSION}/icon_settings.svg" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -127,6 +132,7 @@ export class N2Sidebar extends CustomHTMLElement {
|
||||||
this.addEventListener('keydown', event => this.keyHandler(event))
|
this.addEventListener('keydown', event => this.keyHandler(event))
|
||||||
this.elSearch.addEventListener('click', () => _mbus.dispatch('op-search'))
|
this.elSearch.addEventListener('click', () => _mbus.dispatch('op-search'))
|
||||||
this.elSync.addEventListener('click', () => _sync.run())
|
this.elSync.addEventListener('click', () => _sync.run())
|
||||||
|
this.elStorage.addEventListener('click', () => _mbus.dispatch('SHOW_PAGE', { page: 'storage' }))
|
||||||
this.elLogo.addEventListener('click', () => _app.goToNode(ROOT_NODE, false, false))
|
this.elLogo.addEventListener('click', () => _app.goToNode(ROOT_NODE, false, false))
|
||||||
this.elSettings.addEventListener('click', ()=> _mbus.dispatch('SHOW_PAGE', { page: 'preferences' }))
|
this.elSettings.addEventListener('click', ()=> _mbus.dispatch('SHOW_PAGE', { page: 'preferences' }))
|
||||||
this.elHideTree.addEventListener('click', event => {
|
this.elHideTree.addEventListener('click', event => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { API } from 'api'
|
import { API } from 'api'
|
||||||
import { Node } from 'node'
|
import { Node } from 'node_store'
|
||||||
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
||||||
|
|
||||||
export class Sync {
|
export class Sync {
|
||||||
|
|
@ -164,6 +164,53 @@ export class Sync {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}//}}}
|
}//}}}
|
||||||
|
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
|
||||||
|
console.log(res)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
alert(e.message)
|
||||||
|
}
|
||||||
|
}// }}}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class N2SyncProgress extends CustomHTMLElement {
|
export class N2SyncProgress extends CustomHTMLElement {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { NodeStore } from '/js/{{ .VERSION }}/node_store.mjs'
|
||||||
|
|
||||||
const CACHE_NAME = 'notes2-{{ .VERSION }}'
|
const CACHE_NAME = 'notes2-{{ .VERSION }}'
|
||||||
const CACHED_ASSETS = [
|
const CACHED_ASSETS = [
|
||||||
'/',
|
'/',
|
||||||
|
|
@ -11,16 +13,21 @@ const CACHED_ASSETS = [
|
||||||
|
|
||||||
'/images/{{ .VERSION }}/collapsed.svg',
|
'/images/{{ .VERSION }}/collapsed.svg',
|
||||||
'/images/{{ .VERSION }}/expanded.svg',
|
'/images/{{ .VERSION }}/expanded.svg',
|
||||||
|
'/images/{{ .VERSION }}/icon_back.svg',
|
||||||
'/images/{{ .VERSION }}/icon_history.svg',
|
'/images/{{ .VERSION }}/icon_history.svg',
|
||||||
'/images/{{ .VERSION }}/icon_home.svg',
|
'/images/{{ .VERSION }}/icon_home.svg',
|
||||||
'/images/{{ .VERSION }}/icon_markdown_hollow.svg',
|
'/images/{{ .VERSION }}/icon_markdown_hollow.svg',
|
||||||
'/images/{{ .VERSION }}/icon_markdown.svg',
|
'/images/{{ .VERSION }}/icon_markdown.svg',
|
||||||
|
'/images/{{ .VERSION }}/icon_menu.svg',
|
||||||
|
'/images/{{ .VERSION }}/icon_new_document.svg',
|
||||||
'/images/{{ .VERSION }}/icon_refresh.svg',
|
'/images/{{ .VERSION }}/icon_refresh.svg',
|
||||||
'/images/{{ .VERSION }}/icon_save_disabled.svg',
|
'/images/{{ .VERSION }}/icon_save_disabled.svg',
|
||||||
'/images/{{ .VERSION }}/icon_save.svg',
|
'/images/{{ .VERSION }}/icon_save.svg',
|
||||||
'/images/{{ .VERSION }}/icon_search.svg',
|
'/images/{{ .VERSION }}/icon_search.svg',
|
||||||
'/images/{{ .VERSION }}/icon_settings.svg',
|
'/images/{{ .VERSION }}/icon_settings.svg',
|
||||||
|
'/images/{{ .VERSION }}/icon_storage.svg',
|
||||||
'/images/{{ .VERSION }}/icon_table.svg',
|
'/images/{{ .VERSION }}/icon_table.svg',
|
||||||
|
'/images/{{ .VERSION }}/icon_transfer.svg',
|
||||||
'/images/{{ .VERSION }}/leaf.svg',
|
'/images/{{ .VERSION }}/leaf.svg',
|
||||||
'/images/{{ .VERSION }}/logo_small.svg',
|
'/images/{{ .VERSION }}/logo_small.svg',
|
||||||
'/images/{{ .VERSION }}/logo.svg',
|
'/images/{{ .VERSION }}/logo.svg',
|
||||||
|
|
@ -28,20 +35,18 @@ const CACHED_ASSETS = [
|
||||||
'/js/{{ .VERSION }}/api.mjs',
|
'/js/{{ .VERSION }}/api.mjs',
|
||||||
'/js/{{ .VERSION }}/app.mjs',
|
'/js/{{ .VERSION }}/app.mjs',
|
||||||
'/js/{{ .VERSION }}/checklist.mjs',
|
'/js/{{ .VERSION }}/checklist.mjs',
|
||||||
'/js/{{ .VERSION }}/crypto.mjs',
|
|
||||||
'/js/{{ .VERSION }}/file.mjs',
|
'/js/{{ .VERSION }}/file.mjs',
|
||||||
'/js/{{ .VERSION }}/key.mjs',
|
|
||||||
'/js/{{ .VERSION }}/lib/css_colorize.mjs',
|
'/js/{{ .VERSION }}/lib/css_colorize.mjs',
|
||||||
'/js/{{ .VERSION }}/lib/custom_html_element.mjs',
|
'/js/{{ .VERSION }}/lib/custom_html_element.mjs',
|
||||||
'/js/{{ .VERSION }}/lib/node_modules/marked/lib/marked.esm.js',
|
'/js/{{ .VERSION }}/lib/node_modules/marked/lib/marked.esm.js',
|
||||||
'/js/{{ .VERSION }}/lib/node_modules/marked-token-position/lib/index.esm.js',
|
'/js/{{ .VERSION }}/lib/node_modules/marked-token-position/lib/index.esm.js',
|
||||||
'/js/{{ .VERSION }}/lib/sjcl.js',
|
|
||||||
'/js/{{ .VERSION }}/marked_position.mjs',
|
'/js/{{ .VERSION }}/marked_position.mjs',
|
||||||
'/js/{{ .VERSION }}/mbus.mjs',
|
'/js/{{ .VERSION }}/mbus.mjs',
|
||||||
'/js/{{ .VERSION }}/node_store.mjs',
|
'/js/{{ .VERSION }}/node_store.mjs',
|
||||||
'/js/{{ .VERSION }}/notes2.mjs',
|
'/js/{{ .VERSION }}/notes2.mjs',
|
||||||
'/js/{{ .VERSION }}/page_history.mjs',
|
'/js/{{ .VERSION }}/page_history.mjs',
|
||||||
'/js/{{ .VERSION }}/page_node.mjs',
|
'/js/{{ .VERSION }}/page_node.mjs',
|
||||||
|
'/js/{{ .VERSION }}/page_preferences.mjs',
|
||||||
'/js/{{ .VERSION }}/page_storage.mjs',
|
'/js/{{ .VERSION }}/page_storage.mjs',
|
||||||
'/js/{{ .VERSION }}/sidebar.mjs',
|
'/js/{{ .VERSION }}/sidebar.mjs',
|
||||||
'/js/{{ .VERSION }}/sync.mjs',
|
'/js/{{ .VERSION }}/sync.mjs',
|
||||||
|
|
@ -120,14 +125,55 @@ self.addEventListener('activate', event => {
|
||||||
})
|
})
|
||||||
|
|
||||||
self.addEventListener('fetch', event => {
|
self.addEventListener('fetch', event => {
|
||||||
|
const url = new URL(event.request.url)
|
||||||
|
|
||||||
// The fetch event is also seeing requests to other domains.
|
// The fetch event is also seeing requests to other domains.
|
||||||
// Just let the browser handle those for itself.
|
// Just let the browser handle those for itself.
|
||||||
const ourDomain = event.request.url.startsWith(self.location.origin)
|
const ourDomain = (url.host == self.location.host)
|
||||||
if (!ourDomain)
|
if (!ourDomain)
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
if (url.pathname == '/stream-local') {
|
||||||
|
event.respondWith(indexeddbFile(event))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (`{{ .DevMode }}` == 'true')
|
if (`{{ .DevMode }}` == 'true')
|
||||||
return event
|
return event
|
||||||
|
else
|
||||||
event.respondWith(fetchAsset(event))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,8 @@
|
||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
"api": "/js/{{ .VERSION }}/api.mjs",
|
"api": "/js/{{ .VERSION }}/api.mjs",
|
||||||
"sync": "/js/{{ .VERSION }}/sync.mjs",
|
"sync": "/js/{{ .VERSION }}/sync.mjs",
|
||||||
"key": "/js/{{ .VERSION }}/key.mjs",
|
|
||||||
"checklist": "/js/{{ .VERSION }}/checklist.mjs",
|
"checklist": "/js/{{ .VERSION }}/checklist.mjs",
|
||||||
"crypto": "/js/{{ .VERSION }}/crypto.mjs",
|
|
||||||
"node_store": "/js/{{ .VERSION }}/node_store.mjs",
|
"node_store": "/js/{{ .VERSION }}/node_store.mjs",
|
||||||
"node": "/js/{{ .VERSION }}/page_node.mjs",
|
"node": "/js/{{ .VERSION }}/page_node.mjs",
|
||||||
"sidebar": "/js/{{ .VERSION }}/sidebar.mjs"
|
"sidebar": "/js/{{ .VERSION }}/sidebar.mjs"
|
||||||
|
|
@ -20,14 +18,14 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
window._VERSION = "{{ .VERSION }}"
|
globalThis._VERSION = "{{ .VERSION }}"
|
||||||
|
|
||||||
if (navigator.serviceWorker)
|
if (navigator.serviceWorker)
|
||||||
navigator.serviceWorker.register('/service_worker.js')
|
navigator.serviceWorker.register('/service_worker.js', { type: 'module' })
|
||||||
</script>
|
</script>
|
||||||
<script type="module" defer>
|
<script type="module" defer>
|
||||||
import { MessageBus } from '/js/{{ .VERSION }}/mbus.mjs'
|
import { MessageBus } from '/js/{{ .VERSION }}/mbus.mjs'
|
||||||
window._mbus = new MessageBus()
|
globalThis._mbus = new MessageBus()
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<img id="logo" src="/images/v1/logo.svg">
|
<img id="logo" src="/images/v1/logo.svg">
|
||||||
<input type="text" id="username" placeholder="Username">
|
<input type="text" id="username" placeholder="Username">
|
||||||
<input type="password" id="password" placeholder="Password">
|
<input type="password" id="password" placeholder="Password">
|
||||||
<button onclick=window._login.authenticate()>Log in</button>
|
<button onclick="globalThis._login.authenticate()">Log in</button>
|
||||||
<div id="error"></div>
|
<div id="error"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -42,6 +42,6 @@ class Login {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window._login = new Login()
|
globalThis._login = new Login()
|
||||||
</script>
|
</script>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<!-- page-node -->
|
<!-- page-node -->
|
||||||
<div id="notes2" class="page-node">
|
<div id="notes2" class="page-node">
|
||||||
<div id="tree-expander" onclick="window._mbus.dispatch('TREE_EXPANSION', { expand: true })">></div>
|
<div id="tree-expander" onclick="globalThis._mbus.dispatch('TREE_EXPANSION', { expand: true })">></div>
|
||||||
<div id="tree" tabindex=0></div>
|
<div id="tree" tabindex=0></div>
|
||||||
|
|
||||||
<div id="main-page">
|
<div id="main-page">
|
||||||
|
|
@ -47,20 +47,21 @@
|
||||||
import {App} from "/js/{{ .VERSION }}/app.mjs"
|
import {App} from "/js/{{ .VERSION }}/app.mjs"
|
||||||
import {API} from 'api'
|
import {API} from 'api'
|
||||||
import {Sync} from 'sync'
|
import {Sync} from 'sync'
|
||||||
|
import { } from '/js/{{ .VERSION }}/page_node.mjs'
|
||||||
import { } from '/js/{{ .VERSION }}/page_preferences.mjs'
|
import { } from '/js/{{ .VERSION }}/page_preferences.mjs'
|
||||||
import { } from '/js/{{ .VERSION }}/page_storage.mjs'
|
import { } from '/js/{{ .VERSION }}/page_storage.mjs'
|
||||||
import { } from '/js/{{ .VERSION }}/page_history.mjs'
|
import { } from '/js/{{ .VERSION }}/page_history.mjs'
|
||||||
import { } from '/js/{{ .VERSION }}/file.mjs'
|
import { } from '/js/{{ .VERSION }}/file.mjs'
|
||||||
|
|
||||||
window.Sync = Sync
|
globalThis.Sync = Sync
|
||||||
|
|
||||||
if (!API.hasAuthenticationToken()) {
|
if (!API.hasAuthenticationToken()) {
|
||||||
location.href = '/login'
|
location.href = '/login'
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
window.nodeStore = new NodeStore()
|
globalThis.nodeStore = new NodeStore()
|
||||||
window.nodeStore.initializeDB().then(() => {
|
globalThis.nodeStore.initializeDB().then(() => {
|
||||||
window._app = new App()
|
globalThis._app = new App()
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e)
|
alert(e)
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { NodeStore } from 'node_store'
|
import { NodeStore } from 'node_store'
|
||||||
import { Sync } from 'sync'
|
import { Sync } from 'sync'
|
||||||
window.Sync = Sync
|
globalThis.Sync = Sync
|
||||||
window.nodeStore = new NodeStore()
|
globalThis.nodeStore = new NodeStore()
|
||||||
window.nodeStore.initializeDB().then(()=>{
|
globalThis.nodeStore.initializeDB().then(()=>{
|
||||||
Sync.tree()
|
Sync.tree()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue