diff --git a/.gitignore b/.gitignore index 2d5f545..a1a24f0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules frontend/src/assets/fontawesome/svg/* frontend/src/assets/fontawesome/svg/0.svg +*.db diff --git a/backend/app/__pycache__/auth.cpython-314.pyc b/backend/app/__pycache__/auth.cpython-314.pyc index 4e8d24e..a45ea6f 100644 Binary files a/backend/app/__pycache__/auth.cpython-314.pyc and b/backend/app/__pycache__/auth.cpython-314.pyc differ diff --git a/backend/app/__pycache__/main.cpython-314.pyc b/backend/app/__pycache__/main.cpython-314.pyc index ab30837..10d41a4 100644 Binary files a/backend/app/__pycache__/main.cpython-314.pyc and b/backend/app/__pycache__/main.cpython-314.pyc differ diff --git a/backend/app/__pycache__/models.cpython-314.pyc b/backend/app/__pycache__/models.cpython-314.pyc index 00bb4e2..ec9635d 100644 Binary files a/backend/app/__pycache__/models.cpython-314.pyc and b/backend/app/__pycache__/models.cpython-314.pyc differ diff --git a/backend/app/auth.py b/backend/app/auth.py index fec6785..b7498ca 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -2,7 +2,7 @@ import secrets from datetime import datetime, timedelta from typing import Optional -import bcrypt # Use bcrypt directly instead of passlib +import bcrypt from fastapi import Cookie, Depends, HTTPException, Request, status from sqlmodel import Session, select @@ -11,7 +11,6 @@ from app.models import Session as SessionModel from app.models import User -# Password hashing with bcrypt directly def hash_password(password: str) -> str: password_bytes = password.encode("utf-8") salt = bcrypt.gensalt() @@ -25,12 +24,11 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: return bcrypt.checkpw(password_bytes, hashed_bytes) -# Session management def create_session( user_id: int, request: Request, db: Session, expires_in_days: int = 30 ) -> str: session_id = secrets.token_urlsafe(32) - expires_at = datetime.utcnow() + timedelta(days=expires_in_days) + expires_at = datetime.now() + timedelta(days=expires_in_days) db_session = SessionModel( session_id=session_id, @@ -53,13 +51,12 @@ def get_session_user(session_id: Optional[str], db: Session) -> Optional[User]: select(SessionModel).where(SessionModel.session_id == session_id) ).first() - if not session or session.expires_at < datetime.utcnow(): + if not session or session.expires_at < datetime.now(): return None return session.user -# Dependency for protected routes async def require_auth( session_id: Optional[str] = Cookie(None), db: Session = Depends(get_session) ) -> User: diff --git a/backend/app/main.py b/backend/app/main.py index 728376a..9471f10 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,7 @@ from fastapi import FastAPI # type: ignore from fastapi.middleware.cors import CORSMiddleware # type:ignore from app.database import create_db_and_tables -from app.routes import auth, folders, notes +from app.routes import auth, folders, notes, tags app = FastAPI(title="Notes API") @@ -24,6 +24,7 @@ def on_startup(): app.include_router(notes.router, prefix="/api") app.include_router(folders.router, prefix="/api") app.include_router(auth.router, prefix="/api") +app.include_router(tags.router, prefix="/api") @app.get("/") diff --git a/backend/app/models.py b/backend/app/models.py index afeafa9..2f13376 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -4,26 +4,27 @@ from typing import List, Optional from sqlmodel import Field, Relationship, SQLModel # type: ignore -class User(SQLModel, table=True): +class User(SQLModel, table=True): # type: ignore id: Optional[int] = Field(default=None, primary_key=True) username: str = Field(unique=True, index=True) email: str = Field(unique=True, index=True) hashed_password: str salt: str wrapped_master_key: str - created_at: datetime = Field(default_factory=datetime.utcnow) + created_at: datetime = Field(default_factory=datetime.now) # Add relationships to existing models notes: List["Note"] = Relationship(back_populates="user") folders: List["Folder"] = Relationship(back_populates="user") sessions: List["Session"] = Relationship(back_populates="user") + tags: List["Tag"] = Relationship(back_populates="user") -class Session(SQLModel, table=True): +class Session(SQLModel, table=True): # type: ignore id: Optional[int] = Field(default=None, primary_key=True) session_id: str = Field(unique=True, index=True) user_id: int = Field(foreign_key="user.id") - created_at: datetime = Field(default_factory=datetime.utcnow) + created_at: datetime = Field(default_factory=datetime.now) expires_at: datetime ip_address: Optional[str] = None user_agent: Optional[str] = None @@ -35,7 +36,7 @@ class Folder(SQLModel, table=True): # type: ignore id: Optional[int] = Field(default=None, primary_key=True) name: str = Field(max_length=255) parent_id: Optional[int] = Field(default=None, foreign_key="folder.id") - created_at: datetime = Field(default_factory=datetime.utcnow) + created_at: datetime = Field(default_factory=datetime.now) user_id: int = Field(foreign_key="user.id") # Relationships @@ -47,20 +48,73 @@ class Folder(SQLModel, table=True): # type: ignore user: User = Relationship(back_populates="folders") +class NoteTag(SQLModel, table=True): #type: ignore + note_id: int = Field(foreign_key="note.id", primary_key=True) + tag_id: int = Field(foreign_key="tag.id", primary_key=True) + + +class Tag(SQLModel, table=True): # type: ignore + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(max_length=255) + parent_id: Optional[int] = Field(default=None, foreign_key="tag.id") + user_id: int = Field(foreign_key="user.id") + created_at: datetime = Field(default_factory=datetime.now) + + # Relationships + user: User = Relationship(back_populates="tags") + parent: Optional["Tag"] = Relationship( + back_populates="children", + sa_relationship_kwargs={"remote_side": "Tag.id"} + ) + children: List["Tag"] = Relationship(back_populates="parent") + notes: List["Note"] = Relationship(back_populates="tags", link_model=NoteTag) + + + class Note(SQLModel, table=True): # type: ignore id: Optional[int] = Field(default=None, primary_key=True) title: str = Field(max_length=255) content: str folder_id: Optional[int] = Field(default=None, foreign_key="folder.id") - created_at: datetime = Field(default_factory=datetime.utcnow) - updated_at: datetime = Field(default_factory=datetime.utcnow) + created_at: datetime = Field(default_factory=datetime.now) + updated_at: datetime = Field(default_factory=datetime.now) user_id: int = Field(foreign_key="user.id") + #Relationships folder: Optional[Folder] = Relationship(back_populates="notes") user: User = Relationship(back_populates="notes") + tags: List[Tag] = Relationship(back_populates="notes", link_model=NoteTag) + + + +# API Response models +class TagRead(SQLModel): + id: int + name: str + parent_id: Optional[int] = None + created_at: datetime + + +class TagCreate(SQLModel): + name: str + parent_id: Optional[int] = None + + +class TagUpdate(SQLModel): + name: Optional[str] = None + parent_id: Optional[int] = None + + +class TagTreeNode(SQLModel): + id: int + name: str + children: List["TagTreeNode"] = [] + + +class TagTreeResponse(SQLModel): + tags: List[TagTreeNode] -# API Response models (what gets sent to frontend) class NoteRead(SQLModel): id: int title: str @@ -68,6 +122,7 @@ class NoteRead(SQLModel): folder_id: Optional[int] = None created_at: datetime updated_at: datetime + tags: List[TagRead] = [] class FolderTreeNode(SQLModel): diff --git a/backend/app/routes/__pycache__/folders.cpython-314.pyc b/backend/app/routes/__pycache__/folders.cpython-314.pyc index 5075343..d4377a9 100644 Binary files a/backend/app/routes/__pycache__/folders.cpython-314.pyc and b/backend/app/routes/__pycache__/folders.cpython-314.pyc differ diff --git a/backend/app/routes/__pycache__/notes.cpython-314.pyc b/backend/app/routes/__pycache__/notes.cpython-314.pyc index 9098d40..5592dd8 100644 Binary files a/backend/app/routes/__pycache__/notes.cpython-314.pyc and b/backend/app/routes/__pycache__/notes.cpython-314.pyc differ diff --git a/backend/app/routes/__pycache__/tags.cpython-314.pyc b/backend/app/routes/__pycache__/tags.cpython-314.pyc new file mode 100644 index 0000000..b35bedb Binary files /dev/null and b/backend/app/routes/__pycache__/tags.cpython-314.pyc differ diff --git a/backend/app/routes/folders.py b/backend/app/routes/folders.py index f77ea22..09b393d 100644 --- a/backend/app/routes/folders.py +++ b/backend/app/routes/folders.py @@ -1,8 +1,5 @@ from typing import List -from fastapi import APIRouter, Depends, HTTPException # type: ignore -from sqlmodel import Session, select # type: ignore - from app.auth import require_auth from app.database import get_session from app.models import ( @@ -15,6 +12,9 @@ from app.models import ( NoteRead, User, ) +from fastapi import APIRouter, Depends, HTTPException # type: ignore +from sqlalchemy.orm import selectinload +from sqlmodel import Session, select # type: ignore router = APIRouter(prefix="/folders", tags=["folders"]) @@ -35,21 +35,20 @@ def get_folder_tree( ): """Get complete folder tree with notes""" - # Get all top-level folders (parent_id is None) for current user top_level_folders = session.exec( select(Folder) + .options(selectinload(Folder.notes).selectinload(Note.tags)) .where(Folder.parent_id == None) .where(Folder.user_id == current_user.id) ).all() - # Get all orphaned notes (folder_id is None) for current user orphaned_notes = session.exec( select(Note) + .options(selectinload(Note.tags)) .where(Note.folder_id == None) .where(Note.user_id == current_user.id) ).all() - # Build tree recursively tree = [build_folder_tree_node(folder) for folder in top_level_folders] return FolderTreeResponse( diff --git a/backend/app/routes/notes.py b/backend/app/routes/notes.py index 283168a..ba90306 100644 --- a/backend/app/routes/notes.py +++ b/backend/app/routes/notes.py @@ -1,11 +1,10 @@ from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException -from sqlmodel import Session, select - from app.auth import require_auth from app.database import get_session from app.models import Note, NoteCreate, NoteUpdate, User +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select router = APIRouter(prefix="/notes", tags=["notes"]) @@ -16,6 +15,7 @@ def list_notes(session: Session = Depends(get_session)): return notes + @router.post("/", response_model=Note) def create_note( note: NoteCreate, diff --git a/backend/app/routes/tags.py b/backend/app/routes/tags.py new file mode 100644 index 0000000..8e103ed --- /dev/null +++ b/backend/app/routes/tags.py @@ -0,0 +1,60 @@ +from app.auth import require_auth +from app.database import get_session +from app.models import Note, NoteCreate, NoteTag, NoteUpdate, Tag, TagCreate, User +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select + +router = APIRouter(prefix="/tags", tags=["tags"]) + +@router.get("/") +def list_tags(session: Session = Depends(get_session)): + tags = session.exec(select(Tag)).all() + return tags + +@router.post('/', response_model=Tag) +def create_tag( + tag: TagCreate, + current_user: User = Depends(require_auth), + session: Session = Depends(get_session) +): + tag_data = tag.model_dump() + tag_data["user_id"] = current_user.id + db_tag = Tag.model_validate(tag_data) + + session.add(db_tag) + session.commit() + session.refresh(db_tag) + return db_tag + + +@router.post("/note/{note_id}/tag/{tag_id}") +def add_tag_to_note( + note_id: int, + tag_id: int, + current_user: User = Depends(require_auth), + session: Session = Depends(get_session) +): + existing = session.exec( + select(NoteTag) + .where(NoteTag.note_id == note_id) + .where(NoteTag.tag_id == tag_id) + ).first() + + if existing: + return {"message": "Tag already added"} + + note_tag = NoteTag(note_id=note_id, tag_id=tag_id) + session.add(note_tag) + session.commit() + + return note_tag + +@router.delete("/{tag_id}") +def delete_note(tag_id: int, session: Session = Depends(get_session)): + tag = session.get(Tag, tag_id) + if not tag: + raise HTTPException(status_code=404, detail="Tag not found") + + session.delete(tag) + session.commit() + return {"message": "tag deleted"} diff --git a/frontend/src/api/encryption.tsx b/frontend/src/api/encryption.tsx index 3f094b9..3929f72 100644 --- a/frontend/src/api/encryption.tsx +++ b/frontend/src/api/encryption.tsx @@ -114,6 +114,12 @@ export async function decryptFolderTree( ...note, title: await decryptString(note.title, encryptionKey), content: await decryptString(note.content, encryptionKey), + tags: await Promise.all( + note.tags.map(async (tag) => ({ + ...tag, + name: await decryptString(tag.name, encryptionKey), + })), + ), })), ), children: await Promise.all( @@ -131,6 +137,12 @@ export async function decryptFolderTree( ...note, title: await decryptString(note.title, encryptionKey), content: await decryptString(note.content, encryptionKey), + tags: await Promise.all( + note.tags.map(async (tag) => ({ + ...tag, + name: await decryptString(tag.name, encryptionKey), + })), + ), })), ), }; diff --git a/frontend/src/api/folders.tsx b/frontend/src/api/folders.tsx index 7f09b9d..ec82412 100644 --- a/frontend/src/api/folders.tsx +++ b/frontend/src/api/folders.tsx @@ -1,6 +1,7 @@ import axios from "axios"; import { decryptFolderTree } from "./encryption"; import { useAuthStore } from "../stores/authStore"; +import { Tag } from "./tags"; axios.defaults.withCredentials = true; @@ -22,6 +23,7 @@ export interface NoteRead { folder_id: number | null; created_at: string; updated_at: string; + tags: Tag[]; } export interface FolderTreeNode { diff --git a/frontend/src/api/notes.tsx b/frontend/src/api/notes.tsx index 3407a21..2f4a481 100644 --- a/frontend/src/api/notes.tsx +++ b/frontend/src/api/notes.tsx @@ -1,7 +1,7 @@ import axios from "axios"; -import { NoteRead } from "./folders"; import { encryptString, decryptString } from "./encryption"; import { useAuthStore } from "../stores/authStore"; +import { Tag } from "./tags"; axios.defaults.withCredentials = true; const API_URL = (import.meta as any).env.PROD ? "/api" @@ -14,6 +14,7 @@ export interface Note { content: string; created_at: string; updated_at: string; + tags: Tag[]; } export interface NoteCreate { @@ -50,9 +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), + })), })), ); - return decryptedNotes; }; diff --git a/frontend/src/api/tags.tsx b/frontend/src/api/tags.tsx new file mode 100644 index 0000000..d4422ef --- /dev/null +++ b/frontend/src/api/tags.tsx @@ -0,0 +1,92 @@ +import axios from "axios"; +import { encryptString, decryptString } from "./encryption"; +import { useAuthStore } from "../stores/authStore"; +axios.defaults.withCredentials = true; +const API_URL = (import.meta as any).env.PROD + ? "/api" + : "http://localhost:8000/api"; + +export interface Tag { + id: string; + name: string; + parent_id?: number; + created_at: string; + children: Tag[]; + parent_path: string; +} + +export interface TagCreate { + name: string; + 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); + + return tags; +}; + +const createTag = async (tag: TagCreate, noteId?: number) => { + const encryptionKey = useAuthStore.getState().encryptionKey; + if (!encryptionKey) throw new Error("Not authenticated"); + + const tagName = await encryptString(tag.name, encryptionKey); + const encryptedTag = { + name: tagName, + parent_id: tag.parent_id, + }; + + const r = await axios.post(`${API_URL}/tags/`, encryptedTag); + console.log(r); + + if (noteId) { + return await addTagToNote(r.data.id, noteId); + } +}; + +const addTagToNote = async (tagId: number, noteId: number) => { + return axios.post(`${API_URL}/tags/note/${noteId}/tag/${tagId}`); +}; + +const deleteTag = async (tagId: number) => { + return axios.delete(`${API_URL}/tags/${tagId}`); +}; + +export const tagsApi = { + list: async () => await fetchTags(), + create: (tag: TagCreate, noteId?: number) => createTag(tag, noteId), + delete: (tagId: number) => deleteTag(tagId), +}; diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index 4dc8cfa..a4fdd51 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import "../../main.css"; -import { motion } from "framer-motion"; +import { AnimatePresence, motion } from "framer-motion"; import { useAuthStore } from "@/stores/authStore"; import { useNoteStore } from "@/stores/notesStore"; import { useUIStore } from "@/stores/uiStore"; @@ -9,6 +9,9 @@ import { TiptapEditor } from "../TipTap"; import { Sidebar } from "./components/sidebar/SideBar"; import { StatusIndicator } from "./components/StatusIndicator"; +import { Tag, tagsApi } from "@/api/tags"; +import { useTagStore } from "@/stores/tagStore"; + function Home() { const [newFolder] = useState(false); const [lastSavedNote, setLastSavedNote] = useState<{ @@ -92,12 +95,30 @@ function Home() { } }; + const { getTagTree, tagTree } = useTagStore(); + const getTags = () => { + getTagTree(); + }; return (
{/* Sidebar */} {showModal && } + {/*
+ setTagName(e.target.value)} + /> + + {tags.map((tag) => ( + + ))} +
*/} + {/* Main editor area */}
@@ -109,6 +130,20 @@ function Home() { onChange={(e) => setTitle(e.target.value)} className="w-full px-4 py-3 text-3xl font-semibold bg-transparentfocus:outline-none focus:border-ctp-mauve transition-colors placeholder:text-ctp-overlay0 text-ctp-text" /> +
+ {selectedNote?.tags && + selectedNote.tags.map((tag) => ( + + ))} +
+ { onClick={(e) => e.stopPropagation()} className="w-2/3 h-2/3 bg-ctp-base rounded-xl border-ctp-surface2 border p-5" > - + {/**/} +
); }; + +const TagSelector = () => { + const { tagTree } = useTagStore(); + const [value, setValue] = useState(""); + return ( +
+ setValue(e.target.value)} + /> + {tagTree && tagTree.map((tag) => )} +
+ ); +}; + +export const TagTree = ({ tag, depth = 0 }: { tag: Tag; depth?: number }) => { + const [collapse, setCollapse] = useState(false); + + return ( +
+
setCollapse(!collapse)}>{tag.name}
+ + {collapse && ( + + {/* The line container */} +
+ {/* Child tags */} + {tag.children.map((child) => ( + + ))} +
+
+ )} +
+
+ ); +}; diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts index 5be5e8a..7087e57 100644 --- a/frontend/src/stores/authStore.ts +++ b/frontend/src/stores/authStore.ts @@ -11,15 +11,15 @@ interface User { id: number; username: string; email: string; - salt: string; // For key derivation + salt: string; } interface AuthState { user: User | null; - encryptionKey: CryptoKey | null; // Memory only! + encryptionKey: CryptoKey | null; isAuthenticated: boolean; rememberMe: boolean; - setRememberMe: (boolean) => void; + setRememberMe: (remember: boolean) => void; login: (username: string, password: string) => Promise; register: ( @@ -45,7 +45,6 @@ export const useAuthStore = create()( set({ rememberMe: bool }); }, initEncryptionKey: async (password: string, salt: string) => { - // Use user-specific salt instead of hardcoded const key = await deriveKey(password, salt); set({ encryptionKey: key }); }, @@ -76,7 +75,6 @@ export const useAuthStore = create()( const data = await response.json(); - // Store the master key directly (not derived from password) set({ user: data.user, isAuthenticated: true, @@ -99,11 +97,9 @@ export const useAuthStore = create()( const { user } = await response.json(); - // Derive KEK and unwrap master key const kek = await deriveKey(password, user.salt); const masterKey = await unwrapMasterKey(user.wrapped_master_key, kek); - // Store master key in memory set({ encryptionKey: masterKey, user, isAuthenticated: true }); }, @@ -115,7 +111,7 @@ export const useAuthStore = create()( set({ user: null, - encryptionKey: null, // Wipe from memory + encryptionKey: null, isAuthenticated: false, }); }, diff --git a/frontend/src/stores/notesStore.ts b/frontend/src/stores/notesStore.ts index 038971c..37dfd4e 100644 --- a/frontend/src/stores/notesStore.ts +++ b/frontend/src/stores/notesStore.ts @@ -36,7 +36,7 @@ const updateFolder = ( id: number, folder: FolderTreeNode, newFolder: FolderUpdate, -) => { +): FolderTreeNode => { if (folder.id === id) { return { ...folder, ...newFolder }; } @@ -78,7 +78,7 @@ export const useNoteStore = create()( (set, get) => ({ loadFolderTree: async () => { const data = await folderApi.tree(); - console.log("getting tree"); + console.log(data); set({ folderTree: data }); }, folderTree: null, diff --git a/frontend/src/stores/tagStore.ts b/frontend/src/stores/tagStore.ts new file mode 100644 index 0000000..ef7fc77 --- /dev/null +++ b/frontend/src/stores/tagStore.ts @@ -0,0 +1,36 @@ +import { tagsApi } from "@/api/tags"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface Tag { + id: string; + name: string; + parent_id?: number; + created_at: string; + parent_path: string; + children: Tag[]; +} + +interface TagStore { + tagTree: Tag[] | null; + getTagTree: () => void; +} + +export const useTagStore = create()( + persist( + (set, get) => ({ + tagTree: null, + + getTagTree: async () => { + const tags = await tagsApi.list(); + set({ tagTree: tags }); + }, + }), + { + name: "tags-storage", + partialize: (state) => ({ + tagTree: state.tagTree, + }), + }, + ), +); diff --git a/frontend/src/stores/uiStore.ts b/frontend/src/stores/uiStore.ts index 399c4cc..9f36006 100644 --- a/frontend/src/stores/uiStore.ts +++ b/frontend/src/stores/uiStore.ts @@ -19,7 +19,7 @@ export const useUIStore = create()( setUpdating: (update) => { set({ updating: update }); }, - showModal: false, + showModal: true, setShowModal: (show) => { set({ showModal: show }); },