From 9b24cddef6c4862bd899eb8d6332cafff54e871d Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 9 Dec 2025 16:45:08 -0500 Subject: [PATCH] enh/refac: notes --- backend/open_webui/models/notes.py | 205 ++++-- backend/open_webui/routers/notes.py | 102 ++- src/lib/apis/notes/index.ts | 56 +- .../components/common/DropdownOptions.svelte | 54 ++ src/lib/components/notes/Notes.svelte | 588 +++++++++++------- 5 files changed, 688 insertions(+), 317 deletions(-) create mode 100644 src/lib/components/common/DropdownOptions.svelte diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index af75fab598..0942ad0140 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -7,12 +7,15 @@ from functools import lru_cache from open_webui.internal.db import Base, get_db from open_webui.models.groups import Groups from open_webui.utils.access_control import has_access -from open_webui.models.users import Users, UserResponse +from open_webui.models.users import User, UserModel, Users, UserResponse from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON -from sqlalchemy import or_, func, select, and_, text +from sqlalchemy.dialects.postgresql import JSONB + + +from sqlalchemy import or_, func, select, and_, text, cast, or_, and_, func from sqlalchemy.sql import exists #################### @@ -75,7 +78,63 @@ class NoteUserResponse(NoteModel): user: Optional[UserResponse] = None +class NoteItemResponse(BaseModel): + id: str + title: str + data: Optional[dict] + updated_at: int + created_at: int + user: Optional[UserResponse] = None + + +class NoteListResponse(BaseModel): + items: list[NoteUserResponse] + total: int + + class NoteTable: + def _has_permission(self, db, query, filter: dict, permission: str = "read"): + group_ids = filter.get("group_ids", []) + user_id = filter.get("user_id") + + dialect_name = db.bind.dialect.name + + # Public access + conditions = [] + if group_ids or user_id: + conditions.extend( + [ + Note.access_control.is_(None), + cast(Note.access_control, String) == "null", + ] + ) + + # User-level permission + if user_id: + conditions.append(Note.user_id == user_id) + + # Group-level permission + if group_ids: + group_conditions = [] + for gid in group_ids: + if dialect_name == "sqlite": + group_conditions.append( + Note.access_control[permission]["group_ids"].contains([gid]) + ) + elif dialect_name == "postgresql": + group_conditions.append( + cast( + Note.access_control[permission]["group_ids"], + JSONB, + ).contains([gid]) + ) + conditions.append(or_(*group_conditions)) + + if conditions: + query = query.filter(or_(*conditions)) + + return query + def insert_new_note( self, form_data: NoteForm, @@ -110,15 +169,103 @@ class NoteTable: notes = query.all() return [NoteModel.model_validate(note) for note in notes] + def search_notes( + self, user_id: str, filter: dict = {}, skip: int = 0, limit: int = 30 + ) -> NoteListResponse: + with get_db() as db: + query = db.query(Note, User).outerjoin(User, User.id == Note.user_id) + if filter: + query_key = filter.get("query") + if query_key: + query = query.filter( + or_( + Note.title.ilike(f"%{query_key}%"), + Note.data["content"]["md"].ilike(f"%{query_key}%"), + ) + ) + + view_option = filter.get("view_option") + if view_option == "created": + query = query.filter(Note.user_id == user_id) + elif view_option == "shared": + query = query.filter(Note.user_id != user_id) + + # Apply access control filtering + query = self._has_permission( + db, + query, + filter, + permission="write", + ) + + order_by = filter.get("order_by") + direction = filter.get("direction") + + if order_by == "name": + if direction == "asc": + query = query.order_by(Note.title.asc()) + else: + query = query.order_by(Note.title.desc()) + elif order_by == "created_at": + if direction == "asc": + query = query.order_by(Note.created_at.asc()) + else: + query = query.order_by(Note.created_at.desc()) + elif order_by == "updated_at": + if direction == "asc": + query = query.order_by(Note.updated_at.asc()) + else: + query = query.order_by(Note.updated_at.desc()) + else: + query = query.order_by(Note.updated_at.desc()) + + else: + query = query.order_by(Note.updated_at.desc()) + + for key, value in filter.items(): + query = query.filter(getattr(Note, key).ilike(f"%{value}%")) + + # Count BEFORE pagination + total = query.count() + + if skip: + query = query.offset(skip) + if limit: + query = query.limit(limit) + + items = query.all() + + notes = [] + for note, user in items: + notes.append( + NoteUserResponse( + **NoteModel.model_validate(note).model_dump(), + user=( + UserResponse(**UserModel.model_validate(user).model_dump()) + if user + else None + ), + ) + ) + + return NoteListResponse(items=notes, total=total) + def get_notes_by_user_id( self, user_id: str, + permission: str = "read", skip: Optional[int] = None, limit: Optional[int] = None, ) -> list[NoteModel]: with get_db() as db: - query = db.query(Note).filter(Note.user_id == user_id) - query = query.order_by(Note.updated_at.desc()) + user_group_ids = [ + group.id for group in Groups.get_groups_by_member_id(user_id) + ] + + query = db.query(Note).order_by(Note.updated_at.desc()) + query = self._has_permission( + db, query, {"user_id": user_id, "group_ids": user_group_ids}, permission + ) if skip is not None: query = query.offset(skip) @@ -128,56 +275,6 @@ class NoteTable: notes = query.all() return [NoteModel.model_validate(note) for note in notes] - def get_notes_by_permission( - self, - user_id: str, - permission: str = "write", - skip: Optional[int] = None, - limit: Optional[int] = None, - ) -> list[NoteModel]: - with get_db() as db: - user_groups = Groups.get_groups_by_member_id(user_id) - user_group_ids = {group.id for group in user_groups} - - # Order newest-first. We stream to keep memory usage low. - query = ( - db.query(Note) - .order_by(Note.updated_at.desc()) - .execution_options(stream_results=True) - .yield_per(256) - ) - - results: list[NoteModel] = [] - n_skipped = 0 - - for note in query: - # Fast-pass #1: owner - if note.user_id == user_id: - permitted = True - # Fast-pass #2: public/open - elif note.access_control is None: - # Technically this should mean public access for both read and write, but we'll only do read for now - # We might want to change this behavior later - permitted = permission == "read" - else: - permitted = has_access( - user_id, permission, note.access_control, user_group_ids - ) - - if not permitted: - continue - - # Apply skip AFTER permission filtering so it counts only accessible notes - if skip and n_skipped < skip: - n_skipped += 1 - continue - - results.append(NoteModel.model_validate(note)) - if limit is not None and len(results) >= limit: - break - - return results - def get_note_by_id(self, id: str) -> Optional[NoteModel]: with get_db() as db: note = db.query(Note).filter(Note.id == id).first() diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 3858c4670f..6cbbc4eaf3 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -8,11 +8,21 @@ from pydantic import BaseModel from open_webui.socket.main import sio - +from open_webui.models.groups import Groups from open_webui.models.users import Users, UserResponse -from open_webui.models.notes import Notes, NoteModel, NoteForm, NoteUserResponse +from open_webui.models.notes import ( + NoteListResponse, + Notes, + NoteModel, + NoteForm, + NoteUserResponse, +) -from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT +from open_webui.config import ( + BYPASS_ADMIN_ACCESS_CONTROL, + ENABLE_ADMIN_CHAT_ACCESS, + ENABLE_ADMIN_EXPORT, +) from open_webui.constants import ERROR_MESSAGES from open_webui.env import SRC_LOG_LEVELS @@ -30,39 +40,17 @@ router = APIRouter() ############################ -@router.get("/", response_model=list[NoteUserResponse]) -async def get_notes(request: Request, user=Depends(get_verified_user)): - - if user.role != "admin" and not has_permission( - user.id, "features.notes", request.app.state.config.USER_PERMISSIONS - ): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=ERROR_MESSAGES.UNAUTHORIZED, - ) - - notes = [ - NoteUserResponse( - **{ - **note.model_dump(), - "user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()), - } - ) - for note in Notes.get_notes_by_permission(user.id, "write") - ] - - return notes - - -class NoteTitleIdResponse(BaseModel): +class NoteItemResponse(BaseModel): id: str title: str + data: Optional[dict] updated_at: int created_at: int + user: Optional[UserResponse] = None -@router.get("/list", response_model=list[NoteTitleIdResponse]) -async def get_note_list( +@router.get("/", response_model=list[NoteItemResponse]) +async def get_notes( request: Request, page: Optional[int] = None, user=Depends(get_verified_user) ): if user.role != "admin" and not has_permission( @@ -80,15 +68,61 @@ async def get_note_list( skip = (page - 1) * limit notes = [ - NoteTitleIdResponse(**note.model_dump()) - for note in Notes.get_notes_by_permission( - user.id, "write", skip=skip, limit=limit + NoteUserResponse( + **{ + **note.model_dump(), + "user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()), + } ) + for note in Notes.get_notes_by_user_id(user.id, "read", skip=skip, limit=limit) ] - return notes +@router.get("/search", response_model=NoteListResponse) +async def search_notes( + request: Request, + query: Optional[str] = None, + view_option: Optional[str] = None, + order_by: Optional[str] = None, + direction: Optional[str] = None, + page: Optional[int] = 1, + user=Depends(get_verified_user), +): + if user.role != "admin" and not has_permission( + user.id, "features.notes", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + limit = None + skip = None + if page is not None: + limit = 60 + skip = (page - 1) * limit + + filter = {} + if query: + filter["query"] = query + if view_option: + filter["view_option"] = view_option + if order_by: + filter["order_by"] = order_by + if direction: + filter["direction"] = direction + + if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL: + groups = Groups.get_groups_by_member_id(user.id) + if groups: + filter["group_ids"] = [group.id for group in groups] + + filter["user_id"] = user.id + + return Notes.search_notes(user.id, filter, skip=skip, limit=limit) + + ############################ # CreateNewNote ############################ diff --git a/src/lib/apis/notes/index.ts b/src/lib/apis/notes/index.ts index 61794f6766..945b8f2261 100644 --- a/src/lib/apis/notes/index.ts +++ b/src/lib/apis/notes/index.ts @@ -91,6 +91,60 @@ export const getNotes = async (token: string = '', raw: boolean = false) => { return grouped; }; +export const searchNotes = async ( + token: string = '', + query: string | null = null, + viewOption: string | null = null, + sortKey: string | null = null, + page: number | null = null +) => { + let error = null; + const searchParams = new URLSearchParams(); + + if (query !== null) { + searchParams.append('query', query); + } + + if (viewOption !== null) { + searchParams.append('view_option', viewOption); + } + + if (sortKey !== null) { + searchParams.append('order_by', sortKey); + } + + if (page !== null) { + searchParams.append('page', `${page}`); + } + + const res = await fetch(`${WEBUI_API_BASE_URL}/notes/search?${searchParams.toString()}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const getNoteList = async (token: string = '', page: number | null = null) => { let error = null; const searchParams = new URLSearchParams(); @@ -99,7 +153,7 @@ export const getNoteList = async (token: string = '', page: number | null = null searchParams.append('page', `${page}`); } - const res = await fetch(`${WEBUI_API_BASE_URL}/notes/list?${searchParams.toString()}`, { + const res = await fetch(`${WEBUI_API_BASE_URL}/notes/?${searchParams.toString()}`, { method: 'GET', headers: { Accept: 'application/json', diff --git a/src/lib/components/common/DropdownOptions.svelte b/src/lib/components/common/DropdownOptions.svelte new file mode 100644 index 0000000000..f2640672c4 --- /dev/null +++ b/src/lib/components/common/DropdownOptions.svelte @@ -0,0 +1,54 @@ + + + + +
+ {items.find((item) => item.value === value)?.label ?? placeholder} + +
+
+ + +
+ {#each items as item} + + {/each} +
+
+
diff --git a/src/lib/components/notes/Notes.svelte b/src/lib/components/notes/Notes.svelte index 2b377bda6c..50c3dc1556 100644 --- a/src/lib/components/notes/Notes.svelte +++ b/src/lib/components/notes/Notes.svelte @@ -1,9 +1,7 @@ @@ -236,7 +277,7 @@ -
+
{#if loaded} -
-
+
+
+
+
+ {$i18n.t('Notes')} +
+ +
+ {total} +
+
+ +
+ +
+
+
+ +
+
@@ -277,191 +351,249 @@ {/if}
-
-
- {#if Object.keys(notes).length > 0} -
- {#each Object.keys(notes) as timeRange} -
- {$i18n.t(timeRange)} -
+
+
{ + if (e.deltaY !== 0) { + e.preventDefault(); + e.currentTarget.scrollLeft += e.deltaY; + } + }} + > +
+ +
+
-
- {#each notes[timeRange] as note, idx (note.id)} - + + {#if (items ?? []).length > 0} + {@const notes = groupNotes(items)} + +
+
+ {#each Object.keys(notes) as timeRange} +
+ {$i18n.t(timeRange)} +
+ + {#if displayOption === null} +
- {/each} -
- {/each} + {:else if displayOption === 'grid'} + + {/if} + {/each} + + {#if !allItemsLoaded} + { + if (!itemsLoading) { + loadMoreItems(); + } + }} + > +
+ +
{$i18n.t('Loading...')}
+
+
+ {/if} +
{:else}
-
-
+
+
{$i18n.t('No Notes')}
-
+
{$i18n.t('Create your first note by clicking on the plus button below.')}
{/if}
- -
-
- - - - - -
-
- - {:else}