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
This commit is contained in:
james fitzsimons 2025-12-18 18:12:23 +00:00
parent c01a1fc908
commit 0abeb90cb0
11 changed files with 165 additions and 103 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@ node_modules
frontend/src/assets/fontawesome/svg/* frontend/src/assets/fontawesome/svg/*
frontend/src/assets/fontawesome/svg/0.svg frontend/src/assets/fontawesome/svg/0.svg
*.db *.db
.zed/settings.json

View file

@ -1,7 +1,20 @@
from tkinter.constants import TOP
from app.auth import require_auth from app.auth import require_auth
from app.database import get_session 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 fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import selectinload
from sqlmodel import Session, select from sqlmodel import Session, select
router = APIRouter(prefix="/tags", tags=["tags"]) router = APIRouter(prefix="/tags", tags=["tags"])
@ -27,6 +40,27 @@ def create_tag(
return db_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}") @router.post("/note/{note_id}/tag/{tag_id}")
def add_tag_to_note( def add_tag_to_note(
note_id: int, note_id: int,

View file

@ -1,4 +1,5 @@
import { FolderTreeResponse, FolderTreeNode } from "./folders"; import { FolderTreeResponse, FolderTreeNode } from "./folders";
import { Tag } from "./tags";
export async function deriveKey(password: string, salt: string) { export async function deriveKey(password: string, salt: string) {
const enc = new TextEncoder(); const enc = new TextEncoder();
@ -147,3 +148,25 @@ export async function decryptFolderTree(
), ),
}; };
} }
export const decryptTagTree = async (
tags: Tag[],
key: CryptoKey,
parentPath = "",
): Promise<Tag[]> => {
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),
};
}),
);
};

View file

@ -51,10 +51,12 @@ const fetchNotes = async () => {
...note, ...note,
title: await decryptString(note.title, encryptionKey), title: await decryptString(note.title, encryptionKey),
content: await decryptString(note.content, encryptionKey), content: await decryptString(note.content, encryptionKey),
tags: note.tags.map(async (tag) => ({ tags: await Promise.all(
note.tags.map(async (tag) => ({
...tag, ...tag,
name: await decryptString(tag.name, encryptionKey), name: await decryptString(tag.name, encryptionKey),
})), })),
),
})), })),
); );
return decryptedNotes; return decryptedNotes;

View file

@ -1,5 +1,5 @@
import axios from "axios"; import axios from "axios";
import { encryptString, decryptString } from "./encryption"; import { encryptString, decryptTagTree } from "./encryption";
import { useAuthStore } from "../stores/authStore"; import { useAuthStore } from "../stores/authStore";
axios.defaults.withCredentials = true; axios.defaults.withCredentials = true;
const API_URL = (import.meta as any).env.PROD const API_URL = (import.meta as any).env.PROD
@ -20,42 +20,13 @@ export interface TagCreate {
parent_id?: number; 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 fetchTags = async () => {
const encryptionKey = useAuthStore.getState().encryptionKey; const encryptionKey = useAuthStore.getState().encryptionKey;
if (!encryptionKey) throw new Error("Not authenticated"); if (!encryptionKey) throw new Error("Not authenticated");
const { data } = await axios.get(`${API_URL}/tags/`); const { data } = await axios.get(`${API_URL}/tags/tree`);
const tags = decryptTagTree(data.tags, encryptionKey);
const decryptedTags = await Promise.all( console.log(await tags);
data.map(async (tag: Tag) => ({
...tag,
name: await decryptString(tag.name, encryptionKey),
})),
);
const tags = buildTagTree(decryptedTags);
console.log(tags);
return tags; return tags;
}; };

View file

@ -105,20 +105,19 @@ function Home() {
{showModal && <Modal />} {showModal && <Modal />}
<Sidebar /> <Sidebar />
<button onClick={getTags}>create</button>
{/*<div className="flex flex-col"> {/*<div className="flex flex-col">
<input <input
type="text" type="text"
value={tagName} value={tagName}
onChange={(e) => setTagName(e.target.value)} onChange={(e) => setTagName(e.target.value)}
/> />
<button onClick={createTag}>create</button>
{tags.map((tag) => ( {tags.map((tag) => (
<button onClick={() => deleteTag(tag.id)} key={tag.id}> <button onClick={() => deleteTag(tag.id)} key={tag.id}>
{tag.name} {tag.name}
</button> </button>
))} ))}
</div>*/} </div>*/}
<button onClick={() => getTags()}>Click</button>
{/* Main editor area */} {/* Main editor area */}
<div className="flex flex-col w-full h-screen overflow-hidden"> <div className="flex flex-col w-full h-screen overflow-hidden">
@ -178,16 +177,16 @@ const Modal = () => {
); );
}; };
const TagSelector = () => { export const TagSelector = () => {
const { tagTree } = useTagStore(); const { tagTree } = useTagStore();
const [value, setValue] = useState(""); const [value, setValue] = useState("");
return ( return (
<div> <div>
<input {/*<input
type="text" type="text"
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
/> />*/}
{tagTree && tagTree.map((tag) => <TagTree tag={tag} />)} {tagTree && tagTree.map((tag) => <TagTree tag={tag} />)}
</div> </div>
); );

View file

@ -2,6 +2,8 @@ import React, { useState, useRef, useEffect, SetStateAction } from "react";
// @ts-ignore // @ts-ignore
import FolderIcon from "@/assets/fontawesome/svg/folder.svg?react"; 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 { DraggableNote } from "./subcomponents/DraggableNote";
import { import {
@ -19,6 +21,7 @@ import { SidebarHeader } from "./subcomponents/SideBarHeader.tsx";
import { useAuthStore } from "@/stores/authStore.ts"; import { useAuthStore } from "@/stores/authStore.ts";
import { useNoteStore } from "@/stores/notesStore.ts"; import { useNoteStore } from "@/stores/notesStore.ts";
import { useUIStore } from "@/stores/uiStore.ts"; import { useUIStore } from "@/stores/uiStore.ts";
import { TagSelector } from "../../Home.tsx";
export const Sidebar = () => { export const Sidebar = () => {
const [newFolder, setNewFolder] = useState(false); const [newFolder, setNewFolder] = useState(false);
@ -39,7 +42,8 @@ export const Sidebar = () => {
const { encryptionKey } = useAuthStore(); const { encryptionKey } = useAuthStore();
const { setSideBarResize, sideBarResize } = useUIStore(); const { setSideBarResize, sideBarResize, sideBarView, setSideBarView } =
useUIStore();
useEffect(() => { useEffect(() => {
if (newFolder && newFolderRef.current) { if (newFolder && newFolderRef.current) {
newFolderRef.current.focus(); newFolderRef.current.focus();
@ -169,6 +173,8 @@ export const Sidebar = () => {
style={{ width: `${sideBarResize}px` }} style={{ width: `${sideBarResize}px` }}
> >
<SidebarHeader setNewFolder={setNewFolder} /> <SidebarHeader setNewFolder={setNewFolder} />
{sideBarView == "folders" ? (
<>
<div <div
className="bg-ctp-mantle min-h-full border-r border-ctp-surface2 w-full p-4 overflow-y-auto sm:block hidden flex-col gap-3" className="bg-ctp-mantle min-h-full border-r border-ctp-surface2 w-full p-4 overflow-y-auto sm:block hidden flex-col gap-3"
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
@ -228,6 +234,12 @@ export const Sidebar = () => {
</div> </div>
)} )}
</DragOverlay> </DragOverlay>
</>
) : (
<div className="bg-ctp-mantle min-h-full border-r border-ctp-surface2 w-full p-4 overflow-y-auto sm:block hidden flex-col gap-3">
<TagSelector />
</div>
)}
</div> </div>
</div> </div>
</DndContext> </DndContext>

View file

@ -2,8 +2,11 @@ import { SetStateAction } from "react";
// @ts-ignore // @ts-ignore
import FolderPlusIcon from "@assets/fontawesome/svg/folder-plus.svg?react"; import FolderPlusIcon from "@assets/fontawesome/svg/folder-plus.svg?react";
// @ts-ignore // @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 FileCirclePlusIcon from "@assets/fontawesome/svg/file-circle-plus.svg?react";
import { useNoteStore } from "@/stores/notesStore"; import { useNoteStore } from "@/stores/notesStore";
import { useUIStore } from "@/stores/uiStore";
export const SidebarHeader = ({ export const SidebarHeader = ({
setNewFolder, setNewFolder,
@ -11,6 +14,7 @@ export const SidebarHeader = ({
setNewFolder: React.Dispatch<SetStateAction<boolean>>; setNewFolder: React.Dispatch<SetStateAction<boolean>>;
}) => { }) => {
const { createNote, selectedFolder } = useNoteStore(); const { createNote, selectedFolder } = useNoteStore();
const { setSideBarView, sideBarView } = useUIStore();
const handleCreate = async () => { const handleCreate = async () => {
await createNote({ await createNote({
title: "Untitled", title: "Untitled",
@ -22,17 +26,26 @@ export const SidebarHeader = ({
<div className="flex items-center justify-center w-full gap-2 bg-ctp-mantle border-b border-ctp-surface0 p-1"> <div className="flex items-center justify-center w-full gap-2 bg-ctp-mantle border-b border-ctp-surface0 p-1">
<button <button
onClick={() => setNewFolder(true)} onClick={() => setNewFolder(true)}
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-2" className="hover:bg-ctp-mauve group transition-colors rounded-sm p-1 m-1"
title="New folder" title="New folder"
> >
<FolderPlusIcon className="w-4 h-4 group-hover:fill-ctp-base transition-colors fill-ctp-mauve" /> <FolderPlusIcon className="w-5 h-5 group-hover:fill-ctp-base transition-colors fill-ctp-mauve" />
</button>
<button
onClick={() =>
setSideBarView(sideBarView == "tags" ? "folders" : "tags")
}
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-1 m-1"
title="Tags"
>
<TagsIcon className="w-5 h-5 group-hover:fill-ctp-base transition-colors fill-ctp-mauve" />
</button> </button>
<button <button
onClick={handleCreate} onClick={handleCreate}
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-2 fill-ctp-mauve hover:fill-ctp-base" className="hover:bg-ctp-mauve group transition-colors rounded-sm p-1 m-1 fill-ctp-mauve hover:fill-ctp-base"
title="New note" title="New note"
> >
<FileCirclePlusIcon className="w-4 h-4 text-ctp-mauve group-hover:text-ctp-base transition-colors" /> <FileCirclePlusIcon className="w-5 h-5 text-ctp-mauve group-hover:text-ctp-base transition-colors" />
</button> </button>
</div> </div>
); );

View file

@ -78,7 +78,7 @@ export const useNoteStore = create<NoteState>()(
(set, get) => ({ (set, get) => ({
loadFolderTree: async () => { loadFolderTree: async () => {
const data = await folderApi.tree(); const data = await folderApi.tree();
console.log(data); // console.log(data);
set({ folderTree: data }); set({ folderTree: data });
}, },
folderTree: null, folderTree: null,

View file

@ -10,6 +10,9 @@ interface UIState {
sideBarResize: number; sideBarResize: number;
setSideBarResize: (size: number) => void; setSideBarResize: (size: number) => void;
sideBarView: string;
setSideBarView: (view: string) => void;
} }
export const useUIStore = create<UIState>()( export const useUIStore = create<UIState>()(
@ -27,6 +30,10 @@ export const useUIStore = create<UIState>()(
setSideBarResize: (size) => { setSideBarResize: (size) => {
set({ sideBarResize: size }); set({ sideBarResize: size });
}, },
sideBarView: "folders",
setSideBarView: (view) => {
set({ sideBarView: view });
},
}), }),
{ {
name: "ui-store", name: "ui-store",