From 0abeb90cb0a8f63d0e46916554cc2e45376f0eb4 Mon Sep 17 00:00:00 2001 From: james fitzsimons Date: Thu, 18 Dec 2025 18:12:23 +0000 Subject: [PATCH] Add tag tree API and frontend tag decryption - Implement backend tag tree endpoint at /tags/tree with TagTreeNode and TagTreeResponse models - Add frontend tag tree decryption logic and wire it into notes decryption flow - Fetch and decrypt tag tree in tags.tsx; integrate with tag store - Add UI toggle for folders vs tags and update Sidebar and Header --- .gitignore | 1 + .../routes/__pycache__/tags.cpython-314.pyc | Bin 3789 -> 5377 bytes backend/app/routes/tags.py | 36 +++++- frontend/src/api/encryption.tsx | 23 ++++ frontend/src/api/notes.tsx | 10 +- frontend/src/api/tags.tsx | 37 +----- frontend/src/pages/Home/Home.tsx | 9 +- .../pages/Home/components/sidebar/SideBar.tsx | 122 ++++++++++-------- .../sidebar/subcomponents/SideBarHeader.tsx | 21 ++- frontend/src/stores/notesStore.ts | 2 +- frontend/src/stores/uiStore.ts | 7 + 11 files changed, 165 insertions(+), 103 deletions(-) diff --git a/.gitignore b/.gitignore index a1a24f0..58926dc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules frontend/src/assets/fontawesome/svg/* frontend/src/assets/fontawesome/svg/0.svg *.db +.zed/settings.json diff --git a/backend/app/routes/__pycache__/tags.cpython-314.pyc b/backend/app/routes/__pycache__/tags.cpython-314.pyc index b35bedb230ee5e4c74af59e575ac0055f332ea6e..bd6a99d2430460d7e61e7c274c210a9a1c92bafa 100644 GIT binary patch delta 2576 zcma)7O>7fK6rR~#|E^=l@lXB}hlB)^kO%<{ND<*TO(03b5`qv3?qV;*s$;veYf7n* zgDSP8MUA3Gty&?K0|)4-sF%`1tDbtQ1PVfQ1*I1bNUdt1G~(9x#xV)uM_p;(ym@cl zH*en)X4kebLRsai%4}i;80M{#i zkXE47Xn2LI0YuE>hASYc5(MMW3dlx)d6}!>=Nx;ro0WiBwE{CTaj<%1=S$9C{T%4> zYpem7+7&RXl)7dTt$%<(QEG{!Z;hDuX@t5=yRf-V9#xNzGEE!KBs9ynI8ih`n@Q_t zkFd^1Esw6Hw78K>r!s27oD|w=$UHCX3SJQ&V8AVhuIYL*lfFV(!2DBCZH*wUG!^ku zcMv#+xn_xKDisx2Eeb+FbTn;^um)7RQc2y28R~JJwZJ@Vu9Mnn#5^RW_tcO*J(1hHU^{t@)${BsrlEE-*{ST_w#JEhX5nu(zBL7ioZ1S*nokJbD9PGYA^F zFbSuIKy-l6lG5swW;x@N$y5SD6}9nO;X2k03)p5{6*!s$Jzj9kfXs`fA@$%E`K8*p z?egScSXP|i4Ia>YfliYULcaq{`8TZ1ou$g1{mK2x7$6ZY+dMr`IWFW#;S3nIv}qLBepdi za}C4jq^%E;TxjFNi}7>7LLR>!YL44#%$RdM)ZH)6R`Yw&Z%VF!IqQ@|n7=DNg_wUk zL)33Jd1~@mIz}N{!m?-yu2>d0gbsA-*5U>7U~&i3A<#x%>7Re2pI6Ra7Hc!0_fMdD>?d5ugqmeNjWsbWp@ z$Vzz8I%a2UB(hldp5q!Xd!7eu2k5Z*>nDIrlRrXrUyRR<-*M)>=k!nId#>`C%JX#> z;$Nq}OwIe-=UpB5T;7?`Ieqr@%;}5L4PVQ=YxQG?6t*uoiNk#+{-IWI5nJG)hpg|N zmwT^zF9&XV8ZU~sJS|tf)4Q(Ay?5lAi_%Sb^>u0W(?=rlY@<3B%Fo@w9o5pQx~_D_ z(7HY_qmENUybn205KAU3J0>rjzt}v$Bq~~BGHv|7R9b4($SKwi_W_ z%VOX}=~g7q^p@>dlIaHPL}?cq*q4;0Hs)|Q*YY;zQxl6NmVp8@G&Tqp+sxhWPoS}0 zcOUNNjSTlKwo-xhA&*)0y7WJ29+jH`;UoF4xBFr3DH(1el7I)o&=OIbB|=1t{m>6u z%U;wJ-4*#Ui%$TigpQTbT6zQI;?knC`$02i&U*%jBESttn=QZb4&1NCI^&R(hMG2X zhUS)2&1O5*oH5BTwJZ-#6RM#eRpFJuI=9>?nt~pa(#=C<4f$eE!hGe?vK+@$0L&(t z4@J=K2;;tc^>Yma1L`v>;MS?~2 zAXx|#`o;~i=`LwA%e==ro;1KqQm7z;^W&N`HD}+Pr@`M^+Ar^&Z`u4WHmS!V5kdt4 NxI);>c}FGO{y!`X^?v{W delta 1236 zcma))%}*0i5WwHtZFjrw1m4=^Ss*2JjUcr%5pc9AAaTi-t7u`%8> zA%XWVc;Vnl4<0;u_dp^s*@SRZ4kjG+=FHYgArjrg?7Vq1znM2P&nG?&MJJRYgad6FbtRh|3c+A+E|dQ@XT|opx2na-2r1sZ$mcLN@)Uz#0@k<=2%s zXiP&V*|cq4Vi`0fKsXpWHq3H(so4#OFLCpU!wPWL#An|m&2RdfG%Z^G>?MrSWrpj) zLMUQrpezeN2WL6raTq4{L`=N*mudd(H-C&$P5e;Kg-Uogf+8cz>Tt$O9DK|ylUtaY z;f$lgQWt%B(8O-|1!OlP=}I>UY`W~qjVAxU5{lwYRw}1 z&|Lqs6^mKykB94DZZL`Os&31!@!NejcyvhR3Q`>X)-siR0Rak2cie>ZYg}deY+AZiV4%_P$ zTR;tq+ae4r>uzM=8j#j(Yk^h4hAZamfH;t8meO5P+9hZ9i1CFO+r-!-v)g1=WK+x1 MK|yS#9(W7%3)?f>Bme*a diff --git a/backend/app/routes/tags.py b/backend/app/routes/tags.py index 8e103ed..566c4eb 100644 --- a/backend/app/routes/tags.py +++ b/backend/app/routes/tags.py @@ -1,7 +1,20 @@ +from tkinter.constants import TOP + from app.auth import require_auth from app.database import get_session -from app.models import Note, NoteCreate, NoteTag, NoteUpdate, Tag, TagCreate, User +from app.models import ( + Note, + NoteCreate, + NoteTag, + NoteUpdate, + Tag, + TagCreate, + TagTreeNode, + TagTreeResponse, + User, +) from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import selectinload from sqlmodel import Session, select router = APIRouter(prefix="/tags", tags=["tags"]) @@ -27,6 +40,27 @@ def create_tag( return db_tag +def build_tag_tree_node(tag: Tag) -> TagTreeNode: + return TagTreeNode( + id= tag.id, + name = tag.name, + children = [build_tag_tree_node(child) for child in tag.children] + ) + + + +@router.get("/tree") +def get_tag_tree(session: Session = Depends(get_session)): + top_level_tags = session.exec( + select(Tag) + .options(selectinload(Tag.children)) + .where(Tag.parent_id == None) + ).all() + + tree = [build_tag_tree_node(tag) for tag in top_level_tags] + return TagTreeResponse(tags=tree) + + @router.post("/note/{note_id}/tag/{tag_id}") def add_tag_to_note( note_id: int, diff --git a/frontend/src/api/encryption.tsx b/frontend/src/api/encryption.tsx index 3929f72..cd10770 100644 --- a/frontend/src/api/encryption.tsx +++ b/frontend/src/api/encryption.tsx @@ -1,4 +1,5 @@ import { FolderTreeResponse, FolderTreeNode } from "./folders"; +import { Tag } from "./tags"; export async function deriveKey(password: string, salt: string) { const enc = new TextEncoder(); @@ -147,3 +148,25 @@ export async function decryptFolderTree( ), }; } + +export const decryptTagTree = async ( + tags: Tag[], + key: CryptoKey, + parentPath = "", +): Promise => { + return Promise.all( + tags.map(async (tag) => { + const decryptedName = await decryptString(tag.name, key); + const currentPath = parentPath + ? `${parentPath} › ${decryptedName}` + : decryptedName; + + return { + ...tag, + name: decryptedName, + parent_path: parentPath, + children: await decryptTagTree(tag.children, key, currentPath), + }; + }), + ); +}; diff --git a/frontend/src/api/notes.tsx b/frontend/src/api/notes.tsx index 2f4a481..8bb6caa 100644 --- a/frontend/src/api/notes.tsx +++ b/frontend/src/api/notes.tsx @@ -51,10 +51,12 @@ const fetchNotes = async () => { ...note, title: await decryptString(note.title, encryptionKey), content: await decryptString(note.content, encryptionKey), - tags: note.tags.map(async (tag) => ({ - ...tag, - name: await decryptString(tag.name, encryptionKey), - })), + tags: await Promise.all( + note.tags.map(async (tag) => ({ + ...tag, + name: await decryptString(tag.name, encryptionKey), + })), + ), })), ); return decryptedNotes; diff --git a/frontend/src/api/tags.tsx b/frontend/src/api/tags.tsx index d4422ef..6544137 100644 --- a/frontend/src/api/tags.tsx +++ b/frontend/src/api/tags.tsx @@ -1,5 +1,5 @@ import axios from "axios"; -import { encryptString, decryptString } from "./encryption"; +import { encryptString, decryptTagTree } from "./encryption"; import { useAuthStore } from "../stores/authStore"; axios.defaults.withCredentials = true; const API_URL = (import.meta as any).env.PROD @@ -20,42 +20,13 @@ export interface TagCreate { parent_id?: number; } -const buildTagTree = ( - tags: Tag[], - parent_id: string | number | null = null, - parentPath = "", -): Tag[] => { - const result: Tag[] = []; - for (const tag of tags) { - if (tag.parent_id == parent_id) { - tag.parent_path = parentPath; - - const currentPath = parentPath ? `${parentPath} › ${tag.name}` : tag.name; - - tag.children = buildTagTree(tags, tag.id, currentPath); - result.push(tag); - } - } - return result; -}; - const fetchTags = async () => { const encryptionKey = useAuthStore.getState().encryptionKey; if (!encryptionKey) throw new Error("Not authenticated"); - const { data } = await axios.get(`${API_URL}/tags/`); - - const decryptedTags = await Promise.all( - data.map(async (tag: Tag) => ({ - ...tag, - name: await decryptString(tag.name, encryptionKey), - })), - ); - - const tags = buildTagTree(decryptedTags); - - console.log(tags); - + const { data } = await axios.get(`${API_URL}/tags/tree`); + const tags = decryptTagTree(data.tags, encryptionKey); + console.log(await tags); return tags; }; diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index a4fdd51..0cdd481 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -105,20 +105,19 @@ function Home() { {showModal && } + {/*
setTagName(e.target.value)} /> - {tags.map((tag) => ( ))}
*/} - {/* Main editor area */}
@@ -178,16 +177,16 @@ const Modal = () => { ); }; -const TagSelector = () => { +export const TagSelector = () => { const { tagTree } = useTagStore(); const [value, setValue] = useState(""); return (
- setValue(e.target.value)} - /> + />*/} {tagTree && tagTree.map((tag) => )}
); diff --git a/frontend/src/pages/Home/components/sidebar/SideBar.tsx b/frontend/src/pages/Home/components/sidebar/SideBar.tsx index 9e8578a..5f31d45 100644 --- a/frontend/src/pages/Home/components/sidebar/SideBar.tsx +++ b/frontend/src/pages/Home/components/sidebar/SideBar.tsx @@ -2,6 +2,8 @@ import React, { useState, useRef, useEffect, SetStateAction } from "react"; // @ts-ignore import FolderIcon from "@/assets/fontawesome/svg/folder.svg?react"; +// @ts-ignore +import TagsIcon from "@/assets/fontawesome/svg/tags.svg?react"; import { DraggableNote } from "./subcomponents/DraggableNote"; import { @@ -19,6 +21,7 @@ import { SidebarHeader } from "./subcomponents/SideBarHeader.tsx"; import { useAuthStore } from "@/stores/authStore.ts"; import { useNoteStore } from "@/stores/notesStore.ts"; import { useUIStore } from "@/stores/uiStore.ts"; +import { TagSelector } from "../../Home.tsx"; export const Sidebar = () => { const [newFolder, setNewFolder] = useState(false); @@ -39,7 +42,8 @@ export const Sidebar = () => { const { encryptionKey } = useAuthStore(); - const { setSideBarResize, sideBarResize } = useUIStore(); + const { setSideBarResize, sideBarResize, sideBarView, setSideBarView } = + useUIStore(); useEffect(() => { if (newFolder && newFolderRef.current) { newFolderRef.current.focus(); @@ -169,65 +173,73 @@ export const Sidebar = () => { style={{ width: `${sideBarResize}px` }} > -
e.preventDefault()} - onTouchMove={(e) => e.preventDefault()} - > - {/* New folder input */} - {newFolder && ( -
- setNewFolder(false)} - onChange={(e) => setNewFolderText(e.target.value)} - value={newFolderText} - type="text" - placeholder="Folder name..." - className="standard-input" - ref={newFolderRef} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleCreateFolder(); - } - if (e.key === "Escape") { - setNewFolder(false); - } - }} - /> -
- )} + {sideBarView == "folders" ? ( + <> +
e.preventDefault()} + onTouchMove={(e) => e.preventDefault()} + > + {/* New folder input */} + {newFolder && ( +
+ setNewFolder(false)} + onChange={(e) => setNewFolderText(e.target.value)} + value={newFolderText} + type="text" + placeholder="Folder name..." + className="standard-input" + ref={newFolderRef} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleCreateFolder(); + } + if (e.key === "Escape") { + setNewFolder(false); + } + }} + /> +
+ )} - {/* Folder tree */} -
- {folderTree?.folders.map((folder) => ( - - ))} -
- - {/* Orphaned notes */} - {folderTree?.orphaned_notes && - folderTree.orphaned_notes.length > 0 && ( -
- {folderTree.orphaned_notes.map((note) => ( - + {/* Folder tree */} +
+ {folderTree?.folders.map((folder) => ( + ))}
- )} -
- - {activeItem?.type === "note" && ( -
- {activeItem.data.title} + {/* Orphaned notes */} + {folderTree?.orphaned_notes && + folderTree.orphaned_notes.length > 0 && ( +
+ {folderTree.orphaned_notes.map((note) => ( + + ))} +
+ )}
- )} - {activeItem?.type === "folder" && ( -
- - {activeItem.data.name} -
- )} -
+ + + {activeItem?.type === "note" && ( +
+ {activeItem.data.title} +
+ )} + {activeItem?.type === "folder" && ( +
+ + {activeItem.data.name} +
+ )} +
+ + ) : ( +
+ +
+ )}
diff --git a/frontend/src/pages/Home/components/sidebar/subcomponents/SideBarHeader.tsx b/frontend/src/pages/Home/components/sidebar/subcomponents/SideBarHeader.tsx index 2af39d4..218edfc 100644 --- a/frontend/src/pages/Home/components/sidebar/subcomponents/SideBarHeader.tsx +++ b/frontend/src/pages/Home/components/sidebar/subcomponents/SideBarHeader.tsx @@ -2,8 +2,11 @@ import { SetStateAction } from "react"; // @ts-ignore import FolderPlusIcon from "@assets/fontawesome/svg/folder-plus.svg?react"; // @ts-ignore +import TagsIcon from "@assets/fontawesome/svg/tags.svg?react"; +// @ts-ignore import FileCirclePlusIcon from "@assets/fontawesome/svg/file-circle-plus.svg?react"; import { useNoteStore } from "@/stores/notesStore"; +import { useUIStore } from "@/stores/uiStore"; export const SidebarHeader = ({ setNewFolder, @@ -11,6 +14,7 @@ export const SidebarHeader = ({ setNewFolder: React.Dispatch>; }) => { const { createNote, selectedFolder } = useNoteStore(); + const { setSideBarView, sideBarView } = useUIStore(); const handleCreate = async () => { await createNote({ title: "Untitled", @@ -22,17 +26,26 @@ export const SidebarHeader = ({
+
); diff --git a/frontend/src/stores/notesStore.ts b/frontend/src/stores/notesStore.ts index 37dfd4e..abca07b 100644 --- a/frontend/src/stores/notesStore.ts +++ b/frontend/src/stores/notesStore.ts @@ -78,7 +78,7 @@ export const useNoteStore = create()( (set, get) => ({ loadFolderTree: async () => { const data = await folderApi.tree(); - console.log(data); + // console.log(data); set({ folderTree: data }); }, folderTree: null, diff --git a/frontend/src/stores/uiStore.ts b/frontend/src/stores/uiStore.ts index 9f36006..83e5659 100644 --- a/frontend/src/stores/uiStore.ts +++ b/frontend/src/stores/uiStore.ts @@ -10,6 +10,9 @@ interface UIState { sideBarResize: number; setSideBarResize: (size: number) => void; + + sideBarView: string; + setSideBarView: (view: string) => void; } export const useUIStore = create()( @@ -27,6 +30,10 @@ export const useUIStore = create()( setSideBarResize: (size) => { set({ sideBarResize: size }); }, + sideBarView: "folders", + setSideBarView: (view) => { + set({ sideBarView: view }); + }, }), { name: "ui-store",