File count on storage page
This commit is contained in:
parent
6df2be7944
commit
65f8cd14a7
8 changed files with 319 additions and 16 deletions
5
file.go
5
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)
|
||||
|
|
|
|||
16
main.go
16
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 {
|
||||
|
|
|
|||
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 |
|
|
@ -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 })
|
||||
}
|
||||
}// }}}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 data-el="count-nodes"></div>
|
||||
<div data-el="count-queued-nodes"></div>
|
||||
<div data-el="count-history-nodes"></div>
|
||||
<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)
|
||||
|
|
|
|||
|
|
@ -82,11 +82,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 +109,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 +133,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 => {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const CACHED_ASSETS = [
|
|||
'/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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue