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
|
||||
ExpireDays int
|
||||
}
|
||||
|
||||
Storage struct {
|
||||
Files string
|
||||
}
|
||||
}
|
||||
|
||||
func readConfig(fname string) (err error) {
|
||||
|
|
|
|||
134
file.go
134
file.go
|
|
@ -5,35 +5,47 @@ import (
|
|||
"github.com/jmoiron/sqlx"
|
||||
|
||||
// Standard
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"time"
|
||||
)
|
||||
|
||||
type File struct {
|
||||
ID int
|
||||
UUID string
|
||||
UserID int `db:"user_id"`
|
||||
NodeID int `db:"node_id"`
|
||||
Filename string
|
||||
Size int64
|
||||
MIME string
|
||||
MD5 string
|
||||
Uploaded time.Time
|
||||
Name string `db:"name"`
|
||||
Size int
|
||||
Type string `db:"type"`
|
||||
Modified 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
|
||||
|
||||
var rows *sqlx.Rows
|
||||
rows, err = db.Queryx(`
|
||||
INSERT INTO file(user_id, node_id, filename, size, mime, md5)
|
||||
VALUES($1, $2, $3, $4, $5, $6)
|
||||
INSERT INTO public.file(user_id, uuid, name, size, type, modified, upload_complete)
|
||||
VALUES($1, $2, $3, $4, $5, $6, false)
|
||||
ON CONFLICT (user_id, uuid) DO UPDATE set name = public.file.name
|
||||
RETURNING id
|
||||
`,
|
||||
file.UserID,
|
||||
file.NodeID,
|
||||
file.Filename,
|
||||
file.UUID,
|
||||
file.Name,
|
||||
file.Size,
|
||||
file.MIME,
|
||||
file.MD5,
|
||||
file.Type,
|
||||
file.Modified,
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
|
|
@ -76,5 +88,101 @@ func Files(userID int, nodeUUID string, fileID int) (files []File, err error) {
|
|||
|
||||
return
|
||||
} // }}}
|
||||
func FileIDToPath(id int) (string, string) { // {{{
|
||||
str := fmt.Sprintf("%012d", id)
|
||||
dir := path.Join(config.Storage.Files, str[0:4], str[4:8])
|
||||
fpath := path.Join(config.Storage.Files, str[0:4], str[4:8], str[8:12])
|
||||
return dir, fpath
|
||||
} // }}}
|
||||
|
||||
func FileWriteData(userID int, dbID int, data []byte, create bool) (err error) { // {{{
|
||||
dir, fpath := FileIDToPath(dbID)
|
||||
err = os.MkdirAll(dir, 0700)
|
||||
if err != nil {
|
||||
Log.Error("file", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
var f *os.File
|
||||
if create {
|
||||
f, err = os.OpenFile(fpath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
} else {
|
||||
f, err = os.OpenFile(fpath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
}
|
||||
if err != nil {
|
||||
Log.Error("file", "error", err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
_, err = f.Write(data)
|
||||
if err != nil {
|
||||
Log.Error("file", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
} // }}}
|
||||
|
||||
func FileCountForUser(userID int) (count int, err error) { // {{{
|
||||
row := db.QueryRowx(`SELECT COUNT(*) FROM public.file WHERE user_id = $1`, userID)
|
||||
err = row.Scan(&count)
|
||||
return
|
||||
} // }}}
|
||||
func FileServerUUIDsForUser(userID, page int) (uuids []string, more bool, err error) { // {{{
|
||||
uuids = []string{}
|
||||
|
||||
var rows *sqlx.Rows
|
||||
rows, err = db.Queryx(`
|
||||
SELECT
|
||||
uuid
|
||||
FROM public.file
|
||||
WHERE
|
||||
user_id = $1
|
||||
ORDER BY
|
||||
uuid ASC
|
||||
LIMIT $2 OFFSET $3
|
||||
`,
|
||||
userID,
|
||||
SYNC_PAGINATION+1,
|
||||
page*SYNC_PAGINATION,
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var uuid string
|
||||
for rows.Next() {
|
||||
err = rows.Scan(&uuid)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
uuids = append(uuids, uuid)
|
||||
}
|
||||
|
||||
more = len(uuids) > SYNC_PAGINATION
|
||||
uuids = uuids[0:min(SYNC_PAGINATION, len(uuids))]
|
||||
|
||||
return
|
||||
} // }}}
|
||||
func FileMetadata(userID int, uuid string) (f File, err error) { // {{{
|
||||
row := db.QueryRowx(`SELECT * FROM public.file WHERE user_id = $1 AND "uuid" = $2`, userID, uuid)
|
||||
err = row.StructScan(&f)
|
||||
return
|
||||
|
||||
} // }}}
|
||||
func FileForUser(userID int, uuid string) (f *os.File, md File, err error) { // {{{
|
||||
row := db.QueryRowx(`SELECT * FROM public.file WHERE user_id = $1 AND "uuid" = $2`, userID, uuid)
|
||||
err = row.StructScan(&md)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, fpath := FileIDToPath(md.ID)
|
||||
f, err = os.OpenFile(fpath, os.O_RDONLY, 0600)
|
||||
return
|
||||
|
||||
} // }}}
|
||||
|
||||
// vim: foldmethod=marker
|
||||
|
|
|
|||
134
main.go
134
main.go
|
|
@ -5,7 +5,6 @@ import (
|
|||
"notes2/authentication"
|
||||
"notes2/html_template"
|
||||
appUser "notes2/user"
|
||||
"os"
|
||||
|
||||
// Standard
|
||||
"bufio"
|
||||
|
|
@ -17,14 +16,16 @@ import (
|
|||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
const VERSION = "v29"
|
||||
const VERSION = "v30"
|
||||
const CONTEXT_USER = 1
|
||||
const SYNC_PAGINATION = 200
|
||||
|
||||
|
|
@ -71,7 +72,7 @@ func init() { // {{{
|
|||
} // }}}
|
||||
func initLog() { // {{{
|
||||
opts := slog.HandlerOptions{}
|
||||
opts.Level = slog.LevelDebug
|
||||
opts.Level = slog.LevelInfo
|
||||
Log = slog.New(slog.NewJSONHandler(os.Stdout, &opts))
|
||||
} // }}}
|
||||
func main() { // {{{
|
||||
|
|
@ -141,11 +142,17 @@ func main() { // {{{
|
|||
http.HandleFunc("/sync/from_server/count/{sequence}", authenticated(actionSyncFromServerCount))
|
||||
http.HandleFunc("/sync/from_server/{sequence}/{offset}", authenticated(actionSyncFromServer))
|
||||
http.HandleFunc("/sync/to_server", authenticated(actionSyncToServer))
|
||||
http.HandleFunc("/sync/file/upload/{uuid}", authenticated(actionSyncFileUpload))
|
||||
|
||||
http.HandleFunc("/node/retrieve/{uuid}", authenticated(actionNodeRetrieve))
|
||||
http.HandleFunc("/node/history/retrieve/{uuid}/{offset}", authenticated(actionNodeHistoryRetrieve))
|
||||
http.HandleFunc("/node/history/count/{uuid}", authenticated(actionNodeHistoryCount))
|
||||
|
||||
http.HandleFunc("/file/count", authenticated(actionFileCount))
|
||||
http.HandleFunc("/file/server_uuids/{offset}", authenticated(actionFileServerUUIDs))
|
||||
http.HandleFunc("/file/uuid/{uuid}", authenticated(actionFile))
|
||||
http.HandleFunc("/file/uuid/{uuid}/metadata", authenticated(actionFileMetadata))
|
||||
|
||||
http.HandleFunc("/service_worker.js", pageServiceWorker)
|
||||
|
||||
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.
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
|
||||
if authHeader == "" {
|
||||
authHeader = "Bearer " + r.URL.Query().Get("jwt")
|
||||
}
|
||||
|
||||
authParts := RxpBearerToken.FindStringSubmatch(authHeader)
|
||||
if len(authParts) != 2 {
|
||||
failed(fmt.Errorf("Authorization missing or invalid"))
|
||||
|
|
@ -391,6 +403,63 @@ func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{
|
|||
"OK": true,
|
||||
})
|
||||
} // }}}
|
||||
func actionSyncFileUpload(w http.ResponseWriter, r *http.Request) { // {{{
|
||||
uuid := r.PathValue("uuid")
|
||||
session := getUserSession(r)
|
||||
|
||||
sentBytes, _ := strconv.Atoi(r.Header.Get("X-File-Sent-Bytes"))
|
||||
totalBytes, _ := strconv.Atoi(r.Header.Get("X-File-Total-Bytes"))
|
||||
|
||||
// First block comes with file metadata.
|
||||
file := File{}
|
||||
if sentBytes == 0 {
|
||||
file.UUID = uuid
|
||||
file.Name = r.Header.Get("X-File-Name")
|
||||
file.Size, _ = strconv.Atoi(r.Header.Get("X-File-Size"))
|
||||
file.Type = r.Header.Get("X-File-Type")
|
||||
modded, _ := strconv.ParseInt(r.Header.Get("X-File-Modified"), 10, 64)
|
||||
file.Modified = time.UnixMilli(modded)
|
||||
err := AddFileToDb(session.UserID, &file)
|
||||
if err != nil {
|
||||
Log.Error("upload", "op", "AddFileToDB", "error", err)
|
||||
httpError(w, err)
|
||||
return
|
||||
}
|
||||
fileUUIDCache[uuid] = file.ID
|
||||
}
|
||||
|
||||
data, _ := io.ReadAll(r.Body)
|
||||
var err error
|
||||
|
||||
switch sentBytes {
|
||||
case 0:
|
||||
// First block of data.
|
||||
Log.Info("file_upload", "status", "new", "user", session.UserID, "uuid", uuid, "total", totalBytes)
|
||||
id := fileUUIDCache[uuid]
|
||||
err = FileWriteData(session.UserID, id, data, true)
|
||||
|
||||
case totalBytes:
|
||||
// Last block of data - sent with 0 length data.
|
||||
Log.Info("file_upload", "status", "done", "user", session.UserID, "uuid", uuid, "total", totalBytes)
|
||||
id := fileUUIDCache[uuid]
|
||||
delete(fileUUIDCache, uuid)
|
||||
_, err = db.Exec(`UPDATE public.file SET upload_complete = true WHERE id = $1`, id)
|
||||
|
||||
default:
|
||||
id := fileUUIDCache[uuid]
|
||||
err = FileWriteData(session.UserID, id, data, false)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
Log.Error("upload", "error", err)
|
||||
httpError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
responseData(w, map[string]any{
|
||||
"OK": true,
|
||||
})
|
||||
} // }}}
|
||||
|
||||
func actionUserGetPreferences(w http.ResponseWriter, r *http.Request) { // {{{
|
||||
user := getUserSession(r)
|
||||
|
|
@ -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) { // {{{
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
|
|
|
|||
61
node.go
61
node.go
|
|
@ -57,67 +57,6 @@ type Node struct {
|
|||
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) { // {{{
|
||||
var rows *sqlx.Rows
|
||||
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 })
|
||||
// An HTTP communication level error occured.
|
||||
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.
|
||||
const json = await res.json()
|
||||
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
|
||||
|
||||
} catch (err) {
|
||||
// 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 { N2Sidebar } from 'sidebar'
|
||||
import { Node } from 'node'
|
||||
import { N2PreferenceSet } from './page_preferences.mjs'
|
||||
|
||||
export class App {
|
||||
|
|
@ -66,11 +65,10 @@ export class App {
|
|||
|
||||
_mbus.subscribe('DEVICE_PREFERENCE_SET_UPDATED', ()=>{
|
||||
this.preferences = this.getPreferences()
|
||||
console.log(this.preferences.data)
|
||||
})
|
||||
|
||||
window.addEventListener('keydown', event => this.keyHandler(event))
|
||||
window.addEventListener('popstate', event => this.popState(event))
|
||||
globalThis.addEventListener('keydown', event => this.keyHandler(event))
|
||||
globalThis.addEventListener('popstate', event => this.popState(event))
|
||||
document.getElementById('notes2').addEventListener('click', event => {
|
||||
if (event.target.id === 'notes2')
|
||||
document.getElementById('node-content')?.focus()
|
||||
|
|
@ -81,12 +79,12 @@ export class App {
|
|||
|
||||
_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 haven't gotten the time to look at the page before stuff jumps around.
|
||||
// There a slight delay to initiate sync seems reasonable.
|
||||
setTimeout(() => window._sync.run(), 1000)
|
||||
setTimeout(() => globalThis._sync.run(), 1000)
|
||||
}// }}}
|
||||
keyHandler(event) {//{{{
|
||||
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 { API } from 'api'
|
||||
|
||||
export class N2File extends CustomHTMLElement {
|
||||
static {
|
||||
static {// {{{
|
||||
this.tmpl = document.createElement('template')
|
||||
this.tmpl.innerHTML = `
|
||||
<style>
|
||||
|
|
@ -30,24 +31,64 @@ export class N2File extends CustomHTMLElement {
|
|||
<img data-el="image" src="/images/${_VERSION}/file_icons/generic.svg">
|
||||
<div data-el="filename"></div>
|
||||
`
|
||||
}
|
||||
constructor() {
|
||||
}// }}}
|
||||
constructor() {// {{{
|
||||
super(true)
|
||||
|
||||
this.streamLocalFile = false
|
||||
this.uuid = null
|
||||
|
||||
this.addEventListener('click', event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
window.open(
|
||||
URL.createObjectURL(this.file),
|
||||
(event.ctrlKey || event.shiftKey) ? '_blank' : '_self',
|
||||
)
|
||||
// Download to disk.
|
||||
if (event.shiftKey) {
|
||||
this.downloadToDisk()
|
||||
return
|
||||
}
|
||||
|
||||
// Open in browser.
|
||||
this.openInBrowser(event)
|
||||
})
|
||||
|
||||
this.render()
|
||||
}// }}}
|
||||
openInBrowser(event) {// {{{
|
||||
if (this.streamLocalFile)
|
||||
window.open(
|
||||
`/stream-local?uuid=${this.metadata.UUID}`,
|
||||
event.ctrlKey ? '_blank' : '_self',
|
||||
)
|
||||
else {
|
||||
const jwt = localStorage.getItem('token')
|
||||
window.open(
|
||||
`/file/uuid/${this.uuid}?jwt=${jwt}`,
|
||||
event.ctrlKey ? '_blank' : '_self',
|
||||
)
|
||||
}
|
||||
}// }}}
|
||||
async downloadToDisk() {// {{{
|
||||
try {
|
||||
// TODO: implement download of remote content.
|
||||
if (this.streamLocalFile === false)
|
||||
throw new Error('This file is only available online. Download not implemented yet.')
|
||||
|
||||
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')
|
||||
|
||||
// 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.
|
||||
// populateImg makes sure this returned img element exists and then populates it
|
||||
// with the image from IndexedDB.
|
||||
const file = await globalThis.nodeStore.files.get(src.slice(5))
|
||||
if (!file)
|
||||
return
|
||||
this.file = file.file
|
||||
const fileUUID = src.slice(5)
|
||||
|
||||
if (file.file.type.startsWith('image/'))
|
||||
this.elImage.src = URL.createObjectURL(file.file)
|
||||
this.uuid = fileUUID
|
||||
const md = await globalThis.nodeStore.filesMetadata.get(fileUUID)
|
||||
|
||||
if (md) {
|
||||
this.metadata = md
|
||||
this.streamLocalFile = true
|
||||
this.renderLocalFile(md)
|
||||
} else {
|
||||
/* <file> can be undefined when a node is retrieved but files aren't synced.
|
||||
* Try to show from server.
|
||||
*
|
||||
* Metadata is needed to know whether to display an image or the file representation. */
|
||||
try {
|
||||
const res = await API.query('GET', `/file/uuid/${fileUUID}/metadata`)
|
||||
this.renderRemoteFile(res.Metadata)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
} else
|
||||
// Probably an external URL.
|
||||
this.elImage.src = src
|
||||
}// }}}
|
||||
async renderLocalFile(fileMetadata) {// {{{
|
||||
if (fileMetadata.type.startsWith('image/'))
|
||||
this.elImage.src = `/stream-local?uuid=${fileMetadata.UUID}`
|
||||
else {
|
||||
// Check for and use an existing MIME type icon.
|
||||
// Place them in static/images/file_icons/ and replace the slash with an underscore.
|
||||
const url = `/images/${_VERSION}/file_icons/${file.file.type.replaceAll('/', '_')}.svg`
|
||||
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 = file.file.name
|
||||
this.elFilename.innerText = fileMetadata.name
|
||||
this.elFilename.style.display = 'block'
|
||||
}
|
||||
} else
|
||||
this.elImage.src = src
|
||||
}// }}}
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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 ORPHANED_NODE = '00000000-0000-0000-0000-000000000001'
|
||||
export const DELETED_NODE = '00000000-0000-0000-0000-000000000002'
|
||||
|
||||
export class Node {
|
||||
static sort(a, b) {//{{{
|
||||
// Nodes with children ("folders") are sorted first.
|
||||
if (a._has_children && !b._has_children) return -1
|
||||
if (!a._has_children && b._has_children) return 1
|
||||
|
||||
// Otherwise sort by lowercased name.
|
||||
const an = a.data.Name.toLowerCase()
|
||||
const bn = b.data.Name.toLowerCase()
|
||||
if (an < bn) return -1
|
||||
if (an > bn) return 1
|
||||
return 0
|
||||
}//}}}
|
||||
static create(name, parentUUID) {// {{{
|
||||
const node = new Node({
|
||||
UUID: uuidv7(),
|
||||
Created: (new Date()).toISOString(),
|
||||
Content: '',
|
||||
Name: name,
|
||||
ParentUUID: parentUUID,
|
||||
Markdown: false,
|
||||
})
|
||||
|
||||
// Newly created node (not constructed from existing data) is considered modified
|
||||
// since node.save returns early if it isn't modified.
|
||||
node._modified = true
|
||||
|
||||
return node
|
||||
}// }}}
|
||||
|
||||
constructor(nodeData, level) {//{{{
|
||||
this.Level = level
|
||||
this.data = nodeData
|
||||
this.UUID = nodeData.UUID
|
||||
|
||||
// Toplevel nodes are normalized to have the ROOT_NODE as parent.
|
||||
if (nodeData.UUID !== ROOT_NODE && nodeData.ParentUUID === '') {
|
||||
this.ParentUUID = ROOT_NODE
|
||||
this.data.ParentUUID = ROOT_NODE
|
||||
} else
|
||||
this.ParentUUID = nodeData.ParentUUID
|
||||
|
||||
this._children_fetched = false
|
||||
this._has_children = null // this will be set by nodeStore.getTreeNodes
|
||||
this.Children = []
|
||||
this.Ancestors = []
|
||||
|
||||
this._sibling_before = null
|
||||
this._sibling_after = null
|
||||
this._parent = null
|
||||
|
||||
this.reset()
|
||||
}//}}}
|
||||
|
||||
reset() {// {{{
|
||||
// this._content is the runtime content in the editor.
|
||||
// this.data.Content is the original data.
|
||||
this._content = this.data.Content
|
||||
this._modified = false
|
||||
}// }}}
|
||||
get(prop) {//{{{
|
||||
return this.data[prop]
|
||||
}//}}}
|
||||
updated() {//{{{
|
||||
// '2024-12-17T17:33:48.85939Z
|
||||
return new Date(Date.parse(this.data.Updated))
|
||||
}//}}}
|
||||
isModified() {// {{{
|
||||
return this._modified
|
||||
}// }}}
|
||||
hasFetchedChildren() {//{{{
|
||||
return this._children_fetched
|
||||
}//}}}
|
||||
async fetchChildren() {//{{{
|
||||
this.Children = await globalThis.nodeStore.getTreeNodes(this.UUID, this.Level + 1)
|
||||
this._children_fetched = true
|
||||
|
||||
// Children are sorted to allow for storing siblings befare and after.
|
||||
// These are used with keyboard navigation in the tree.
|
||||
this.Children.sort(Node.sort)
|
||||
|
||||
const numChildren = this.Children.length
|
||||
this.setHasChildren(numChildren > 0)
|
||||
for (let i = 0; i < numChildren; i++) {
|
||||
if (i > 0)
|
||||
this.Children[i]._sibling_before = this.Children[i - 1]
|
||||
if (i < numChildren - 1)
|
||||
this.Children[i]._sibling_after = this.Children[i + 1]
|
||||
this.Children[i]._parent = this
|
||||
}
|
||||
|
||||
return this.Children
|
||||
}//}}}
|
||||
setHasChildren(v) {// {{{
|
||||
this._has_children = v
|
||||
}// }}}
|
||||
hasChildren() {//{{{
|
||||
return this._has_children
|
||||
}//}}}
|
||||
getSiblingBefore() {// {{{
|
||||
return this._sibling_before
|
||||
}// }}}
|
||||
getSiblingAfter() {// {{{
|
||||
return this._sibling_after
|
||||
}// }}}
|
||||
getParent() {//{{{
|
||||
return this._parent
|
||||
}//}}}
|
||||
moveToParent(newParentUUID) {// {{{
|
||||
if (this.UUID === newParentUUID)
|
||||
throw new Error("New parent UUID is the same as node UUID. Can't be your own parent.")
|
||||
|
||||
this.ParentUUID = newParentUUID
|
||||
this.data.ParentUUID = newParentUUID
|
||||
this._modified = true
|
||||
}// }}}
|
||||
isLastSibling() {//{{{
|
||||
return this._sibling_after === null
|
||||
}//}}}
|
||||
isFirstSibling() {//{{{
|
||||
return this._sibling_before === null
|
||||
}//}}}
|
||||
isSpecial() {// {{{
|
||||
return this.data.Special
|
||||
}// }}}
|
||||
content() {//{{{
|
||||
// TODO - implement crypto
|
||||
return this._content
|
||||
}//}}}
|
||||
setContent(new_content) {//{{{
|
||||
this._content = new_content
|
||||
this._modified = true
|
||||
_mbus.dispatch('NODE_MODIFIED', { node: this })
|
||||
}//}}}
|
||||
setName(new_name) {// {{{
|
||||
if (new_name.trim() === '')
|
||||
throw new Error(`The name can't be empty`)
|
||||
|
||||
this.data.Name = new_name
|
||||
this._modified = true
|
||||
_mbus.dispatch('NODE_MODIFIED', { node: this })
|
||||
}// }}}
|
||||
async save() {//{{{
|
||||
try {
|
||||
const dblink = /db:\/\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi
|
||||
|
||||
// Just safeguarding not using the root node,
|
||||
// which sort of exist but isn't supposed to communicate to server.
|
||||
if (this.UUID == ROOT_NODE)
|
||||
return
|
||||
|
||||
// File links from the original, unmodified content are found to compare to links from the new content.
|
||||
// All files (images) needs to be indexed to nodes in order to track orphaned files.
|
||||
const before = new Set([...this.data.Content.matchAll(dblink)].map(m => m[1]))
|
||||
const after = new Set([...this._content.matchAll(dblink)].map(m => m[1]))
|
||||
const removed = before.difference(after)
|
||||
const added = after.difference(before)
|
||||
|
||||
await globalThis.nodeStore.filesNodelink.removeFileUUIDs(this.UUID, removed)
|
||||
await globalThis.nodeStore.filesNodelink.addFileUUIDs(this.UUID, added)
|
||||
|
||||
// Authoritative content and metadata is set to prepare it for IndexedDB update.
|
||||
this.data.Content = this._content
|
||||
this.data.Updated = new Date().toISOString()
|
||||
this.data.HistoryUUID = uuidv7() // every time the node is saved a new history UUID identifies the changed node.
|
||||
|
||||
_mbus.dispatch('NODE_UNMODIFIED')
|
||||
this._modified = false
|
||||
|
||||
// When stored into database and ancestry was changed,
|
||||
// the ancestry path could be interesting.
|
||||
/*
|
||||
const ancestors = await nodeStore.getNodeAncestry(this)
|
||||
this.data.Ancestors = ancestors.map(a => a.get('Name')).reverse()
|
||||
*/
|
||||
|
||||
/* The node history is a local store for node history.
|
||||
* This could be provisioned from the server or cleared if
|
||||
* deemed unnecessary.
|
||||
*
|
||||
* The send queue is what will be sent back to the server
|
||||
* to have a recorded history of the notes.
|
||||
*
|
||||
* A setting to be implemented in the future could be to
|
||||
* not save the history locally at all. */
|
||||
|
||||
// Current node is added to history. It will be duplicated with the "nodes" store
|
||||
// for simplicity, to hopefully avoid bugs.
|
||||
const history = globalThis.nodeStore.nodesHistory.add(this)
|
||||
|
||||
// Updated node is added to the send queue to be stored on server.
|
||||
|
||||
const sendQueue = globalThis.nodeStore.sendQueue.add(this)
|
||||
|
||||
// Updated node is saved to the primary node store.
|
||||
const nodeStoreAdding = globalThis.nodeStore.add([this])
|
||||
|
||||
await Promise.all([history, sendQueue, nodeStoreAdding])
|
||||
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
alert(err.message)
|
||||
}
|
||||
|
||||
return
|
||||
}//}}}
|
||||
}
|
||||
|
||||
export class NodeStore {
|
||||
constructor() {//{{{
|
||||
if (!('indexedDB' in window)) {
|
||||
if (!('indexedDB' in self)) {
|
||||
throw 'Missing IndexedDB'
|
||||
}
|
||||
|
||||
|
|
@ -15,12 +221,13 @@ export class NodeStore {
|
|||
this.sendQueue = null
|
||||
this.nodesHistory = null
|
||||
this.files = null
|
||||
this.filesMetadata = null
|
||||
|
||||
this.initializeSpecialNodes()
|
||||
}//}}}
|
||||
initializeDB() {//{{{
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open('notes', 8)
|
||||
const req = indexedDB.open('notes', 14)
|
||||
|
||||
// Schema upgrades for IndexedDB.
|
||||
// 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:
|
||||
files = db.createObjectStore('files', { keyPath: 'UUID' })
|
||||
break
|
||||
|
||||
case 9:
|
||||
db.createObjectStore('files_metadata', { keyPath: 'UUID' })
|
||||
break
|
||||
|
||||
case 10:
|
||||
trx.objectStore('files_metadata').createIndex('byUploaded', 'uploaded', { unique: false })
|
||||
break
|
||||
|
||||
case 11:
|
||||
db.createObjectStore('files_nodelink', { keyPath: 'UUID' })
|
||||
break
|
||||
|
||||
case 12:
|
||||
trx.objectStore('files_nodelink').createIndex('byNodeCount', 'NodeCount', { unique: false })
|
||||
break
|
||||
|
||||
case 13:
|
||||
db.createObjectStore('file_segments', { keyPath: ['UUID', 'Sequence'] })
|
||||
break
|
||||
|
||||
case 14:
|
||||
db.deleteObjectStore('files')
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -80,6 +311,9 @@ export class NodeStore {
|
|||
this.sendQueue = new SimpleNodeStore(this.db, 'send_queue')
|
||||
this.nodesHistory = new NodeHistoryStore(this.db, 'nodes_history')
|
||||
this.files = new SimpleNodeStore(this.db, 'files')
|
||||
this.fileSegments = new FileSegmentsStore(this.db, 'file_segments')
|
||||
this.filesMetadata = new FilesMetadataStore(this.db, 'files_metadata')
|
||||
this.filesNodelink = new FileNodesLinkStore(this.db, 'files_nodelink')
|
||||
resolve()
|
||||
}
|
||||
|
||||
|
|
@ -221,7 +455,6 @@ export class NodeStore {
|
|||
nodeStore = t.objectStore('nodes')
|
||||
|
||||
t.oncomplete = (_event) => {
|
||||
console.log('complete')
|
||||
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 {
|
||||
|
|
@ -351,7 +644,6 @@ class SimpleNodeStore {
|
|||
// Node to be moved is first stored in the new queue.
|
||||
const req = store.put(node.data)
|
||||
req.onsuccess = () => {
|
||||
console.log('here')
|
||||
resolve()
|
||||
}
|
||||
req.onerror = (event) => {
|
||||
|
|
@ -460,7 +752,7 @@ class NodeHistoryStore extends SimpleNodeStore {
|
|||
}
|
||||
|
||||
req.onerror = (event) => {
|
||||
console.log(event.target.error)
|
||||
console.error(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() {// {{{
|
||||
// random bytes
|
||||
const value = new Uint8Array(16)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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'
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { ROOT_NODE, uuidv7 } from 'node_store'
|
||||
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
||||
import { MarkedPosition } from './marked_position.mjs'
|
||||
|
||||
|
|
@ -267,11 +266,11 @@ export class N2PageNodeUI extends CustomHTMLElement {
|
|||
const file = item.getAsFile()
|
||||
if (!file)
|
||||
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]
|
||||
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.
|
||||
this.node.setContent(this.elNodeContent.value)
|
||||
|
|
@ -398,196 +397,4 @@ export class N2PageNodeUI extends CustomHTMLElement {
|
|||
}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export class N2PagePreferences extends CustomHTMLElement {
|
|||
|
||||
this.elNewSet.addEventListener('click', () => this.newSet())
|
||||
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 => {
|
||||
if (event.detail.data?.page == 'preferences') {
|
||||
|
|
@ -71,7 +71,7 @@ export class N2PagePreferences extends CustomHTMLElement {
|
|||
this.sets.sort(this.sortSets)
|
||||
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')
|
||||
optn.innerText = set.name
|
||||
return optn
|
||||
|
|
@ -106,7 +106,7 @@ export class N2PagePreferences extends CustomHTMLElement {
|
|||
alert(`Error retrieving preferences: ${e.message}`)
|
||||
}
|
||||
}// }}}
|
||||
changePreferenceSet(event) {// {{{
|
||||
changePreferenceSet() {// {{{
|
||||
this.preferencesModified()
|
||||
}// }}}
|
||||
newSet() {// {{{
|
||||
|
|
@ -168,6 +168,8 @@ export class N2PagePreferences extends CustomHTMLElement {
|
|||
user.Preferences = newPrefs
|
||||
localStorage.setItem('user', JSON.stringify(user))
|
||||
localStorage.setItem('device_preference_set', this.elDevPreferenceSet.value)
|
||||
|
||||
// Application is told that reloading preferences is to be done.
|
||||
_mbus.dispatch('DEVICE_PREFERENCE_SET_UPDATED')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
|
|
|
|||
|
|
@ -1,17 +1,51 @@
|
|||
import { CustomHTMLElement } from "./lib/custom_html_element.mjs"
|
||||
import { API } from 'api'
|
||||
|
||||
export class N2PageStorage extends CustomHTMLElement {
|
||||
static {
|
||||
this.tmpl = document.createElement('template')
|
||||
this.tmpl.innerHTML = `
|
||||
<style>
|
||||
.table {
|
||||
display: grid;
|
||||
grid-template-columns: min-content min-content;
|
||||
grid-gap: 4px 16px;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: #ff2a2a;
|
||||
color: #fff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h1>Local storage</h1>
|
||||
<div class="table">
|
||||
<div>Local nodes</div>
|
||||
<div data-el="count-nodes"></div>
|
||||
|
||||
<div>Queued to sync</div>
|
||||
<div data-el="count-queued-nodes"></div>
|
||||
|
||||
<div>History nodes</div>
|
||||
<div data-el="count-history-nodes"></div>
|
||||
</div>
|
||||
|
||||
<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() {
|
||||
super()
|
||||
super(true)
|
||||
|
||||
window._mbus.subscribe('SHOW_PAGE', event => {
|
||||
if (event.detail.data?.page == 'storage')
|
||||
|
|
@ -19,13 +53,21 @@ export class N2PageStorage extends CustomHTMLElement {
|
|||
})
|
||||
}
|
||||
async render() {
|
||||
const countNodes = await globalThis.nodeStore.nodeCount()
|
||||
const countQueuedNodes = await globalThis.nodeStore.sendQueue.count()
|
||||
const countHistoryNodes = await globalThis.nodeStore.nodesHistory.count()
|
||||
API.query('GET', '/file/count')
|
||||
.then(res => {
|
||||
this.elCountFilesOnServer.innerText = res.Count
|
||||
})
|
||||
.catch(err => {
|
||||
this.elCountFilesOnServer.classList.add('error')
|
||||
console.error(err)
|
||||
this.elCountFilesOnServer.innerText = err.message
|
||||
})
|
||||
|
||||
this.elCountNodes.innerText = countNodes
|
||||
this.elCountQueuedNodes.innerText = countQueuedNodes
|
||||
this.elCountHistoryNodes.innerText = countHistoryNodes
|
||||
|
||||
this.elCountNodes.innerText = await globalThis.nodeStore.nodeCount()
|
||||
this.elCountQueuedNodes.innerText = await globalThis.nodeStore.sendQueue.count()
|
||||
this.elCountHistoryNodes.innerText = await globalThis.nodeStore.nodesHistory.count()
|
||||
this.elCountQueuedFiles.innerText = await globalThis.nodeStore.filesMetadata.countToUpload()
|
||||
}
|
||||
}
|
||||
customElements.define('n2-pagestorage', N2PageStorage)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { ROOT_NODE, ORPHANED_NODE, DELETED_NODE } from 'node_store'
|
||||
import { Node } from 'node'
|
||||
import { ROOT_NODE, ORPHANED_NODE, DELETED_NODE, Node } from 'node_store'
|
||||
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
||||
import { Color, Solver } from './lib/css_colorize.mjs'
|
||||
|
||||
|
|
@ -82,11 +81,16 @@ export class N2Sidebar extends CustomHTMLElement {
|
|||
|
||||
.icons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
justify-content: start;
|
||||
margin-top: 16px;
|
||||
gap: 8px;
|
||||
gap: 8px 16px;
|
||||
padding-left: 40px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--line-color);
|
||||
|
||||
img {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -104,6 +108,7 @@ export class N2Sidebar extends CustomHTMLElement {
|
|||
</div>
|
||||
<div class="icons">
|
||||
<img data-el="sync" class='sync colorize' src="/images/${_VERSION}/icon_refresh.svg" />
|
||||
<img data-el="storage" class='storage colorize' src="/images/${_VERSION}/icon_storage.svg" />
|
||||
<img data-el="search" class='search colorize' src="/images/${_VERSION}/icon_search.svg" style="height: 22px" />
|
||||
<img data-el="settings" class='settings colorize' src="/images/${_VERSION}/icon_settings.svg" />
|
||||
</div>
|
||||
|
|
@ -127,6 +132,7 @@ export class N2Sidebar extends CustomHTMLElement {
|
|||
this.addEventListener('keydown', event => this.keyHandler(event))
|
||||
this.elSearch.addEventListener('click', () => _mbus.dispatch('op-search'))
|
||||
this.elSync.addEventListener('click', () => _sync.run())
|
||||
this.elStorage.addEventListener('click', () => _mbus.dispatch('SHOW_PAGE', { page: 'storage' }))
|
||||
this.elLogo.addEventListener('click', () => _app.goToNode(ROOT_NODE, false, false))
|
||||
this.elSettings.addEventListener('click', ()=> _mbus.dispatch('SHOW_PAGE', { page: 'preferences' }))
|
||||
this.elHideTree.addEventListener('click', event => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { API } from 'api'
|
||||
import { Node } from 'node'
|
||||
import { Node } from 'node_store'
|
||||
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { NodeStore } from '/js/{{ .VERSION }}/node_store.mjs'
|
||||
|
||||
const CACHE_NAME = 'notes2-{{ .VERSION }}'
|
||||
const CACHED_ASSETS = [
|
||||
'/',
|
||||
|
|
@ -11,16 +13,21 @@ const CACHED_ASSETS = [
|
|||
|
||||
'/images/{{ .VERSION }}/collapsed.svg',
|
||||
'/images/{{ .VERSION }}/expanded.svg',
|
||||
'/images/{{ .VERSION }}/icon_back.svg',
|
||||
'/images/{{ .VERSION }}/icon_history.svg',
|
||||
'/images/{{ .VERSION }}/icon_home.svg',
|
||||
'/images/{{ .VERSION }}/icon_markdown_hollow.svg',
|
||||
'/images/{{ .VERSION }}/icon_markdown.svg',
|
||||
'/images/{{ .VERSION }}/icon_menu.svg',
|
||||
'/images/{{ .VERSION }}/icon_new_document.svg',
|
||||
'/images/{{ .VERSION }}/icon_refresh.svg',
|
||||
'/images/{{ .VERSION }}/icon_save_disabled.svg',
|
||||
'/images/{{ .VERSION }}/icon_save.svg',
|
||||
'/images/{{ .VERSION }}/icon_search.svg',
|
||||
'/images/{{ .VERSION }}/icon_settings.svg',
|
||||
'/images/{{ .VERSION }}/icon_storage.svg',
|
||||
'/images/{{ .VERSION }}/icon_table.svg',
|
||||
'/images/{{ .VERSION }}/icon_transfer.svg',
|
||||
'/images/{{ .VERSION }}/leaf.svg',
|
||||
'/images/{{ .VERSION }}/logo_small.svg',
|
||||
'/images/{{ .VERSION }}/logo.svg',
|
||||
|
|
@ -28,20 +35,18 @@ const CACHED_ASSETS = [
|
|||
'/js/{{ .VERSION }}/api.mjs',
|
||||
'/js/{{ .VERSION }}/app.mjs',
|
||||
'/js/{{ .VERSION }}/checklist.mjs',
|
||||
'/js/{{ .VERSION }}/crypto.mjs',
|
||||
'/js/{{ .VERSION }}/file.mjs',
|
||||
'/js/{{ .VERSION }}/key.mjs',
|
||||
'/js/{{ .VERSION }}/lib/css_colorize.mjs',
|
||||
'/js/{{ .VERSION }}/lib/custom_html_element.mjs',
|
||||
'/js/{{ .VERSION }}/lib/node_modules/marked/lib/marked.esm.js',
|
||||
'/js/{{ .VERSION }}/lib/node_modules/marked-token-position/lib/index.esm.js',
|
||||
'/js/{{ .VERSION }}/lib/sjcl.js',
|
||||
'/js/{{ .VERSION }}/marked_position.mjs',
|
||||
'/js/{{ .VERSION }}/mbus.mjs',
|
||||
'/js/{{ .VERSION }}/node_store.mjs',
|
||||
'/js/{{ .VERSION }}/notes2.mjs',
|
||||
'/js/{{ .VERSION }}/page_history.mjs',
|
||||
'/js/{{ .VERSION }}/page_node.mjs',
|
||||
'/js/{{ .VERSION }}/page_preferences.mjs',
|
||||
'/js/{{ .VERSION }}/page_storage.mjs',
|
||||
'/js/{{ .VERSION }}/sidebar.mjs',
|
||||
'/js/{{ .VERSION }}/sync.mjs',
|
||||
|
|
@ -120,14 +125,55 @@ self.addEventListener('activate', event => {
|
|||
})
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const url = new URL(event.request.url)
|
||||
|
||||
// The fetch event is also seeing requests to other domains.
|
||||
// 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)
|
||||
return event
|
||||
|
||||
if (url.pathname == '/stream-local') {
|
||||
event.respondWith(indexeddbFile(event))
|
||||
return
|
||||
}
|
||||
|
||||
if (`{{ .DevMode }}` == 'true')
|
||||
return event
|
||||
|
||||
else
|
||||
event.respondWith(fetchAsset(event))
|
||||
})
|
||||
|
||||
|
||||
let nodestore = null
|
||||
async function getNodestore() {
|
||||
if (nodestore)
|
||||
return nodestore
|
||||
|
||||
// When setting nodestore directly, then waiting on initializeDB,
|
||||
// a second call for getNodestore can return the nodestore before
|
||||
// initialized.
|
||||
const initializingNodeStore = new NodeStore()
|
||||
await initializingNodeStore.initializeDB()
|
||||
nodestore = initializingNodeStore
|
||||
return nodestore
|
||||
}
|
||||
|
||||
async function indexeddbFile(event) {
|
||||
const url = new URL(event.request.url)
|
||||
const uuid = url.searchParams.get('uuid')
|
||||
|
||||
try {
|
||||
const nodeStore = await getNodestore()
|
||||
const md = await nodeStore.filesMetadata.get(uuid)
|
||||
const stream = await nodeStore.fileSegments.getStream(uuid)
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': md.type,
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,7 @@
|
|||
"imports": {
|
||||
"api": "/js/{{ .VERSION }}/api.mjs",
|
||||
"sync": "/js/{{ .VERSION }}/sync.mjs",
|
||||
"key": "/js/{{ .VERSION }}/key.mjs",
|
||||
"checklist": "/js/{{ .VERSION }}/checklist.mjs",
|
||||
"crypto": "/js/{{ .VERSION }}/crypto.mjs",
|
||||
"node_store": "/js/{{ .VERSION }}/node_store.mjs",
|
||||
"node": "/js/{{ .VERSION }}/page_node.mjs",
|
||||
"sidebar": "/js/{{ .VERSION }}/sidebar.mjs"
|
||||
|
|
@ -20,14 +18,14 @@
|
|||
}
|
||||
</script>
|
||||
<script>
|
||||
window._VERSION = "{{ .VERSION }}"
|
||||
globalThis._VERSION = "{{ .VERSION }}"
|
||||
|
||||
if (navigator.serviceWorker)
|
||||
navigator.serviceWorker.register('/service_worker.js')
|
||||
navigator.serviceWorker.register('/service_worker.js', { type: 'module' })
|
||||
</script>
|
||||
<script type="module" defer>
|
||||
import { MessageBus } from '/js/{{ .VERSION }}/mbus.mjs'
|
||||
window._mbus = new MessageBus()
|
||||
globalThis._mbus = new MessageBus()
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<img id="logo" src="/images/v1/logo.svg">
|
||||
<input type="text" id="username" placeholder="Username">
|
||||
<input type="password" id="password" placeholder="Password">
|
||||
<button onclick=window._login.authenticate()>Log in</button>
|
||||
<button onclick="globalThis._login.authenticate()">Log in</button>
|
||||
<div id="error"></div>
|
||||
</div>
|
||||
|
||||
|
|
@ -42,6 +42,6 @@ class Login {
|
|||
}
|
||||
}
|
||||
|
||||
window._login = new Login()
|
||||
globalThis._login = new Login()
|
||||
</script>
|
||||
{{ end }}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
<!-- 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="main-page">
|
||||
|
|
@ -47,20 +47,21 @@
|
|||
import {App} from "/js/{{ .VERSION }}/app.mjs"
|
||||
import {API} from 'api'
|
||||
import {Sync} from 'sync'
|
||||
import { } from '/js/{{ .VERSION }}/page_node.mjs'
|
||||
import { } from '/js/{{ .VERSION }}/page_preferences.mjs'
|
||||
import { } from '/js/{{ .VERSION }}/page_storage.mjs'
|
||||
import { } from '/js/{{ .VERSION }}/page_history.mjs'
|
||||
import { } from '/js/{{ .VERSION }}/file.mjs'
|
||||
|
||||
window.Sync = Sync
|
||||
globalThis.Sync = Sync
|
||||
|
||||
if (!API.hasAuthenticationToken()) {
|
||||
location.href = '/login'
|
||||
} else {
|
||||
try {
|
||||
window.nodeStore = new NodeStore()
|
||||
window.nodeStore.initializeDB().then(() => {
|
||||
window._app = new App()
|
||||
globalThis.nodeStore = new NodeStore()
|
||||
globalThis.nodeStore.initializeDB().then(() => {
|
||||
globalThis._app = new App()
|
||||
})
|
||||
} catch (e) {
|
||||
alert(e)
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@
|
|||
<script type="module">
|
||||
import { NodeStore } from 'node_store'
|
||||
import { Sync } from 'sync'
|
||||
window.Sync = Sync
|
||||
window.nodeStore = new NodeStore()
|
||||
window.nodeStore.initializeDB().then(()=>{
|
||||
globalThis.Sync = Sync
|
||||
globalThis.nodeStore = new NodeStore()
|
||||
globalThis.nodeStore.initializeDB().then(()=>{
|
||||
Sync.tree()
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue