diff --git a/file.go b/file.go index e070d9f..66e07e8 100644 --- a/file.go +++ b/file.go @@ -124,6 +124,11 @@ func FileWriteData(userID int, dbID int, data []byte, create bool) (err error) { 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 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) diff --git a/main.go b/main.go index a30affd..50d217f 100644 --- a/main.go +++ b/main.go @@ -148,6 +148,7 @@ func main() { // {{{ 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/{uuid}", authenticated(actionFile)) http.HandleFunc("/file/{uuid}/metadata", authenticated(actionFileMetadata)) @@ -500,9 +501,22 @@ 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 actionFile(w http.ResponseWriter, r *http.Request) {// {{{ uuid := r.PathValue("uuid") - Log.Info("FOO", "uuid", uuid) session := getUserSession(r) f, md, err := FileForUser(session.UserID, uuid) if err != nil { diff --git a/sql/00013.sql b/sql/00013.sql new file mode 100644 index 0000000..ef7e72c --- /dev/null +++ b/sql/00013.sql @@ -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$ +; diff --git a/static/images/icon_storage.svg b/static/images/icon_storage.svg new file mode 100644 index 0000000..563d355 --- /dev/null +++ b/static/images/icon_storage.svg @@ -0,0 +1,49 @@ + + + + diff --git a/static/js/node_store.mjs b/static/js/node_store.mjs index 3ff1843..a878ccf 100644 --- a/static/js/node_store.mjs +++ b/static/js/node_store.mjs @@ -98,7 +98,7 @@ 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.filesMetadata = new SimpleNodeStore(this.db, 'files_metadata') + this.filesMetadata = new FilesMetadataStore(this.db, 'files_metadata') this.filesNodelink = new FileNodesLinkStore(this.db, 'files_nodelink') resolve() } @@ -581,6 +581,20 @@ class NodeHistoryStore extends SimpleNodeStore { }// }}} } +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) { @@ -597,7 +611,7 @@ class FileNodesLinkStore extends SimpleNodeStore { // Maintains an index to quickly find orphaned files. nodelink.NodeCount = nodelink.Nodes.length - await this.add({data: nodelink}) + await this.add({ data: nodelink }) } }// }}} async addFileUUIDs(nodeUUID, fileUUIDs) {// {{{ @@ -619,7 +633,7 @@ class FileNodesLinkStore extends SimpleNodeStore { // Maintains an index to quickly find orphaned files. nodelink.NodeCount = nodelink.Nodes.length - await this.add({data: nodelink}) + await this.add({ data: nodelink }) } }// }}} } diff --git a/static/js/page_storage.mjs b/static/js/page_storage.mjs index a007130..96f50ac 100644 --- a/static/js/page_storage.mjs +++ b/static/js/page_storage.mjs @@ -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 = ` + +