From a4d62253df55c6307112eb76a6bfa29a7f538e21 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 18 Apr 2026 06:23:50 +0900 Subject: [PATCH 01/41] refac --- backend/open_webui/models/automations.py | 13 ++++++++++++- src/routes/+layout.svelte | 10 ++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/backend/open_webui/models/automations.py b/backend/open_webui/models/automations.py index c891c3204e..02a6f231da 100644 --- a/backend/open_webui/models/automations.py +++ b/backend/open_webui/models/automations.py @@ -273,9 +273,20 @@ class AutomationTable: from open_webui.utils.automations import next_run_ns + # Batch-fetch user timezones so rescheduling respects each + # user's local timezone instead of falling back to server time. + user_ids = list({row.user_id for row in rows}) + timezone_by_user_id: dict[str, Optional[str]] = {} + if user_ids: + from open_webui.models.users import User + tz_result = await db.execute( + select(User.id, User.timezone).where(User.id.in_(user_ids)) + ) + timezone_by_user_id = {uid: tz for uid, tz in tz_result.all()} + for row in rows: row.last_run_at = now_ns - row.next_run_at = next_run_ns(row.data.get('rrule', '')) + row.next_run_at = next_run_ns(row.data.get('rrule', ''), tz=timezone_by_user_id.get(row.user_id)) await db.commit() diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 8aeda16594..f62aa20cab 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -51,7 +51,7 @@ import 'tippy.js/dist/tippy.css'; import { executeToolServer, getBackendConfig, getModels, getVersion } from '$lib/apis'; - import { getSessionUser, userSignOut } from '$lib/apis/auths'; + import { getSessionUser, updateUserTimezone, userSignOut } from '$lib/apis/auths'; import { getAllTags, getChatList } from '$lib/apis/chats'; import { chatCompletion } from '$lib/apis/openai'; import { @@ -62,7 +62,7 @@ } from '$lib/utils/connections'; import { WEBUI_API_BASE_URL, WEBUI_BASE_URL, WEBUI_HOSTNAME } from '$lib/constants'; - import { bestMatchingLanguage, displayFileHandler } from '$lib/utils'; + import { bestMatchingLanguage, displayFileHandler, getUserTimezone } from '$lib/utils'; import { setTextScale } from '$lib/utils/text-scale'; import NotificationToast from '$lib/components/NotificationToast.svelte'; @@ -989,6 +989,12 @@ console.error('Error refreshing backend config:', error); } + // Keep user timezone in sync on every app load/refresh + const timezone = getUserTimezone(); + if (timezone) { + updateUserTimezone(localStorage.token, timezone); + } + // Relay auth token to desktop app for API access if (window.electronAPI?.send) { window.electronAPI From 5087492e25401b87ebc56f8dbe13fb1fc415c224 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 19 Apr 2026 15:49:42 +0900 Subject: [PATCH 02/41] refac --- .../chat/Messages/ResponseMessage.svelte | 65 ++++++++-------- src/lib/utils/audio.ts | 76 +++++++++++-------- 2 files changed, 75 insertions(+), 66 deletions(-) diff --git a/src/lib/components/chat/Messages/ResponseMessage.svelte b/src/lib/components/chat/Messages/ResponseMessage.svelte index ebae97d95b..e97b5ac75f 100644 --- a/src/lib/components/chat/Messages/ResponseMessage.svelte +++ b/src/lib/components/chat/Messages/ResponseMessage.svelte @@ -185,6 +185,7 @@ let speakingIdx: number | undefined; let loadingSpeech = false; + let speakAbort: AbortController | null = null; let showRateComment = false; @@ -202,39 +203,39 @@ }; const stopAudio = () => { + speakAbort?.abort(); + speakAbort = null; + try { speechSynthesis.cancel(); $audioQueue?.stop(); } catch {} - if (speaking) { - speaking = false; - speakingIdx = undefined; - } + speaking = false; + speakingIdx = undefined; + loadingSpeech = false; }; + // Resolve voice: model-specific > user settings > config default + const getVoiceId = () => + model?.info?.meta?.tts?.voice ?? + ($settings?.audio?.tts?.defaultVoice === $config.audio.tts.voice + ? $settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice + : $config?.audio?.tts?.voice); + const speak = async () => { if (!(message?.content ?? '').trim().length) { toast.info($i18n.t('No content to speak')); return; } + stopAudio(); + speakAbort = new AbortController(); + const { signal } = speakAbort; + speaking = true; const content = removeAllDetails(message.content); - // Get voice: model-specific > user settings > config default - const getVoiceId = () => { - // Check for model-specific TTS voice first - if (model?.info?.meta?.tts?.voice) { - return model.info.meta.tts.voice; - } - // Fall back to user settings or config default - if ($settings?.audio?.tts?.defaultVoice === $config.audio.tts.voice) { - return $settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice; - } - return $config?.audio?.tts?.voice; - }; - if ($config.audio.tts.engine === '') { let voices = []; const getVoicesLoop = setInterval(() => { @@ -242,16 +243,10 @@ if (voices.length > 0) { clearInterval(getVoicesLoop); - const voiceId = getVoiceId(); - const voice = voices?.filter((v) => v.voiceURI === voiceId)?.at(0) ?? undefined; - - console.log(voice); - + const voice = voices.find((v) => v.voiceURI === getVoiceId()); const speech = new SpeechSynthesisUtterance(content); speech.rate = $settings.audio?.tts?.playbackRate ?? 1; - console.log(speech); - speech.onend = () => { speaking = false; if ($settings.conversationMode) { @@ -281,9 +276,7 @@ ); if (!messageContentParts.length) { - console.log('No content to speak'); toast.info($i18n.t('No content to speak')); - speaking = false; loadingSpeech = false; return; @@ -303,41 +296,43 @@ await $TTSWorker.init(); } - for (const [idx, sentence] of messageContentParts.entries()) { + for (const [, sentence] of messageContentParts.entries()) { + if (signal.aborted) return; + const url = await $TTSWorker - .generate({ - text: sentence, - voice: voiceId - }) + .generate({ text: sentence, voice: voiceId }) .catch((error) => { console.error(error); toast.error(`${error}`); - speaking = false; loadingSpeech = false; }); + if (signal.aborted) return; + if (url && speaking) { $audioQueue.enqueue(url); loadingSpeech = false; } } } else { - for (const [idx, sentence] of messageContentParts.entries()) { + for (const [, sentence] of messageContentParts.entries()) { + if (signal.aborted) return; + const res = await synthesizeOpenAISpeech(localStorage.token, voiceId, sentence).catch( (error) => { console.error(error); toast.error(`${error}`); - speaking = false; loadingSpeech = false; } ); + if (signal.aborted) return; + if (res && speaking) { const blob = await res.blob(); const url = URL.createObjectURL(blob); - $audioQueue.enqueue(url); loadingSpeech = false; } diff --git a/src/lib/utils/audio.ts b/src/lib/utils/audio.ts index 6effbc4137..94da18af67 100644 --- a/src/lib/utils/audio.ts +++ b/src/lib/utils/audio.ts @@ -1,32 +1,39 @@ +type AudioQueueEvent = 'stop' | 'empty-queue' | 'id-change'; + +interface AudioQueueStopDetail { + event: AudioQueueEvent; + id: string | null; +} + +export type OnStoppedCallback = (detail: AudioQueueStopDetail) => void; + export class AudioQueue { - constructor(audioElement) { + private audio: HTMLAudioElement; + private queue: string[] = []; + private current: string | null = null; + private readonly _onEnded = () => this.next(); + + id: string | null = null; + onStopped: OnStoppedCallback | null = null; + + constructor(audioElement: HTMLAudioElement) { this.audio = audioElement; - this.queue = []; - this.current = null; - this.id = null; - - this._onEnded = () => this.next(); this.audio.addEventListener('ended', this._onEnded); - - this.onStopped = null; // optional callback } - setId(newId) { - console.log('Setting audio queue ID to:', newId); - if (this.id !== newId) { - this.stop(); - this.id = newId; - if (this.onStopped) this.onStopped({ event: 'id-change', id: newId }); - } + setId(newId: string) { + if (this.id === newId) return; + + this.#halt(); + this.id = newId; + this.onStopped?.({ event: 'id-change', id: newId }); } - setPlaybackRate(rate) { - console.log('Setting audio playback rate to:', rate); + setPlaybackRate(rate: number) { this.audio.playbackRate = rate; } - enqueue(url) { - console.log('Enqueuing audio URL:', url); + enqueue(url: string) { this.queue.push(url); // Auto-play if nothing is currently playing or loaded @@ -44,30 +51,37 @@ export class AudioQueue { } next() { - this.current = this.queue.shift(); + this.current = this.queue.shift() ?? null; + if (this.current) { this.audio.src = this.current; this.audio.play(); - console.log('Playing audio URL:', this.current); } else { - this.stop(); - if (this.onStopped) this.onStopped({ event: 'empty-queue', id: this.id }); + this.#halt(); + this.onStopped?.({ event: 'empty-queue', id: this.id }); } } stop() { + this.#halt(); + this.onStopped?.({ event: 'stop', id: this.id }); + } + + destroy() { + this.audio.removeEventListener('ended', this._onEnded); + this.#halt(); + this.onStopped = null; + } + + /** + * Pause audio and clear queue without firing onStopped. + * Callers that need the callback should invoke it themselves. + */ + #halt() { this.audio.pause(); this.audio.currentTime = 0; this.audio.src = ''; this.queue = []; this.current = null; - if (this.onStopped) this.onStopped({ event: 'stop', id: this.id }); - } - - destroy() { - this.audio.removeEventListener('ended', this._onEnded); - this.stop(); - this.onStopped = null; - this.audio = null; } } From 8d739e2abab74ac5a8a27276d69e7ede391a9a45 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 19 Apr 2026 19:15:05 +0900 Subject: [PATCH 03/41] feat: calendar --- backend/open_webui/main.py | 2 + backend/open_webui/migrations/env.py | 1 + .../56359461a091_add_calendar_tables.py | 79 ++ backend/open_webui/models/automations.py | 38 + backend/open_webui/models/calendar.py | 820 ++++++++++++++++++ backend/open_webui/routers/calendar.py | 319 +++++++ backend/open_webui/utils/calendar.py | 83 ++ src/lib/apis/calendar/index.ts | 427 +++++++++ .../calendar/CalendarEventChip.svelte | 28 + .../calendar/CalendarEventModal.svelte | 260 ++++++ .../calendar/CalendarSidebar.svelte | 174 ++++ .../components/calendar/CalendarView.svelte | 380 ++++++++ .../components/layout/Sidebar/UserMenu.svelte | 32 + src/routes/(app)/calendar/+page.svelte | 201 +++++ 14 files changed, 2844 insertions(+) create mode 100644 backend/open_webui/migrations/versions/56359461a091_add_calendar_tables.py create mode 100644 backend/open_webui/models/calendar.py create mode 100644 backend/open_webui/routers/calendar.py create mode 100644 backend/open_webui/utils/calendar.py create mode 100644 src/lib/apis/calendar/index.ts create mode 100644 src/lib/components/calendar/CalendarEventChip.svelte create mode 100644 src/lib/components/calendar/CalendarEventModal.svelte create mode 100644 src/lib/components/calendar/CalendarSidebar.svelte create mode 100644 src/lib/components/calendar/CalendarView.svelte create mode 100644 src/routes/(app)/calendar/+page.svelte diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 8d95f5b277..c13250c587 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -105,6 +105,7 @@ from open_webui.routers import ( scim, terminals, automations, + calendar, ) from open_webui.routers.retrieval import ( @@ -1433,6 +1434,7 @@ if ENABLE_ADMIN_ANALYTICS: app.include_router(utils.router, prefix='/api/v1/utils', tags=['utils']) app.include_router(terminals.router, prefix='/api/v1/terminals', tags=['terminals']) app.include_router(automations.router, prefix='/api/v1/automations', tags=['automations']) +app.include_router(calendar.router, prefix='/api/v1/calendars', tags=['calendars']) # SCIM 2.0 API for identity management if ENABLE_SCIM: diff --git a/backend/open_webui/migrations/env.py b/backend/open_webui/migrations/env.py index 9ee6c2dceb..3840cb4a17 100644 --- a/backend/open_webui/migrations/env.py +++ b/backend/open_webui/migrations/env.py @@ -3,6 +3,7 @@ from logging.config import fileConfig from alembic import context from open_webui.models.auths import Auth +from open_webui.models.calendar import Calendar, CalendarEvent, CalendarEventAttendee # noqa: F401 from open_webui.env import DATABASE_URL, DATABASE_PASSWORD, LOG_FORMAT from sqlalchemy import engine_from_config, pool, create_engine diff --git a/backend/open_webui/migrations/versions/56359461a091_add_calendar_tables.py b/backend/open_webui/migrations/versions/56359461a091_add_calendar_tables.py new file mode 100644 index 0000000000..8277daa738 --- /dev/null +++ b/backend/open_webui/migrations/versions/56359461a091_add_calendar_tables.py @@ -0,0 +1,79 @@ +"""add calendar tables + +Revision ID: 56359461a091 +Revises: c1d2e3f4a5b6 +Create Date: 2026-04-19 16:20:58.162045 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '56359461a091' +down_revision: Union[str, None] = 'c1d2e3f4a5b6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table('calendar', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('name', sa.Text(), nullable=False), + sa.Column('color', sa.Text(), nullable=True), + sa.Column('is_system', sa.Boolean(), nullable=False), + sa.Column('data', sa.JSON(), nullable=True), + sa.Column('meta', sa.JSON(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=False), + sa.Column('updated_at', sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_calendar_user', 'calendar', ['user_id'], unique=False) + + op.create_table('calendar_event', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('calendar_id', sa.Text(), nullable=False), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('title', sa.Text(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('start_at', sa.BigInteger(), nullable=False), + sa.Column('end_at', sa.BigInteger(), nullable=True), + sa.Column('all_day', sa.Boolean(), nullable=False), + sa.Column('rrule', sa.Text(), nullable=True), + sa.Column('color', sa.Text(), nullable=True), + sa.Column('location', sa.Text(), nullable=True), + sa.Column('data', sa.JSON(), nullable=True), + sa.Column('meta', sa.JSON(), nullable=True), + sa.Column('is_cancelled', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.BigInteger(), nullable=False), + sa.Column('updated_at', sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_calendar_event_calendar', 'calendar_event', ['calendar_id', 'start_at'], unique=False) + op.create_index('ix_calendar_event_user_date', 'calendar_event', ['user_id', 'start_at'], unique=False) + + op.create_table('calendar_event_attendee', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('event_id', sa.Text(), nullable=False), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('status', sa.Text(), nullable=False), + sa.Column('meta', sa.JSON(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=False), + sa.Column('updated_at', sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('event_id', 'user_id', name='uq_event_attendee') + ) + op.create_index('ix_calendar_event_attendee_user', 'calendar_event_attendee', ['user_id', 'status'], unique=False) + + +def downgrade() -> None: + op.drop_index('ix_calendar_event_attendee_user', table_name='calendar_event_attendee') + op.drop_table('calendar_event_attendee') + op.drop_index('ix_calendar_event_user_date', table_name='calendar_event') + op.drop_index('ix_calendar_event_calendar', table_name='calendar_event') + op.drop_table('calendar_event') + op.drop_index('ix_calendar_user', table_name='calendar') + op.drop_table('calendar') diff --git a/backend/open_webui/models/automations.py b/backend/open_webui/models/automations.py index 02a6f231da..c7c78a7c8e 100644 --- a/backend/open_webui/models/automations.py +++ b/backend/open_webui/models/automations.py @@ -153,6 +153,18 @@ class AutomationTable: row = await db.get(Automation, id) return AutomationModel.model_validate(row) if row else None + async def get_active_by_user( + self, user_id: str, db: Optional[AsyncSession] = None + ) -> list[AutomationModel]: + """Get active automations for a user (for calendar RRULE expansion).""" + async with get_async_db_context(db) as db: + result = await db.execute( + select(Automation) + .filter_by(user_id=user_id, is_active=True) + .order_by(Automation.created_at.desc()) + ) + return [AutomationModel.model_validate(r) for r in result.scalars().all()] + async def search_automations( self, user_id: str, @@ -383,6 +395,32 @@ class AutomationRunTable: await db.commit() return result.rowcount + async def get_runs_by_user_range( + self, + user_id: str, + start_ns: int, + end_ns: int, + limit: int = 500, + db: Optional[AsyncSession] = None, + ) -> list[tuple['AutomationRunModel', 'AutomationModel']]: + """Get runs within a date range for a user, joined with parent automation.""" + async with get_async_db_context(db) as db: + result = await db.execute( + select(AutomationRun, Automation) + .join(Automation, Automation.id == AutomationRun.automation_id) + .filter( + Automation.user_id == user_id, + AutomationRun.created_at >= start_ns, + AutomationRun.created_at < end_ns, + ) + .order_by(AutomationRun.created_at.desc()) + .limit(limit) + ) + return [ + (AutomationRunModel.model_validate(run), AutomationModel.model_validate(auto)) + for run, auto in result.all() + ] + Automations = AutomationTable() AutomationRuns = AutomationRunTable() diff --git a/backend/open_webui/models/calendar.py b/backend/open_webui/models/calendar.py new file mode 100644 index 0000000000..e055c87f90 --- /dev/null +++ b/backend/open_webui/models/calendar.py @@ -0,0 +1,820 @@ +import time +import logging +from typing import Optional +from uuid import uuid4 + +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import ( + Column, + Text, + JSON, + Boolean, + BigInteger, + Index, + UniqueConstraint, + select, + or_, + exists, + func, + delete, +) +from sqlalchemy.ext.asyncio import AsyncSession + +from open_webui.internal.db import Base, get_async_db_context +from open_webui.models.access_grants import AccessGrantModel, AccessGrants +from open_webui.models.groups import Groups +from open_webui.models.users import User, UserModel, UserResponse + +log = logging.getLogger(__name__) + + +#################### +# Calendar DB Schema +#################### + + +class Calendar(Base): + __tablename__ = 'calendar' + + id = Column(Text, primary_key=True) + user_id = Column(Text, nullable=False) + name = Column(Text, nullable=False) + color = Column(Text, nullable=True) + is_system = Column(Boolean, nullable=False, default=False) + data = Column(JSON, nullable=True) + meta = Column(JSON, nullable=True) + + created_at = Column(BigInteger, nullable=False) + updated_at = Column(BigInteger, nullable=False) + + __table_args__ = (Index('ix_calendar_user', 'user_id'),) + + +class CalendarEvent(Base): + __tablename__ = 'calendar_event' + + id = Column(Text, primary_key=True) + calendar_id = Column(Text, nullable=False) + user_id = Column(Text, nullable=False) + title = Column(Text, nullable=False) + description = Column(Text, nullable=True) + start_at = Column(BigInteger, nullable=False) + end_at = Column(BigInteger, nullable=True) + all_day = Column(Boolean, nullable=False, default=False) + rrule = Column(Text, nullable=True) + color = Column(Text, nullable=True) + location = Column(Text, nullable=True) + data = Column(JSON, nullable=True) + meta = Column(JSON, nullable=True) + is_cancelled = Column(Boolean, nullable=False, default=False) + + created_at = Column(BigInteger, nullable=False) + updated_at = Column(BigInteger, nullable=False) + + __table_args__ = ( + Index('ix_calendar_event_calendar', 'calendar_id', 'start_at'), + Index('ix_calendar_event_user_date', 'user_id', 'start_at'), + ) + + +class CalendarEventAttendee(Base): + __tablename__ = 'calendar_event_attendee' + + id = Column(Text, primary_key=True) + event_id = Column(Text, nullable=False) + user_id = Column(Text, nullable=False) + status = Column(Text, nullable=False, default='pending') + meta = Column(JSON, nullable=True) + + created_at = Column(BigInteger, nullable=False) + updated_at = Column(BigInteger, nullable=False) + + __table_args__ = ( + UniqueConstraint('event_id', 'user_id', name='uq_event_attendee'), + Index('ix_calendar_event_attendee_user', 'user_id', 'status'), + ) + + +#################### +# Pydantic Models +#################### + + +class CalendarModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + user_id: str + name: str + color: Optional[str] = None + is_system: bool = False + data: Optional[dict] = None + meta: Optional[dict] = None + + access_grants: list[AccessGrantModel] = Field(default_factory=list) + + created_at: int + updated_at: int + + +class CalendarEventModel(BaseModel): + model_config = ConfigDict(from_attributes=True, extra='allow') + + id: str + calendar_id: str + user_id: str + title: str + description: Optional[str] = None + start_at: int + end_at: Optional[int] = None + all_day: bool = False + rrule: Optional[str] = None + color: Optional[str] = None + location: Optional[str] = None + data: Optional[dict] = None + meta: Optional[dict] = None + is_cancelled: bool = False + + attendees: list['CalendarEventAttendeeModel'] = Field(default_factory=list) + + created_at: int + updated_at: int + + +class CalendarEventAttendeeModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + event_id: str + user_id: str + status: str = 'pending' + meta: Optional[dict] = None + + created_at: int + updated_at: int + + +#################### +# Forms +#################### + + +class CalendarForm(BaseModel): + name: str + color: Optional[str] = None + data: Optional[dict] = None + meta: Optional[dict] = None + access_grants: Optional[list[dict]] = None + + +class CalendarUpdateForm(BaseModel): + name: Optional[str] = None + color: Optional[str] = None + data: Optional[dict] = None + meta: Optional[dict] = None + access_grants: Optional[list[dict]] = None + + +class CalendarEventForm(BaseModel): + calendar_id: str + title: str + description: Optional[str] = None + start_at: int + end_at: Optional[int] = None + all_day: bool = False + rrule: Optional[str] = None + color: Optional[str] = None + location: Optional[str] = None + data: Optional[dict] = None + meta: Optional[dict] = None + attendees: Optional[list[dict]] = None + + +class CalendarEventUpdateForm(BaseModel): + calendar_id: Optional[str] = None + title: Optional[str] = None + description: Optional[str] = None + start_at: Optional[int] = None + end_at: Optional[int] = None + all_day: Optional[bool] = None + rrule: Optional[str] = None + color: Optional[str] = None + location: Optional[str] = None + data: Optional[dict] = None + meta: Optional[dict] = None + is_cancelled: Optional[bool] = None + attendees: Optional[list[dict]] = None + + +class RSVPForm(BaseModel): + status: str # 'accepted' | 'declined' | 'tentative' | 'pending' + + +#################### +# Response Models +#################### + + +class CalendarEventUserResponse(CalendarEventModel): + user: Optional[UserResponse] = None + + +class CalendarEventListResponse(BaseModel): + items: list[CalendarEventUserResponse] + total: int + + +#################### +# Table Operations +#################### + + +class CalendarTable: + async def _get_access_grants( + self, calendar_id: str, db: Optional[AsyncSession] = None + ) -> list[AccessGrantModel]: + return await AccessGrants.get_grants_by_resource('calendar', calendar_id, db=db) + + async def _to_calendar_model( + self, + cal: Calendar, + access_grants: Optional[list[AccessGrantModel]] = None, + db: Optional[AsyncSession] = None, + ) -> CalendarModel: + cal_data = CalendarModel.model_validate(cal).model_dump(exclude={'access_grants'}) + cal_data['access_grants'] = ( + access_grants + if access_grants is not None + else await self._get_access_grants(cal_data['id'], db=db) + ) + return CalendarModel.model_validate(cal_data) + + async def get_or_create_defaults( + self, user_id: str, db: Optional[AsyncSession] = None + ) -> list[CalendarModel]: + """Return user's calendars, creating 'Personal' and 'Scheduled Tasks' if none exist.""" + async with get_async_db_context(db) as db: + result = await db.execute( + select(Calendar).filter(Calendar.user_id == user_id).order_by(Calendar.created_at.asc()) + ) + calendars = result.scalars().all() + + if calendars: + return [CalendarModel.model_validate(c) for c in calendars] + + now = int(time.time_ns()) + defaults = [ + Calendar( + id=str(uuid4()), + user_id=user_id, + name='Personal', + color='#3b82f6', + is_system=True, + created_at=now, + updated_at=now, + ), + Calendar( + id=str(uuid4()), + user_id=user_id, + name='Scheduled Tasks', + color='#8b5cf6', + is_system=True, + created_at=now + 1, + updated_at=now + 1, + ), + ] + for cal in defaults: + db.add(cal) + await db.commit() + return [CalendarModel.model_validate(c) for c in defaults] + + async def get_calendars_by_user( + self, user_id: str, db: Optional[AsyncSession] = None + ) -> list[CalendarModel]: + """Owned + shared calendars.""" + async with get_async_db_context(db) as db: + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = [g.id for g in user_groups] + + stmt = select(Calendar) + stmt = AccessGrants.has_permission_filter( + db=db, + query=stmt, + DocumentModel=Calendar, + filter={'user_id': user_id, 'group_ids': user_group_ids}, + resource_type='calendar', + permission='read', + ) + stmt = stmt.order_by(Calendar.created_at.asc()) + + result = await db.execute(stmt) + calendars = result.scalars().all() + + if not calendars: + return await self.get_or_create_defaults(user_id, db=db) + + cal_ids = [c.id for c in calendars] + grants_map = await AccessGrants.get_grants_by_resources('calendar', cal_ids, db=db) + + return [ + await self._to_calendar_model(c, access_grants=grants_map.get(c.id, []), db=db) + for c in calendars + ] + + async def get_calendar_by_id( + self, id: str, db: Optional[AsyncSession] = None + ) -> Optional[CalendarModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Calendar).filter(Calendar.id == id)) + cal = result.scalars().first() + return await self._to_calendar_model(cal, db=db) if cal else None + + async def get_scheduled_tasks_calendar( + self, user_id: str, db: Optional[AsyncSession] = None + ) -> Optional[CalendarModel]: + """Get the user's Scheduled Tasks calendar (for automation integration).""" + async with get_async_db_context(db) as db: + result = await db.execute( + select(Calendar).filter( + Calendar.user_id == user_id, + Calendar.name == 'Scheduled Tasks', + Calendar.is_system == True, + ) + ) + cal = result.scalars().first() + if not cal: + # Ensure defaults exist then retry + await self.get_or_create_defaults(user_id, db=db) + result = await db.execute( + select(Calendar).filter( + Calendar.user_id == user_id, + Calendar.name == 'Scheduled Tasks', + Calendar.is_system == True, + ) + ) + cal = result.scalars().first() + # Lightweight return — skip access_grants loading since we only need id/color + return CalendarModel.model_validate(cal) if cal else None + + async def insert_new_calendar( + self, user_id: str, form_data: CalendarForm, db: Optional[AsyncSession] = None + ) -> Optional[CalendarModel]: + async with get_async_db_context(db) as db: + now = int(time.time_ns()) + cal = Calendar( + id=str(uuid4()), + user_id=user_id, + name=form_data.name, + color=form_data.color, + is_system=False, + data=form_data.data, + meta=form_data.meta, + created_at=now, + updated_at=now, + ) + db.add(cal) + await db.commit() + if form_data.access_grants is not None: + await AccessGrants.set_access_grants('calendar', cal.id, form_data.access_grants, db=db) + return await self._to_calendar_model(cal, db=db) + + async def update_calendar_by_id( + self, id: str, form_data: CalendarUpdateForm, db: Optional[AsyncSession] = None + ) -> Optional[CalendarModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Calendar).filter(Calendar.id == id)) + cal = result.scalars().first() + if not cal: + return None + + update_data = form_data.model_dump(exclude_unset=True) + if 'name' in update_data: + cal.name = update_data['name'] + if 'color' in update_data: + cal.color = update_data['color'] + if 'data' in update_data: + cal.data = {**(cal.data or {}), **update_data['data']} + if 'meta' in update_data: + cal.meta = {**(cal.meta or {}), **update_data['meta']} + if 'access_grants' in update_data: + await AccessGrants.set_access_grants('calendar', id, update_data['access_grants'], db=db) + + cal.updated_at = int(time.time_ns()) + await db.commit() + return await self._to_calendar_model(cal, db=db) + + async def delete_calendar_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: + """Delete a non-system calendar. Cascades to events, attendees, and grants.""" + try: + async with get_async_db_context(db) as db: + result = await db.execute(select(Calendar).filter(Calendar.id == id)) + cal = result.scalars().first() + if not cal or cal.is_system: + return False + + # Delete attendees for all events in this calendar + event_ids_result = await db.execute( + select(CalendarEvent.id).filter(CalendarEvent.calendar_id == id) + ) + event_ids = [r[0] for r in event_ids_result.all()] + if event_ids: + await db.execute( + delete(CalendarEventAttendee).filter( + CalendarEventAttendee.event_id.in_(event_ids) + ) + ) + + # Delete events + await db.execute(delete(CalendarEvent).filter(CalendarEvent.calendar_id == id)) + + # Delete access grants + await AccessGrants.revoke_all_access('calendar', id, db=db) + + # Delete calendar + await db.execute(delete(Calendar).filter(Calendar.id == id)) + await db.commit() + return True + except Exception: + return False + + +class CalendarEventTable: + async def _get_attendees( + self, event_id: str, db: Optional[AsyncSession] = None + ) -> list[CalendarEventAttendeeModel]: + async with get_async_db_context(db) as db: + result = await db.execute( + select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id) + ) + rows = result.scalars().all() + return [CalendarEventAttendeeModel.model_validate(r) for r in rows] + + async def _to_event_model( + self, + event: CalendarEvent, + attendees: Optional[list[CalendarEventAttendeeModel]] = None, + db: Optional[AsyncSession] = None, + ) -> CalendarEventModel: + event_data = CalendarEventModel.model_validate(event).model_dump(exclude={'attendees'}) + event_data['attendees'] = ( + attendees if attendees is not None else await self._get_attendees(event_data['id'], db=db) + ) + return CalendarEventModel.model_validate(event_data) + + async def insert_new_event( + self, user_id: str, form_data: CalendarEventForm, db: Optional[AsyncSession] = None + ) -> Optional[CalendarEventModel]: + async with get_async_db_context(db) as db: + now = int(time.time_ns()) + event = CalendarEvent( + id=str(uuid4()), + calendar_id=form_data.calendar_id, + user_id=user_id, + title=form_data.title, + description=form_data.description, + start_at=form_data.start_at, + end_at=form_data.end_at, + all_day=form_data.all_day, + rrule=form_data.rrule, + color=form_data.color, + location=form_data.location, + data=form_data.data, + meta=form_data.meta, + is_cancelled=False, + created_at=now, + updated_at=now, + ) + db.add(event) + await db.commit() + + # Add attendees + if form_data.attendees: + await CalendarEventAttendees.set_attendees(event.id, form_data.attendees, db=db) + + return await self._to_event_model(event, db=db) + + async def get_event_by_id( + self, id: str, db: Optional[AsyncSession] = None + ) -> Optional[CalendarEventModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(CalendarEvent).filter(CalendarEvent.id == id)) + event = result.scalars().first() + return await self._to_event_model(event, db=db) if event else None + + async def get_events_by_range( + self, + user_id: str, + start: int, + end: int, + calendar_ids: Optional[list[str]] = None, + db: Optional[AsyncSession] = None, + ) -> list[CalendarEventUserResponse]: + """Fetch events visible to user within a date range. + + Visible events = events in owned/shared calendars + events user attends. + Recurring events are fetched if they have any rrule (expansion in Python). + """ + async with get_async_db_context(db) as db: + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = [g.id for g in user_groups] + + # Get calendar IDs accessible to user + cal_stmt = select(Calendar.id) + cal_stmt = AccessGrants.has_permission_filter( + db=db, + query=cal_stmt, + DocumentModel=Calendar, + filter={'user_id': user_id, 'group_ids': user_group_ids}, + resource_type='calendar', + permission='read', + ) + cal_result = await db.execute(cal_stmt) + accessible_cal_ids = [r[0] for r in cal_result.all()] + + if calendar_ids: + # Filter to requested calendars only + accessible_cal_ids = [c for c in accessible_cal_ids if c in calendar_ids] + + # Also get event IDs where user is an attendee + attendee_event_ids_result = await db.execute( + select(CalendarEventAttendee.event_id).filter( + CalendarEventAttendee.user_id == user_id + ) + ) + attendee_event_ids = [r[0] for r in attendee_event_ids_result.all()] + + # Build conditions for accessible events + conditions = [] + if accessible_cal_ids: + conditions.append(CalendarEvent.calendar_id.in_(accessible_cal_ids)) + if attendee_event_ids: + conditions.append(CalendarEvent.id.in_(attendee_event_ids)) + + if not conditions: + return [] + + # Build event query + stmt = ( + select(CalendarEvent, User) + .outerjoin(User, User.id == CalendarEvent.user_id) + .filter( + CalendarEvent.is_cancelled == False, + or_(*conditions), + or_( + # Non-recurring: overlaps the range + ( + CalendarEvent.rrule.is_(None) + & (CalendarEvent.start_at < end) + & or_( + CalendarEvent.end_at.is_(None) & (CalendarEvent.start_at >= start), + CalendarEvent.end_at.isnot(None) & (CalendarEvent.end_at > start), + ) + ), + # Recurring: fetch all (expansion in Python) + CalendarEvent.rrule.isnot(None), + ), + ) + .order_by(CalendarEvent.start_at.asc()) + ) + + result = await db.execute(stmt) + items = result.all() + + if not items: + return [] + + # Batch-load attendees for all events in one query (avoid N+1) + event_ids = [event.id for event, _user in items] + att_result = await db.execute( + select(CalendarEventAttendee).filter( + CalendarEventAttendee.event_id.in_(event_ids) + ) + ) + att_rows = att_result.scalars().all() + att_map: dict[str, list[CalendarEventAttendeeModel]] = {} + for a in att_rows: + att_map.setdefault(a.event_id, []).append( + CalendarEventAttendeeModel.model_validate(a) + ) + + events = [] + for event, user in items: + event_data = CalendarEventModel.model_validate(event).model_dump(exclude={'attendees'}) + event_data['attendees'] = att_map.get(event.id, []) + events.append( + CalendarEventUserResponse( + **event_data, + user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None), + ) + ) + + return events + + async def search_events( + self, + user_id: str, + query: Optional[str] = None, + skip: int = 0, + limit: int = 30, + db: Optional[AsyncSession] = None, + ) -> CalendarEventListResponse: + async with get_async_db_context(db) as db: + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = [g.id for g in user_groups] + + # Get accessible calendar IDs + cal_stmt = select(Calendar.id) + cal_stmt = AccessGrants.has_permission_filter( + db=db, + query=cal_stmt, + DocumentModel=Calendar, + filter={'user_id': user_id, 'group_ids': user_group_ids}, + resource_type='calendar', + permission='read', + ) + cal_result = await db.execute(cal_stmt) + accessible_cal_ids = [r[0] for r in cal_result.all()] + if not accessible_cal_ids: + return CalendarEventListResponse(items=[], total=0) + + stmt = ( + select(CalendarEvent, User) + .outerjoin(User, User.id == CalendarEvent.user_id) + .filter( + CalendarEvent.is_cancelled == False, + CalendarEvent.calendar_id.in_(accessible_cal_ids), + ) + ) + + if query: + search = f'%{query}%' + stmt = stmt.filter( + or_( + CalendarEvent.title.ilike(search), + CalendarEvent.description.ilike(search), + CalendarEvent.location.ilike(search), + ) + ) + + stmt = stmt.order_by(CalendarEvent.start_at.desc()) + + count_result = await db.execute(select(func.count()).select_from(stmt.subquery())) + total = count_result.scalar() + + if skip: + stmt = stmt.offset(skip) + if limit: + stmt = stmt.limit(limit) + + result = await db.execute(stmt) + items = result.all() + + if not items: + return CalendarEventListResponse(items=[], total=total) + + # Batch-load attendees + event_ids = [event.id for event, _user in items] + att_result = await db.execute( + select(CalendarEventAttendee).filter( + CalendarEventAttendee.event_id.in_(event_ids) + ) + ) + att_rows = att_result.scalars().all() + att_map: dict[str, list[CalendarEventAttendeeModel]] = {} + for a in att_rows: + att_map.setdefault(a.event_id, []).append( + CalendarEventAttendeeModel.model_validate(a) + ) + + events = [] + for event, user in items: + event_data = CalendarEventModel.model_validate(event).model_dump(exclude={'attendees'}) + event_data['attendees'] = att_map.get(event.id, []) + events.append( + CalendarEventUserResponse( + **event_data, + user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None), + ) + ) + + return CalendarEventListResponse(items=events, total=total) + + async def update_event_by_id( + self, id: str, form_data: CalendarEventUpdateForm, db: Optional[AsyncSession] = None + ) -> Optional[CalendarEventModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(CalendarEvent).filter(CalendarEvent.id == id)) + event = result.scalars().first() + if not event: + return None + + update_data = form_data.model_dump(exclude_unset=True) + for field in [ + 'calendar_id', 'title', 'description', 'start_at', 'end_at', + 'all_day', 'rrule', 'color', 'location', 'is_cancelled', + ]: + if field in update_data: + setattr(event, field, update_data[field]) + + if 'data' in update_data and update_data['data'] is not None: + event.data = {**(event.data or {}), **update_data['data']} + if 'meta' in update_data and update_data['meta'] is not None: + event.meta = {**(event.meta or {}), **update_data['meta']} + + if 'attendees' in update_data and update_data['attendees'] is not None: + await CalendarEventAttendees.set_attendees(id, update_data['attendees'], db=db) + + event.updated_at = int(time.time_ns()) + await db.commit() + return await self._to_event_model(event, db=db) + + + async def delete_event_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: + try: + async with get_async_db_context(db) as db: + await db.execute( + delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == id) + ) + await db.execute(delete(CalendarEvent).filter(CalendarEvent.id == id)) + await db.commit() + return True + except Exception: + return False + + +class CalendarEventAttendeeTable: + async def set_attendees( + self, event_id: str, attendees: list[dict], db: Optional[AsyncSession] = None + ) -> list[CalendarEventAttendeeModel]: + """Replace all attendees for an event. + + Each dict in attendees: {user_id: str, status?: str, meta?: dict} + """ + async with get_async_db_context(db) as db: + # Remove existing + await db.execute( + delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id) + ) + + now = int(time.time_ns()) + models = [] + for att in attendees: + row = CalendarEventAttendee( + id=str(uuid4()), + event_id=event_id, + user_id=att['user_id'], + status=att.get('status', 'pending'), + meta=att.get('meta'), + created_at=now, + updated_at=now, + ) + db.add(row) + models.append(CalendarEventAttendeeModel.model_validate(row)) + + await db.commit() + return models + + async def update_rsvp( + self, event_id: str, user_id: str, status: str, db: Optional[AsyncSession] = None + ) -> Optional[CalendarEventAttendeeModel]: + async with get_async_db_context(db) as db: + result = await db.execute( + select(CalendarEventAttendee).filter( + CalendarEventAttendee.event_id == event_id, + CalendarEventAttendee.user_id == user_id, + ) + ) + att = result.scalars().first() + if not att: + return None + + att.status = status + att.updated_at = int(time.time_ns()) + await db.commit() + return CalendarEventAttendeeModel.model_validate(att) + + async def get_attendees_by_event( + self, event_id: str, db: Optional[AsyncSession] = None + ) -> list[CalendarEventAttendeeModel]: + async with get_async_db_context(db) as db: + result = await db.execute( + select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id) + ) + return [CalendarEventAttendeeModel.model_validate(r) for r in result.scalars().all()] + + async def get_events_by_attendee( + self, user_id: str, db: Optional[AsyncSession] = None + ) -> list[str]: + """Return event IDs where user is an attendee.""" + async with get_async_db_context(db) as db: + result = await db.execute( + select(CalendarEventAttendee.event_id).filter( + CalendarEventAttendee.user_id == user_id + ) + ) + return [r[0] for r in result.all()] + + +Calendars = CalendarTable() +CalendarEvents = CalendarEventTable() +CalendarEventAttendees = CalendarEventAttendeeTable() diff --git a/backend/open_webui/routers/calendar.py b/backend/open_webui/routers/calendar.py new file mode 100644 index 0000000000..4b052bd754 --- /dev/null +++ b/backend/open_webui/routers/calendar.py @@ -0,0 +1,319 @@ +import logging +import time +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from open_webui.models.calendar import ( + Calendars, + CalendarEvents, + CalendarEventAttendees, + CalendarForm, + CalendarUpdateForm, + CalendarEventForm, + CalendarEventUpdateForm, + CalendarModel, + CalendarEventModel, + CalendarEventUserResponse, + CalendarEventListResponse, + RSVPForm, +) +from open_webui.models.access_grants import AccessGrants +from open_webui.models.groups import Groups +from open_webui.models.users import UserModel +from open_webui.utils.auth import get_verified_user +from open_webui.utils.calendar import expand_recurring_event + +log = logging.getLogger(__name__) + +router = APIRouter() + + +async def _check_calendar_access( + calendar_id: str, user: UserModel, permission: str = 'write' +) -> CalendarModel: + """Verify user has access to a calendar. Returns the calendar or raises 403/404.""" + cal = await Calendars.get_calendar_by_id(calendar_id) + if not cal: + raise HTTPException(status_code=404, detail='Calendar not found') + if cal.user_id == user.id or user.role == 'admin': + return cal + user_groups = await Groups.get_groups_by_member_id(user.id) + user_group_ids = [g.id for g in user_groups] + if await AccessGrants.has_access( + user_id=user.id, + resource_type='calendar', + resource_id=cal.id, + permission=permission, + user_group_ids=user_group_ids, + ): + return cal + raise HTTPException(status_code=403, detail='Access denied') + + +#################### +# Calendar CRUD (static paths first) +#################### + + +@router.get('/', response_model=list[CalendarModel]) +async def get_calendars(user: UserModel = Depends(get_verified_user)): + """List user's calendars (owned + shared). Auto-creates defaults on first call.""" + return await Calendars.get_calendars_by_user(user.id) + + +@router.post('/create', response_model=CalendarModel) +async def create_calendar(form_data: CalendarForm, user: UserModel = Depends(get_verified_user)): + """Create a new user calendar.""" + return await Calendars.insert_new_calendar(user.id, form_data) + + +#################### +# Event CRUD (before /{calendar_id} to avoid route conflicts) +#################### + + +@router.get('/events') +async def get_events( + start: str, + end: str, + calendar_ids: Optional[str] = None, + user: UserModel = Depends(get_verified_user), +): + """Get events in date range. + + Args: + start: ISO 8601 datetime string (e.g. 2026-04-01T00:00:00) + end: ISO 8601 datetime string (e.g. 2026-05-01T00:00:00) + calendar_ids: optional comma-separated list to filter + + Includes: + - Stored events from the database + - Virtual events computed from active automation RRULEs (Scheduled Tasks calendar) + """ + from datetime import datetime + + try: + start_dt = datetime.fromisoformat(start.replace('Z', '+00:00')) + end_dt = datetime.fromisoformat(end.replace('Z', '+00:00')) + except ValueError: + raise HTTPException(status_code=400, detail='Invalid date format. Use ISO 8601 (e.g. 2026-04-01T00:00:00)') + + NS = 1_000_000 + start_ns = int(start_dt.timestamp() * 1000) * NS + end_ns = int(end_dt.timestamp() * 1000) * NS + cal_id_list = calendar_ids.split(',') if calendar_ids else None + + # 1. Stored events + events = await CalendarEvents.get_events_by_range( + user_id=user.id, + start=start_ns, + end=end_ns, + calendar_ids=cal_id_list, + ) + + # Expand recurring stored events + expanded = [] + for event in events: + event_dict = event.model_dump() + if event_dict.get('rrule'): + instances = expand_recurring_event( + event_dict, start_ns, end_ns, tz=user.timezone + ) + for inst in instances: + expanded.append(CalendarEventUserResponse(**{**inst, 'user': event.user})) + else: + expanded.append(event) + + # 2. Virtual automation events (Scheduled Tasks calendar) + try: + from open_webui.models.automations import Automations, AutomationRuns + + scheduled_cal = await Calendars.get_scheduled_tasks_calendar(user.id) + if scheduled_cal and (cal_id_list is None or scheduled_cal.id in cal_id_list): + # Future runs: expand RRULEs for active automations only + active_automations = await Automations.get_active_by_user(user.id) + for auto in active_automations: + rrule_str = auto.data.get('rrule', '') if auto.data else '' + if not rrule_str: + continue + + virtual = { + 'id': f'auto_{auto.id}', + 'calendar_id': scheduled_cal.id, + 'user_id': user.id, + 'title': auto.name, + 'description': auto.data.get('prompt', '') if auto.data else '', + 'start_at': auto.next_run_at or 0, + 'end_at': None, + 'all_day': False, + 'rrule': rrule_str, + 'color': None, + 'location': None, + 'data': None, + 'meta': {'automation_id': auto.id}, + 'is_cancelled': False, + 'attendees': [], + 'created_at': auto.created_at, + 'updated_at': auto.updated_at, + 'user': None, + } + + # Only expand into the future — past runs are handled below + now_ns = int(time.time_ns()) + rrule_start = max(start_ns, now_ns) + instances = expand_recurring_event(virtual, rrule_start, end_ns, tz=user.timezone) + for inst in instances: + expanded.append(CalendarEventUserResponse(**inst)) + + # Past runs: single range query joined with automation + runs_with_auto = await AutomationRuns.get_runs_by_user_range( + user.id, start_ns, end_ns + ) + for run, auto in runs_with_auto: + expanded.append(CalendarEventUserResponse( + id=f'run_{run.id}', + calendar_id=scheduled_cal.id, + user_id=user.id, + title=auto.name, + description=run.error if run.status == 'error' else '', + start_at=run.created_at, + end_at=None, + all_day=False, + color=None, + location=None, + data=None, + meta={ + 'automation_id': auto.id, + 'run_id': run.id, + 'chat_id': run.chat_id, + 'status': run.status, + }, + is_cancelled=False, + attendees=[], + created_at=run.created_at, + updated_at=run.created_at, + user=None, + )) + except Exception as e: + log.warning(f'Failed to compute automation events: {e}', exc_info=True) + + return [e.model_dump() if hasattr(e, 'model_dump') else e for e in expanded] + + +@router.post('/events/create', response_model=CalendarEventModel) +async def create_event(form_data: CalendarEventForm, user: UserModel = Depends(get_verified_user)): + await _check_calendar_access(form_data.calendar_id, user, 'write') + return await CalendarEvents.insert_new_event(user.id, form_data) + + +@router.get('/events/search', response_model=CalendarEventListResponse) +async def search_events( + query: Optional[str] = None, + skip: int = 0, + limit: int = 30, + user: UserModel = Depends(get_verified_user), +): + return await CalendarEvents.search_events( + user_id=user.id, query=query, skip=skip, limit=limit + ) + + +@router.get('/events/{event_id}', response_model=CalendarEventModel) +async def get_event(event_id: str, user: UserModel = Depends(get_verified_user)): + event = await CalendarEvents.get_event_by_id(event_id) + if not event: + raise HTTPException(status_code=404, detail='Event not found') + + await _check_calendar_access(event.calendar_id, user, 'read') + + return event + + +@router.post('/events/{event_id}/update', response_model=CalendarEventModel) +async def update_event( + event_id: str, form_data: CalendarEventUpdateForm, user: UserModel = Depends(get_verified_user) +): + event = await CalendarEvents.get_event_by_id(event_id) + if not event: + raise HTTPException(status_code=404, detail='Event not found') + + await _check_calendar_access(event.calendar_id, user, 'write') + + updated = await CalendarEvents.update_event_by_id(event_id, form_data) + if not updated: + raise HTTPException(status_code=500, detail='Failed to update') + return updated + + +@router.delete('/events/{event_id}/delete') +async def delete_event(event_id: str, user: UserModel = Depends(get_verified_user)): + event = await CalendarEvents.get_event_by_id(event_id) + if not event: + raise HTTPException(status_code=404, detail='Event not found') + + await _check_calendar_access(event.calendar_id, user, 'write') + + result = await CalendarEvents.delete_event_by_id(event_id) + if not result: + raise HTTPException(status_code=500, detail='Failed to delete') + return {'status': True} + + +@router.post('/events/{event_id}/rsvp', response_model=dict) +async def rsvp_event( + event_id: str, form_data: RSVPForm, user: UserModel = Depends(get_verified_user) +): + """Update own RSVP status for an event.""" + if form_data.status not in ('accepted', 'declined', 'tentative', 'pending'): + raise HTTPException(status_code=400, detail='Invalid status') + + result = await CalendarEventAttendees.update_rsvp(event_id, user.id, form_data.status) + if not result: + raise HTTPException(status_code=404, detail='Not an attendee of this event') + return {'status': True, 'rsvp': result.status} + + +#################### +# Calendar by ID (dynamic path — MUST come after /events* routes) +#################### + + +@router.get('/{calendar_id}', response_model=CalendarModel) +async def get_calendar_by_id(calendar_id: str, user: UserModel = Depends(get_verified_user)): + cal = await _check_calendar_access(calendar_id, user, 'read') + return cal + + +@router.post('/{calendar_id}/update', response_model=CalendarModel) +async def update_calendar( + calendar_id: str, form_data: CalendarUpdateForm, user: UserModel = Depends(get_verified_user) +): + cal = await _check_calendar_access(calendar_id, user, 'write') + + # Only owner/admin can change access grants + if form_data.access_grants is not None and cal.user_id != user.id and user.role != 'admin': + raise HTTPException(status_code=403, detail='Only owner can manage sharing') + + updated = await Calendars.update_calendar_by_id(calendar_id, form_data) + if not updated: + raise HTTPException(status_code=500, detail='Failed to update') + return updated + + +@router.delete('/{calendar_id}/delete') +async def delete_calendar(calendar_id: str, user: UserModel = Depends(get_verified_user)): + cal = await _check_calendar_access(calendar_id, user, 'write') + + # Only owner/admin can delete + if cal.user_id != user.id and user.role != 'admin': + raise HTTPException(status_code=403, detail='Only owner can delete calendar') + + if cal.is_system: + raise HTTPException(status_code=400, detail='Cannot delete system calendar') + + result = await Calendars.delete_calendar_by_id(calendar_id) + if not result: + raise HTTPException(status_code=500, detail='Failed to delete') + return {'status': True} diff --git a/backend/open_webui/utils/calendar.py b/backend/open_webui/utils/calendar.py new file mode 100644 index 0000000000..9484c58dc0 --- /dev/null +++ b/backend/open_webui/utils/calendar.py @@ -0,0 +1,83 @@ +""" +Calendar utilities. + +RRULE expansion reusing the automation infra. +""" + +import logging +from datetime import datetime, timedelta +from typing import Optional +from zoneinfo import ZoneInfo + +from open_webui.utils.automations import _parse_rule + +log = logging.getLogger(__name__) + + +def expand_recurring_event( + event_dict: dict, + range_start_ns: int, + range_end_ns: int, + tz: Optional[str] = None, + max_instances: int = 5000, +) -> list[dict]: + """Expand a recurring event into individual instances within a date range. + + Takes an event dict (from CalendarEventModel.model_dump()) and produces + one dict per occurrence, with adjusted start_at / end_at. + """ + from dateutil.rrule import rrulestr + + rrule_str = event_dict.get('rrule') + if not rrule_str: + return [event_dict] + + range_start_dt = datetime.fromtimestamp(range_start_ns / 1_000_000_000) + range_end_dt = datetime.fromtimestamp(range_end_ns / 1_000_000_000) + scan_start = range_start_dt - timedelta(days=1) + + try: + # Parse with dtstart near the range so we never iterate from epoch + rule = rrulestr(rrule_str, dtstart=scan_start, ignoretz=True) + except Exception: + log.warning(f'Failed to parse RRULE for event {event_dict.get("id")}: {rrule_str}') + return [event_dict] + + original_start_ns = event_dict['start_at'] + original_end_ns = event_dict.get('end_at') + duration_ns = (original_end_ns - original_start_ns) if original_end_ns else None + + instances = [] + dt = rule.after(scan_start, inc=True) + + while dt and dt < range_end_dt and len(instances) < max_instances: + if tz: + try: + dt_tz = dt.replace(tzinfo=ZoneInfo(tz)) + instance_start_ns = int(dt_tz.timestamp() * 1_000_000_000) + except Exception: + instance_start_ns = int(dt.timestamp() * 1_000_000_000) + else: + instance_start_ns = int(dt.timestamp() * 1_000_000_000) + + if instance_start_ns >= range_start_ns: + instance = { + **event_dict, + 'start_at': instance_start_ns, + 'end_at': (instance_start_ns + duration_ns) if duration_ns else None, + 'instance_id': f'{event_dict["id"]}_{instance_start_ns}', + } + instances.append(instance) + + dt = rule.after(dt) + + return instances + + +def ns_from_date(year: int, month: int, day: int, tz: Optional[str] = None) -> int: + """Create epoch nanoseconds from a date.""" + if tz: + dt = datetime(year, month, day, tzinfo=ZoneInfo(tz)) + else: + dt = datetime(year, month, day) + return int(dt.timestamp() * 1_000_000_000) diff --git a/src/lib/apis/calendar/index.ts b/src/lib/apis/calendar/index.ts new file mode 100644 index 0000000000..39540bb890 --- /dev/null +++ b/src/lib/apis/calendar/index.ts @@ -0,0 +1,427 @@ +import { WEBUI_API_BASE_URL } from '$lib/constants'; + +export type CalendarModel = { + id: string; + user_id: string; + name: string; + color: string | null; + is_system: boolean; + data: Record | null; + meta: Record | null; + access_grants: any[]; + created_at: number; + updated_at: number; +}; + +export type CalendarEventAttendeeModel = { + id: string; + event_id: string; + user_id: string; + status: string; + meta: Record | null; + created_at: number; + updated_at: number; +}; + +export type CalendarEventModel = { + id: string; + calendar_id: string; + user_id: string; + title: string; + description: string | null; + start_at: number; + end_at: number | null; + all_day: boolean; + rrule: string | null; + color: string | null; + location: string | null; + data: Record | null; + meta: Record | null; + is_cancelled: boolean; + attendees: CalendarEventAttendeeModel[]; + created_at: number; + updated_at: number; + // Set by expand_recurring_event for recurring instances + instance_id?: string; +}; + +export type CalendarEventForm = { + calendar_id: string; + title: string; + description?: string; + start_at: number; + end_at?: number; + all_day?: boolean; + rrule?: string; + color?: string; + location?: string; + data?: Record; + meta?: Record; + attendees?: { user_id: string; status?: string }[]; +}; + +export type CalendarForm = { + name: string; + color?: string; + data?: Record; + meta?: Record; + access_grants?: { target_type: string; target_id: string; permission: string }[]; +}; + +// ── Calendars ───────────────────────────────── + +export const getCalendars = async (token: string): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/`, { + 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(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const createCalendar = async (token: string, form: CalendarForm): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/create`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify(form) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateCalendar = async ( + token: string, + calendarId: string, + form: Partial +): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/${calendarId}/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify(form) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const deleteCalendar = async (token: string, calendarId: string): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/${calendarId}/delete`, { + method: 'DELETE', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res?.status ?? false; +}; + +// ── Events ───────────────────────────────── + +export const getCalendarEvents = async ( + token: string, + start: string, + end: string, + calendarIds?: string[] +): Promise => { + let error = null; + + const params = new URLSearchParams(); + params.append('start', start); + params.append('end', end); + if (calendarIds && calendarIds.length > 0) { + params.append('calendar_ids', calendarIds.join(',')); + } + + const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events?${params.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(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const createCalendarEvent = async ( + token: string, + form: CalendarEventForm +): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events/create`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify(form) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getCalendarEventById = async ( + token: string, + eventId: string +): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events/${eventId}`, { + 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(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateCalendarEvent = async ( + token: string, + eventId: string, + form: Partial +): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events/${eventId}/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify(form) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const deleteCalendarEvent = async (token: string, eventId: string): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events/${eventId}/delete`, { + method: 'DELETE', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res?.status ?? false; +}; + +export const rsvpCalendarEvent = async ( + token: string, + eventId: string, + status: string +): Promise<{ status: boolean; rsvp: string }> => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events/${eventId}/rsvp`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ status }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + + +export const searchCalendarEvents = async ( + token: string, + query: string | null, + skip: number = 0, + limit: number = 30 +): Promise<{ items: CalendarEventModel[]; total: number }> => { + let error = null; + + const params = new URLSearchParams(); + if (query) params.append('query', query); + params.append('skip', skip.toString()); + params.append('limit', limit.toString()); + + const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events/search?${params.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(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/components/calendar/CalendarEventChip.svelte b/src/lib/components/calendar/CalendarEventChip.svelte new file mode 100644 index 0000000000..c63eac1063 --- /dev/null +++ b/src/lib/components/calendar/CalendarEventChip.svelte @@ -0,0 +1,28 @@ + + + + + diff --git a/src/lib/components/calendar/CalendarEventModal.svelte b/src/lib/components/calendar/CalendarEventModal.svelte new file mode 100644 index 0000000000..6e334e9cc6 --- /dev/null +++ b/src/lib/components/calendar/CalendarEventModal.svelte @@ -0,0 +1,260 @@ + + + +
+ +
+ + +
+ + +
+ +
+
{$i18n.t('Calendar')}
+ +
+ + +
+
{$i18n.t('When')}
+
+ + {#if !allDay} + + + + {/if} + +
+
+ + +
+
{$i18n.t('Location')}
+ +
+ + +
+
{$i18n.t('Description')}
+ +
+
+ + +
+
+ {#if event && !event.meta?.automation_id} + + {/if} +
+ +
+ + +
+
+
+
diff --git a/src/lib/components/calendar/CalendarSidebar.svelte b/src/lib/components/calendar/CalendarSidebar.svelte new file mode 100644 index 0000000000..09e604a281 --- /dev/null +++ b/src/lib/components/calendar/CalendarSidebar.svelte @@ -0,0 +1,174 @@ + + +
+ +
+
+
{miniMonthNames[miniMonth]} {miniYear}
+
+ + +
+
+ +
+ {#each ['S', 'M', 'T', 'W', 'T', 'F', 'S'] as d} +
{d}
+ {/each} +
+ +
+ {#each miniDays as day} + + {/each} +
+
+ + +
+
+
+ {$i18n.t('Calendars')} +
+
+ + {#each calendars as cal (cal.id)} + + {/each} +
+
diff --git a/src/lib/components/calendar/CalendarView.svelte b/src/lib/components/calendar/CalendarView.svelte new file mode 100644 index 0000000000..0b67d7de98 --- /dev/null +++ b/src/lib/components/calendar/CalendarView.svelte @@ -0,0 +1,380 @@ + + +
+ + + + + {#if view === 'month'} +
+
+ {#each DAY_NAMES as day} +
{$i18n.t(day)}
+ {/each} +
+ +
+ {#each monthDays as day, i} + {@const dayKey = new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime().toString()} + {@const dayEvents = eventsByDay[dayKey] || []} + {@const col = i % 7} + {@const row = Math.floor(i / 7)} + + {/each} +
+
+ + + {:else if view === 'week'} +
+
+
+
+
+
+ {#each weekDays as day} +
+
{DAY_NAMES[day.getDay()]}
+
+ {day.getDate()} +
+
+ {/each} +
+ +
+ {#each hours as hour} +
+
{hour > 0 ? formatHour(hour) : ''}
+ {#each weekDays as day} + {@const hourEvents = getEventsForHour(day, hour, filteredEvents)} + + {/each} +
+ {/each} +
+
+
+
+
+ + + {:else} +
+
+ {#each hours as hour} + {@const hourEvents = getEventsForHour(currentDate, hour, filteredEvents)} +
+
{hour > 0 ? formatHour(hour) : ''}
+ +
+ {/each} +
+
+ {/if} +
diff --git a/src/lib/components/layout/Sidebar/UserMenu.svelte b/src/lib/components/layout/Sidebar/UserMenu.svelte index 1a1681a844..30c29962b4 100644 --- a/src/lib/components/layout/Sidebar/UserMenu.svelte +++ b/src/lib/components/layout/Sidebar/UserMenu.svelte @@ -250,6 +250,38 @@ {/if} + {#if $user?.role === 'admin' || $user?.permissions?.features?.calendar} + { + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return; + e.preventDefault(); + show = false; + goto('/calendar'); + }} + > +
+ + + +
+
{$i18n.t('Calendar')}
+
+ {/if} + {#if role === 'admin'} + import { onMount, getContext, tick } from 'svelte'; + import { toast } from 'svelte-sonner'; + import { goto } from '$app/navigation'; + import { WEBUI_NAME, mobile, showSidebar, user } from '$lib/stores'; + import { + getCalendars, + getCalendarEvents, + type CalendarModel, + type CalendarEventModel + } from '$lib/apis/calendar'; + import CalendarView from '$lib/components/calendar/CalendarView.svelte'; + import CalendarSidebar from '$lib/components/calendar/CalendarSidebar.svelte'; + import CalendarEventModal from '$lib/components/calendar/CalendarEventModal.svelte'; + import Spinner from '$lib/components/common/Spinner.svelte'; + import Plus from '$lib/components/icons/Plus.svelte'; + + const i18n = getContext('i18n'); + + let loaded = false; + let calendars: CalendarModel[] = []; + let events: CalendarEventModel[] = []; + let visibleCalendarIds: Set = new Set(); + + let view: 'month' | 'week' | 'day' = 'month'; + let currentDate = new Date(); + + let showEventModal = false; + let editEvent: CalendarEventModel | null = null; + let defaultStartAt: number | null = null; + + function getVisibleRange(): { start: string; end: string } { + const d = new Date(currentDate); + let start: Date; + let end: Date; + + if (view === 'month') { + start = new Date(d.getFullYear(), d.getMonth(), 1); + start.setDate(start.getDate() - start.getDay()); + end = new Date(start); + end.setDate(end.getDate() + 42); + } else if (view === 'week') { + start = new Date(d); + start.setDate(start.getDate() - start.getDay()); + start.setHours(0, 0, 0, 0); + end = new Date(start); + end.setDate(end.getDate() + 7); + } else { + start = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + end = new Date(start); + end.setDate(end.getDate() + 1); + } + + return { + start: start.toISOString(), + end: end.toISOString() + }; + } + + async function loadCalendars() { + try { + calendars = (await getCalendars(localStorage.token)) ?? []; + visibleCalendarIds = new Set(calendars.map((c) => c.id)); + } catch (err) { + console.error('loadCalendars', err); + calendars = []; + } + } + + async function loadEvents() { + try { + const { start, end } = getVisibleRange(); + events = await getCalendarEvents(localStorage.token, start, end); + } catch (err) { + toast.error(`${err}`); + } + } + + async function refresh() { + await loadEvents(); + } + + function toggleCalendar(id: string) { + const next = new Set(visibleCalendarIds); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + visibleCalendarIds = next; + } + + function handleCreateEvent(e: CustomEvent<{ start_at: number }>) { + editEvent = null; + defaultStartAt = e.detail.start_at; + showEventModal = true; + } + + function handleEventClick(e: CustomEvent) { + const evt = e.detail; + if (evt.meta?.automation_id) { + if (evt.meta?.chat_id) { + goto(`/c/${evt.meta.chat_id}`); + } else { + goto(`/automations/${evt.meta.automation_id}`); + } + return; + } + editEvent = evt; + defaultStartAt = null; + showEventModal = true; + } + + async function handleNavigate() { + await tick(); + refresh(); + } + + async function handleDateSelect(date: Date) { + currentDate = date; + await tick(); + refresh(); + } + + function handleNewEvent() { + editEvent = null; + defaultStartAt = null; + showEventModal = true; + } + + $: defaultCalendarId = calendars.find((c) => !c.is_system || c.name === 'Personal')?.id || calendars[0]?.id || ''; + + onMount(async () => { + await loadCalendars(); + await refresh(); + loaded = true; + }); + + + + {$i18n.t('Calendar')} • {$WEBUI_NAME} + + + refresh()} + on:delete={() => refresh()} +/> + +
+ {#if loaded} +
+ + + + +
+ +
+
+ {:else} +
+ +
+ {/if} +
From 4a5401b4174edbef8d102ac2917944ae1d2cdc00 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 19 Apr 2026 21:48:27 +0900 Subject: [PATCH 04/41] refac --- .../56359461a091_add_calendar_tables.py | 2 +- backend/open_webui/models/calendar.py | 40 ++++++++++++++----- backend/open_webui/routers/calendar.py | 10 ++++- src/lib/apis/calendar/index.ts | 33 ++++++++++++++- src/routes/(app)/calendar/+page.svelte | 2 +- 5 files changed, 73 insertions(+), 14 deletions(-) diff --git a/backend/open_webui/migrations/versions/56359461a091_add_calendar_tables.py b/backend/open_webui/migrations/versions/56359461a091_add_calendar_tables.py index 8277daa738..a0812578c8 100644 --- a/backend/open_webui/migrations/versions/56359461a091_add_calendar_tables.py +++ b/backend/open_webui/migrations/versions/56359461a091_add_calendar_tables.py @@ -24,7 +24,7 @@ def upgrade() -> None: sa.Column('user_id', sa.Text(), nullable=False), sa.Column('name', sa.Text(), nullable=False), sa.Column('color', sa.Text(), nullable=True), - sa.Column('is_system', sa.Boolean(), nullable=False), + sa.Column('is_default', sa.Boolean(), nullable=False), sa.Column('data', sa.JSON(), nullable=True), sa.Column('meta', sa.JSON(), nullable=True), sa.Column('created_at', sa.BigInteger(), nullable=False), diff --git a/backend/open_webui/models/calendar.py b/backend/open_webui/models/calendar.py index e055c87f90..859632c494 100644 --- a/backend/open_webui/models/calendar.py +++ b/backend/open_webui/models/calendar.py @@ -17,6 +17,7 @@ from sqlalchemy import ( exists, func, delete, + update, ) from sqlalchemy.ext.asyncio import AsyncSession @@ -40,7 +41,7 @@ class Calendar(Base): user_id = Column(Text, nullable=False) name = Column(Text, nullable=False) color = Column(Text, nullable=True) - is_system = Column(Boolean, nullable=False, default=False) + is_default = Column(Boolean, nullable=False, default=False) data = Column(JSON, nullable=True) meta = Column(JSON, nullable=True) @@ -107,7 +108,8 @@ class CalendarModel(BaseModel): user_id: str name: str color: Optional[str] = None - is_system: bool = False + is_default: bool = False + data: Optional[dict] = None meta: Optional[dict] = None @@ -269,7 +271,7 @@ class CalendarTable: user_id=user_id, name='Personal', color='#3b82f6', - is_system=True, + is_default=True, created_at=now, updated_at=now, ), @@ -278,7 +280,6 @@ class CalendarTable: user_id=user_id, name='Scheduled Tasks', color='#8b5cf6', - is_system=True, created_at=now + 1, updated_at=now + 1, ), @@ -338,7 +339,6 @@ class CalendarTable: select(Calendar).filter( Calendar.user_id == user_id, Calendar.name == 'Scheduled Tasks', - Calendar.is_system == True, ) ) cal = result.scalars().first() @@ -349,7 +349,6 @@ class CalendarTable: select(Calendar).filter( Calendar.user_id == user_id, Calendar.name == 'Scheduled Tasks', - Calendar.is_system == True, ) ) cal = result.scalars().first() @@ -366,7 +365,7 @@ class CalendarTable: user_id=user_id, name=form_data.name, color=form_data.color, - is_system=False, + is_default=False, data=form_data.data, meta=form_data.meta, created_at=now, @@ -403,13 +402,36 @@ class CalendarTable: await db.commit() return await self._to_calendar_model(cal, db=db) + async def set_default_calendar( + self, user_id: str, calendar_id: str, db: Optional[AsyncSession] = None + ) -> Optional[CalendarModel]: + """Set a calendar as the user's default, clearing all others.""" + async with get_async_db_context(db) as db: + # Clear all defaults for this user + await db.execute( + update(Calendar) + .where(Calendar.user_id == user_id, Calendar.is_default == True) + .values(is_default=False) + ) + # Set the new default + result = await db.execute( + select(Calendar).filter(Calendar.id == calendar_id, Calendar.user_id == user_id) + ) + cal = result.scalars().first() + if not cal: + return None + cal.is_default = True + cal.updated_at = int(time.time_ns()) + await db.commit() + return await self._to_calendar_model(cal, db=db) + async def delete_calendar_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: - """Delete a non-system calendar. Cascades to events, attendees, and grants.""" + """Delete a non-default calendar. Cascades to events, attendees, and grants.""" try: async with get_async_db_context(db) as db: result = await db.execute(select(Calendar).filter(Calendar.id == id)) cal = result.scalars().first() - if not cal or cal.is_system: + if not cal or cal.is_default: return False # Delete attendees for all events in this calendar diff --git a/backend/open_webui/routers/calendar.py b/backend/open_webui/routers/calendar.py index 4b052bd754..220edf853c 100644 --- a/backend/open_webui/routers/calendar.py +++ b/backend/open_webui/routers/calendar.py @@ -310,10 +310,16 @@ async def delete_calendar(calendar_id: str, user: UserModel = Depends(get_verifi if cal.user_id != user.id and user.role != 'admin': raise HTTPException(status_code=403, detail='Only owner can delete calendar') - if cal.is_system: - raise HTTPException(status_code=400, detail='Cannot delete system calendar') result = await Calendars.delete_calendar_by_id(calendar_id) if not result: raise HTTPException(status_code=500, detail='Failed to delete') return {'status': True} + + +@router.post('/{calendar_id}/default') +async def set_default_calendar(calendar_id: str, user: UserModel = Depends(get_verified_user)): + cal = await Calendars.set_default_calendar(user.id, calendar_id) + if not cal: + raise HTTPException(status_code=404, detail='Calendar not found') + return cal diff --git a/src/lib/apis/calendar/index.ts b/src/lib/apis/calendar/index.ts index 39540bb890..fa75f9a6a7 100644 --- a/src/lib/apis/calendar/index.ts +++ b/src/lib/apis/calendar/index.ts @@ -5,7 +5,7 @@ export type CalendarModel = { user_id: string; name: string; color: string | null; - is_system: boolean; + is_default: boolean; data: Record | null; meta: Record | null; access_grants: any[]; @@ -188,6 +188,37 @@ export const deleteCalendar = async (token: string, calendarId: string): Promise return res?.status ?? false; }; +export const setDefaultCalendar = async ( + token: string, + calendarId: string +): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/${calendarId}/default`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + // ── Events ───────────────────────────────── export const getCalendarEvents = async ( diff --git a/src/routes/(app)/calendar/+page.svelte b/src/routes/(app)/calendar/+page.svelte index 89940ef23b..25671fc791 100644 --- a/src/routes/(app)/calendar/+page.svelte +++ b/src/routes/(app)/calendar/+page.svelte @@ -128,7 +128,7 @@ showEventModal = true; } - $: defaultCalendarId = calendars.find((c) => !c.is_system || c.name === 'Personal')?.id || calendars[0]?.id || ''; + $: defaultCalendarId = calendars.find((c) => c.is_default)?.id || calendars[0]?.id || ''; onMount(async () => { await loadCalendars(); From f0ec5ee08ff6978131b3d657c8a8bc98c8533d05 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 19 Apr 2026 21:49:48 +0900 Subject: [PATCH 05/41] refac --- src/lib/components/calendar/CalendarSidebar.svelte | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/components/calendar/CalendarSidebar.svelte b/src/lib/components/calendar/CalendarSidebar.svelte index 09e604a281..8ed0df4727 100644 --- a/src/lib/components/calendar/CalendarSidebar.svelte +++ b/src/lib/components/calendar/CalendarSidebar.svelte @@ -72,7 +72,7 @@
-
{miniMonthNames[miniMonth]} {miniYear}
+
{miniMonthNames[miniMonth]} {miniYear}
-
+
{#each ['S', 'M', 'T', 'W', 'T', 'F', 'S'] as d}
{d}
{/each}
-
+
{#each miniDays as day}
+ + diff --git a/src/lib/components/workspace/Models/BuiltinTools.svelte b/src/lib/components/workspace/Models/BuiltinTools.svelte index b900d004b3..fe99005688 100644 --- a/src/lib/components/workspace/Models/BuiltinTools.svelte +++ b/src/lib/components/workspace/Models/BuiltinTools.svelte @@ -50,6 +50,10 @@ automations: { label: $i18n.t('Automations'), description: $i18n.t('Create and manage scheduled automations') + }, + calendar: { + label: $i18n.t('Calendar'), + description: $i18n.t('List calendars, search, create, update, and delete calendar events') } }; From f45d0f130ef9c3d3958f54320b987f2e2eacc421 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 19 Apr 2026 22:22:15 +0900 Subject: [PATCH 07/41] refac --- backend/open_webui/config.py | 6 +++ backend/open_webui/main.py | 3 ++ backend/open_webui/routers/auths.py | 4 ++ backend/open_webui/routers/calendar.py | 49 ++++++++++++++----- backend/open_webui/utils/tools.py | 5 +- .../components/admin/Settings/General.svelte | 8 +++ .../components/layout/Sidebar/UserMenu.svelte | 2 +- 7 files changed, 63 insertions(+), 14 deletions(-) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index a68720a7c0..1ee107a6ac 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1624,6 +1624,12 @@ ENABLE_CHANNELS = PersistentConfig( os.environ.get('ENABLE_CHANNELS', 'False').lower() == 'true', ) +ENABLE_CALENDAR = PersistentConfig( + 'ENABLE_CALENDAR', + 'calendar.enable', + os.environ.get('ENABLE_CALENDAR', 'True').lower() == 'true', +) + AUTOMATION_MAX_COUNT = PersistentConfig( 'AUTOMATION_MAX_COUNT', 'automations.max_count', diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index c13250c587..23379a7750 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -396,6 +396,7 @@ from open_webui.config import ( AUTOMATION_MAX_COUNT, AUTOMATION_MIN_INTERVAL, ENABLE_CHANNELS, + ENABLE_CALENDAR, ENABLE_NOTES, ENABLE_USER_STATUS, ENABLE_COMMUNITY_SHARING, @@ -902,6 +903,7 @@ app.state.config.FOLDER_MAX_FILE_COUNT = FOLDER_MAX_FILE_COUNT app.state.config.AUTOMATION_MAX_COUNT = AUTOMATION_MAX_COUNT app.state.config.AUTOMATION_MIN_INTERVAL = AUTOMATION_MIN_INTERVAL app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS +app.state.config.ENABLE_CALENDAR = ENABLE_CALENDAR app.state.config.ENABLE_NOTES = ENABLE_NOTES app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING app.state.config.ENABLE_MESSAGE_RATING = ENABLE_MESSAGE_RATING @@ -2218,6 +2220,7 @@ async def get_app_config(request: Request): 'enable_folders': app.state.config.ENABLE_FOLDERS, 'folder_max_file_count': app.state.config.FOLDER_MAX_FILE_COUNT, 'enable_channels': app.state.config.ENABLE_CHANNELS, + 'enable_calendar': app.state.config.ENABLE_CALENDAR, 'enable_notes': app.state.config.ENABLE_NOTES, 'enable_web_search': app.state.config.ENABLE_WEB_SEARCH, 'enable_code_execution': app.state.config.ENABLE_CODE_EXECUTION, diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 651e123b64..c8daa4957a 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -972,6 +972,7 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)): 'AUTOMATION_MAX_COUNT': request.app.state.config.AUTOMATION_MAX_COUNT, 'AUTOMATION_MIN_INTERVAL': request.app.state.config.AUTOMATION_MIN_INTERVAL, 'ENABLE_CHANNELS': request.app.state.config.ENABLE_CHANNELS, + 'ENABLE_CALENDAR': request.app.state.config.ENABLE_CALENDAR, 'ENABLE_MEMORIES': request.app.state.config.ENABLE_MEMORIES, 'ENABLE_NOTES': request.app.state.config.ENABLE_NOTES, 'ENABLE_USER_WEBHOOKS': request.app.state.config.ENABLE_USER_WEBHOOKS, @@ -1000,6 +1001,7 @@ class AdminConfig(BaseModel): AUTOMATION_MAX_COUNT: Optional[int | str] = None AUTOMATION_MIN_INTERVAL: Optional[int | str] = None ENABLE_CHANNELS: bool + ENABLE_CALENDAR: bool ENABLE_MEMORIES: bool ENABLE_NOTES: bool ENABLE_USER_WEBHOOKS: bool @@ -1031,6 +1033,7 @@ async def update_admin_config(request: Request, form_data: AdminConfig, user=Dep int(form_data.AUTOMATION_MIN_INTERVAL) if form_data.AUTOMATION_MIN_INTERVAL else '' ) request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS + request.app.state.config.ENABLE_CALENDAR = form_data.ENABLE_CALENDAR request.app.state.config.ENABLE_MEMORIES = form_data.ENABLE_MEMORIES request.app.state.config.ENABLE_NOTES = form_data.ENABLE_NOTES @@ -1074,6 +1077,7 @@ async def update_admin_config(request: Request, form_data: AdminConfig, user=Dep 'AUTOMATION_MAX_COUNT': request.app.state.config.AUTOMATION_MAX_COUNT, 'AUTOMATION_MIN_INTERVAL': request.app.state.config.AUTOMATION_MIN_INTERVAL, 'ENABLE_CHANNELS': request.app.state.config.ENABLE_CHANNELS, + 'ENABLE_CALENDAR': request.app.state.config.ENABLE_CALENDAR, 'ENABLE_MEMORIES': request.app.state.config.ENABLE_MEMORIES, 'ENABLE_NOTES': request.app.state.config.ENABLE_NOTES, 'ENABLE_USER_WEBHOOKS': request.app.state.config.ENABLE_USER_WEBHOOKS, diff --git a/backend/open_webui/routers/calendar.py b/backend/open_webui/routers/calendar.py index 220edf853c..f92f5b8943 100644 --- a/backend/open_webui/routers/calendar.py +++ b/backend/open_webui/routers/calendar.py @@ -2,7 +2,7 @@ import logging import time from typing import Optional -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from open_webui.models.calendar import ( @@ -24,12 +24,22 @@ from open_webui.models.groups import Groups from open_webui.models.users import UserModel from open_webui.utils.auth import get_verified_user from open_webui.utils.calendar import expand_recurring_event +from open_webui.constants import ERROR_MESSAGES log = logging.getLogger(__name__) router = APIRouter() +async def check_calendar_enabled(request: Request): + """Dependency to ensure calendar feature is globally enabled.""" + if not request.app.state.config.ENABLE_CALENDAR: + raise HTTPException( + status_code=403, + detail=ERROR_MESSAGES.FEATURE_DISABLED('Calendar'), + ) + + async def _check_calendar_access( calendar_id: str, user: UserModel, permission: str = 'write' ) -> CalendarModel: @@ -58,14 +68,16 @@ async def _check_calendar_access( @router.get('/', response_model=list[CalendarModel]) -async def get_calendars(user: UserModel = Depends(get_verified_user)): +async def get_calendars(request: Request, user: UserModel = Depends(get_verified_user)): """List user's calendars (owned + shared). Auto-creates defaults on first call.""" + await check_calendar_enabled(request) return await Calendars.get_calendars_by_user(user.id) @router.post('/create', response_model=CalendarModel) -async def create_calendar(form_data: CalendarForm, user: UserModel = Depends(get_verified_user)): +async def create_calendar(request: Request, form_data: CalendarForm, user: UserModel = Depends(get_verified_user)): """Create a new user calendar.""" + await check_calendar_enabled(request) return await Calendars.insert_new_calendar(user.id, form_data) @@ -76,6 +88,7 @@ async def create_calendar(form_data: CalendarForm, user: UserModel = Depends(get @router.get('/events') async def get_events( + request: Request, start: str, end: str, calendar_ids: Optional[str] = None, @@ -92,6 +105,7 @@ async def get_events( - Stored events from the database - Virtual events computed from active automation RRULEs (Scheduled Tasks calendar) """ + await check_calendar_enabled(request) from datetime import datetime try: @@ -203,25 +217,29 @@ async def get_events( @router.post('/events/create', response_model=CalendarEventModel) -async def create_event(form_data: CalendarEventForm, user: UserModel = Depends(get_verified_user)): +async def create_event(request: Request, form_data: CalendarEventForm, user: UserModel = Depends(get_verified_user)): + await check_calendar_enabled(request) await _check_calendar_access(form_data.calendar_id, user, 'write') return await CalendarEvents.insert_new_event(user.id, form_data) @router.get('/events/search', response_model=CalendarEventListResponse) async def search_events( + request: Request, query: Optional[str] = None, skip: int = 0, limit: int = 30, user: UserModel = Depends(get_verified_user), ): + await check_calendar_enabled(request) return await CalendarEvents.search_events( user_id=user.id, query=query, skip=skip, limit=limit ) @router.get('/events/{event_id}', response_model=CalendarEventModel) -async def get_event(event_id: str, user: UserModel = Depends(get_verified_user)): +async def get_event(request: Request, event_id: str, user: UserModel = Depends(get_verified_user)): + await check_calendar_enabled(request) event = await CalendarEvents.get_event_by_id(event_id) if not event: raise HTTPException(status_code=404, detail='Event not found') @@ -233,8 +251,9 @@ async def get_event(event_id: str, user: UserModel = Depends(get_verified_user)) @router.post('/events/{event_id}/update', response_model=CalendarEventModel) async def update_event( - event_id: str, form_data: CalendarEventUpdateForm, user: UserModel = Depends(get_verified_user) + request: Request, event_id: str, form_data: CalendarEventUpdateForm, user: UserModel = Depends(get_verified_user) ): + await check_calendar_enabled(request) event = await CalendarEvents.get_event_by_id(event_id) if not event: raise HTTPException(status_code=404, detail='Event not found') @@ -248,7 +267,8 @@ async def update_event( @router.delete('/events/{event_id}/delete') -async def delete_event(event_id: str, user: UserModel = Depends(get_verified_user)): +async def delete_event(request: Request, event_id: str, user: UserModel = Depends(get_verified_user)): + await check_calendar_enabled(request) event = await CalendarEvents.get_event_by_id(event_id) if not event: raise HTTPException(status_code=404, detail='Event not found') @@ -263,9 +283,10 @@ async def delete_event(event_id: str, user: UserModel = Depends(get_verified_use @router.post('/events/{event_id}/rsvp', response_model=dict) async def rsvp_event( - event_id: str, form_data: RSVPForm, user: UserModel = Depends(get_verified_user) + request: Request, event_id: str, form_data: RSVPForm, user: UserModel = Depends(get_verified_user) ): """Update own RSVP status for an event.""" + await check_calendar_enabled(request) if form_data.status not in ('accepted', 'declined', 'tentative', 'pending'): raise HTTPException(status_code=400, detail='Invalid status') @@ -281,15 +302,17 @@ async def rsvp_event( @router.get('/{calendar_id}', response_model=CalendarModel) -async def get_calendar_by_id(calendar_id: str, user: UserModel = Depends(get_verified_user)): +async def get_calendar_by_id(request: Request, calendar_id: str, user: UserModel = Depends(get_verified_user)): + await check_calendar_enabled(request) cal = await _check_calendar_access(calendar_id, user, 'read') return cal @router.post('/{calendar_id}/update', response_model=CalendarModel) async def update_calendar( - calendar_id: str, form_data: CalendarUpdateForm, user: UserModel = Depends(get_verified_user) + request: Request, calendar_id: str, form_data: CalendarUpdateForm, user: UserModel = Depends(get_verified_user) ): + await check_calendar_enabled(request) cal = await _check_calendar_access(calendar_id, user, 'write') # Only owner/admin can change access grants @@ -303,7 +326,8 @@ async def update_calendar( @router.delete('/{calendar_id}/delete') -async def delete_calendar(calendar_id: str, user: UserModel = Depends(get_verified_user)): +async def delete_calendar(request: Request, calendar_id: str, user: UserModel = Depends(get_verified_user)): + await check_calendar_enabled(request) cal = await _check_calendar_access(calendar_id, user, 'write') # Only owner/admin can delete @@ -318,7 +342,8 @@ async def delete_calendar(calendar_id: str, user: UserModel = Depends(get_verifi @router.post('/{calendar_id}/default') -async def set_default_calendar(calendar_id: str, user: UserModel = Depends(get_verified_user)): +async def set_default_calendar(request: Request, calendar_id: str, user: UserModel = Depends(get_verified_user)): + await check_calendar_enabled(request) cal = await Calendars.set_default_calendar(user.id, calendar_id) if not cal: raise HTTPException(status_code=404, detail='Calendar not found') diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 52a8868391..e0791a35ff 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -557,7 +557,10 @@ async def get_builtin_tools( ) # Calendar tools - search/create/update/delete events - if is_builtin_tool_enabled('calendar'): + if ( + is_builtin_tool_enabled('calendar') + and getattr(request.app.state.config, 'ENABLE_CALENDAR', False) + ): builtin_functions.extend( [search_calendar_events, create_calendar_event, update_calendar_event, delete_calendar_event] ) diff --git a/src/lib/components/admin/Settings/General.svelte b/src/lib/components/admin/Settings/General.svelte index f2ba4a3ee1..f535bd68ee 100644 --- a/src/lib/components/admin/Settings/General.svelte +++ b/src/lib/components/admin/Settings/General.svelte @@ -756,6 +756,14 @@
+
+
+ {$i18n.t('Calendar')} ({$i18n.t('Beta')}) +
+ + +
+
{$i18n.t('Memories')} ({$i18n.t('Beta')}) diff --git a/src/lib/components/layout/Sidebar/UserMenu.svelte b/src/lib/components/layout/Sidebar/UserMenu.svelte index 30c29962b4..b159a0dd61 100644 --- a/src/lib/components/layout/Sidebar/UserMenu.svelte +++ b/src/lib/components/layout/Sidebar/UserMenu.svelte @@ -250,7 +250,7 @@ {/if} - {#if $user?.role === 'admin' || $user?.permissions?.features?.calendar} + {#if $config?.features?.enable_calendar && ($user?.role === 'admin' || $user?.permissions?.features?.calendar)} Date: Sun, 19 Apr 2026 22:33:32 +0900 Subject: [PATCH 08/41] refac --- backend/open_webui/config.py | 6 ++++++ backend/open_webui/main.py | 3 +++ backend/open_webui/routers/auths.py | 4 ++++ backend/open_webui/routers/automations.py | 5 +++++ backend/open_webui/utils/automations.py | 4 ++++ backend/open_webui/utils/tools.py | 6 +++++- src/lib/components/admin/Settings/General.svelte | 14 +++++++++++--- src/lib/components/layout/Sidebar/UserMenu.svelte | 2 +- src/routes/(app)/automations/+page.svelte | 2 +- src/routes/(app)/automations/[id]/+page.svelte | 4 ++-- 10 files changed, 42 insertions(+), 8 deletions(-) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 1ee107a6ac..53de67387f 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1630,6 +1630,12 @@ ENABLE_CALENDAR = PersistentConfig( os.environ.get('ENABLE_CALENDAR', 'True').lower() == 'true', ) +ENABLE_AUTOMATIONS = PersistentConfig( + 'ENABLE_AUTOMATIONS', + 'automations.enable', + os.environ.get('ENABLE_AUTOMATIONS', 'True').lower() == 'true', +) + AUTOMATION_MAX_COUNT = PersistentConfig( 'AUTOMATION_MAX_COUNT', 'automations.max_count', diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 23379a7750..f9b21e6592 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -393,6 +393,7 @@ from open_webui.config import ( API_KEYS_ALLOWED_ENDPOINTS, ENABLE_FOLDERS, FOLDER_MAX_FILE_COUNT, + ENABLE_AUTOMATIONS, AUTOMATION_MAX_COUNT, AUTOMATION_MIN_INTERVAL, ENABLE_CHANNELS, @@ -900,6 +901,7 @@ app.state.config.BANNERS = WEBUI_BANNERS app.state.config.ENABLE_FOLDERS = ENABLE_FOLDERS app.state.config.FOLDER_MAX_FILE_COUNT = FOLDER_MAX_FILE_COUNT +app.state.config.ENABLE_AUTOMATIONS = ENABLE_AUTOMATIONS app.state.config.AUTOMATION_MAX_COUNT = AUTOMATION_MAX_COUNT app.state.config.AUTOMATION_MIN_INTERVAL = AUTOMATION_MIN_INTERVAL app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS @@ -2221,6 +2223,7 @@ async def get_app_config(request: Request): 'folder_max_file_count': app.state.config.FOLDER_MAX_FILE_COUNT, 'enable_channels': app.state.config.ENABLE_CHANNELS, 'enable_calendar': app.state.config.ENABLE_CALENDAR, + 'enable_automations': app.state.config.ENABLE_AUTOMATIONS, 'enable_notes': app.state.config.ENABLE_NOTES, 'enable_web_search': app.state.config.ENABLE_WEB_SEARCH, 'enable_code_execution': app.state.config.ENABLE_CODE_EXECUTION, diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index c8daa4957a..d3337d8109 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -971,6 +971,7 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)): 'FOLDER_MAX_FILE_COUNT': request.app.state.config.FOLDER_MAX_FILE_COUNT, 'AUTOMATION_MAX_COUNT': request.app.state.config.AUTOMATION_MAX_COUNT, 'AUTOMATION_MIN_INTERVAL': request.app.state.config.AUTOMATION_MIN_INTERVAL, + 'ENABLE_AUTOMATIONS': request.app.state.config.ENABLE_AUTOMATIONS, 'ENABLE_CHANNELS': request.app.state.config.ENABLE_CHANNELS, 'ENABLE_CALENDAR': request.app.state.config.ENABLE_CALENDAR, 'ENABLE_MEMORIES': request.app.state.config.ENABLE_MEMORIES, @@ -1000,6 +1001,7 @@ class AdminConfig(BaseModel): FOLDER_MAX_FILE_COUNT: Optional[int | str] = None AUTOMATION_MAX_COUNT: Optional[int | str] = None AUTOMATION_MIN_INTERVAL: Optional[int | str] = None + ENABLE_AUTOMATIONS: bool ENABLE_CHANNELS: bool ENABLE_CALENDAR: bool ENABLE_MEMORIES: bool @@ -1032,6 +1034,7 @@ async def update_admin_config(request: Request, form_data: AdminConfig, user=Dep request.app.state.config.AUTOMATION_MIN_INTERVAL = ( int(form_data.AUTOMATION_MIN_INTERVAL) if form_data.AUTOMATION_MIN_INTERVAL else '' ) + request.app.state.config.ENABLE_AUTOMATIONS = form_data.ENABLE_AUTOMATIONS request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS request.app.state.config.ENABLE_CALENDAR = form_data.ENABLE_CALENDAR request.app.state.config.ENABLE_MEMORIES = form_data.ENABLE_MEMORIES @@ -1076,6 +1079,7 @@ async def update_admin_config(request: Request, form_data: AdminConfig, user=Dep 'FOLDER_MAX_FILE_COUNT': request.app.state.config.FOLDER_MAX_FILE_COUNT, 'AUTOMATION_MAX_COUNT': request.app.state.config.AUTOMATION_MAX_COUNT, 'AUTOMATION_MIN_INTERVAL': request.app.state.config.AUTOMATION_MIN_INTERVAL, + 'ENABLE_AUTOMATIONS': request.app.state.config.ENABLE_AUTOMATIONS, 'ENABLE_CHANNELS': request.app.state.config.ENABLE_CHANNELS, 'ENABLE_CALENDAR': request.app.state.config.ENABLE_CALENDAR, 'ENABLE_MEMORIES': request.app.state.config.ENABLE_MEMORIES, diff --git a/backend/open_webui/routers/automations.py b/backend/open_webui/routers/automations.py index d68bd8e2c6..ed33c4e8cb 100644 --- a/backend/open_webui/routers/automations.py +++ b/backend/open_webui/routers/automations.py @@ -39,6 +39,11 @@ PAGE_ITEM_COUNT = 30 async def check_automations_permission(request, user): + if not request.app.state.config.ENABLE_AUTOMATIONS: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) if user.role != 'admin' and not await has_permission( user.id, 'features.automations', request.app.state.config.USER_PERMISSIONS ): diff --git a/backend/open_webui/utils/automations.py b/backend/open_webui/utils/automations.py index 3866eb865a..ac1f4df699 100644 --- a/backend/open_webui/utils/automations.py +++ b/backend/open_webui/utils/automations.py @@ -126,6 +126,10 @@ async def automation_worker_loop(app) -> None: log.info(f'Automation worker started (poll interval: {AUTOMATION_POLL_INTERVAL}s)') while True: try: + if not getattr(app.state.config, 'ENABLE_AUTOMATIONS', False): + await asyncio.sleep(AUTOMATION_POLL_INTERVAL) + continue + async with get_async_db() as db: batch = await Automations.claim_due(int(time.time_ns()), limit=10, db=db) if batch: diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index e0791a35ff..1c47fc75a6 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -551,7 +551,11 @@ async def get_builtin_tools( builtin_functions.extend([create_tasks, update_task]) # Automation tools - create and manage scheduled automations from chat - if is_builtin_tool_enabled('automations') and await has_user_permission('automations'): + if ( + is_builtin_tool_enabled('automations') + and getattr(request.app.state.config, 'ENABLE_AUTOMATIONS', False) + and await has_user_permission('automations') + ): builtin_functions.extend( [create_automation, update_automation, list_automations, toggle_automation, delete_automation] ) diff --git a/src/lib/components/admin/Settings/General.svelte b/src/lib/components/admin/Settings/General.svelte index f535bd68ee..ddb81e844b 100644 --- a/src/lib/components/admin/Settings/General.svelte +++ b/src/lib/components/admin/Settings/General.svelte @@ -740,6 +740,14 @@
{/if} +
+
+ {$i18n.t('Memories')} ({$i18n.t('Beta')}) +
+ + +
+
{$i18n.t('Notes')} ({$i18n.t('Beta')}) @@ -758,7 +766,7 @@
- {$i18n.t('Calendar')} ({$i18n.t('Beta')}) + {$i18n.t('Calendar')}
@@ -766,10 +774,10 @@
- {$i18n.t('Memories')} ({$i18n.t('Beta')}) + {$i18n.t('Automations')}
- +
diff --git a/src/lib/components/layout/Sidebar/UserMenu.svelte b/src/lib/components/layout/Sidebar/UserMenu.svelte index b159a0dd61..dddcf42550 100644 --- a/src/lib/components/layout/Sidebar/UserMenu.svelte +++ b/src/lib/components/layout/Sidebar/UserMenu.svelte @@ -214,7 +214,7 @@
{$i18n.t('Settings')}
- {#if $user?.role === 'admin' || $user?.permissions?.features?.automations} + {#if $config?.features?.enable_automations && ($user?.role === 'admin' || $user?.permissions?.features?.automations)}
{ - if ($user?.role !== 'admin' && !($user?.permissions?.features?.automations ?? false)) { + if (!$config?.features?.enable_automations || ($user?.role !== 'admin' && !($user?.permissions?.features?.automations ?? false))) { goto('/'); return; } diff --git a/src/routes/(app)/automations/[id]/+page.svelte b/src/routes/(app)/automations/[id]/+page.svelte index 51745fa01e..9d7cf6bbed 100644 --- a/src/routes/(app)/automations/[id]/+page.svelte +++ b/src/routes/(app)/automations/[id]/+page.svelte @@ -4,7 +4,7 @@ import { onMount, getContext } from 'svelte'; import { page } from '$app/stores'; - import { user, showSidebar } from '$lib/stores'; + import { user, showSidebar, config } from '$lib/stores'; import { getAutomationById } from '$lib/apis/automations'; import AutomationEditor from '$lib/components/automations/AutomationEditor.svelte'; @@ -18,7 +18,7 @@ $: automationId = $page.params.id; onMount(async () => { - if ($user?.role !== 'admin' && !($user?.permissions?.features?.automations ?? false)) { + if (!$config?.features?.enable_automations || ($user?.role !== 'admin' && !($user?.permissions?.features?.automations ?? false))) { goto('/'); return; } From 5afc258c5b13f456be528420513ade546c5e86f9 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 19 Apr 2026 22:37:10 +0900 Subject: [PATCH 09/41] refac --- backend/open_webui/config.py | 5 ++ backend/open_webui/routers/calendar.py | 45 ++++++++++-------- backend/open_webui/utils/tools.py | 1 + .../admin/Users/Groups/Permissions.svelte | 16 +++++++ static/favicon.png | Bin 10655 -> 21666 bytes static/static/favicon.png | Bin 10655 -> 21666 bytes 6 files changed, 48 insertions(+), 19 deletions(-) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 53de67387f..c43ead1d79 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1524,6 +1524,10 @@ USER_PERMISSIONS_FEATURES_AUTOMATIONS = ( os.environ.get('USER_PERMISSIONS_FEATURES_AUTOMATIONS', 'False').lower() == 'true' ) +USER_PERMISSIONS_FEATURES_CALENDAR = ( + os.environ.get('USER_PERMISSIONS_FEATURES_CALENDAR', 'True').lower() == 'true' +) + USER_PERMISSIONS_SETTINGS_INTERFACE = os.environ.get('USER_PERMISSIONS_SETTINGS_INTERFACE', 'True').lower() == 'true' @@ -1594,6 +1598,7 @@ DEFAULT_USER_PERMISSIONS = { 'code_interpreter': USER_PERMISSIONS_FEATURES_CODE_INTERPRETER, 'memories': USER_PERMISSIONS_FEATURES_MEMORIES, 'automations': USER_PERMISSIONS_FEATURES_AUTOMATIONS, + 'calendar': USER_PERMISSIONS_FEATURES_CALENDAR, }, 'settings': { 'interface': USER_PERMISSIONS_SETTINGS_INTERFACE, diff --git a/backend/open_webui/routers/calendar.py b/backend/open_webui/routers/calendar.py index f92f5b8943..5fb7cb6f9d 100644 --- a/backend/open_webui/routers/calendar.py +++ b/backend/open_webui/routers/calendar.py @@ -2,8 +2,7 @@ import logging import time from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Request -from pydantic import BaseModel +from fastapi import APIRouter, Depends, HTTPException, Request, status from open_webui.models.calendar import ( Calendars, @@ -23,6 +22,7 @@ from open_webui.models.access_grants import AccessGrants from open_webui.models.groups import Groups from open_webui.models.users import UserModel from open_webui.utils.auth import get_verified_user +from open_webui.utils.access_control import has_permission from open_webui.utils.calendar import expand_recurring_event from open_webui.constants import ERROR_MESSAGES @@ -31,12 +31,19 @@ log = logging.getLogger(__name__) router = APIRouter() -async def check_calendar_enabled(request: Request): - """Dependency to ensure calendar feature is globally enabled.""" +async def check_calendar_permission(request: Request, user): + """Check global feature flag AND per-user permission for calendar access.""" if not request.app.state.config.ENABLE_CALENDAR: raise HTTPException( - status_code=403, - detail=ERROR_MESSAGES.FEATURE_DISABLED('Calendar'), + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + if user.role != 'admin' and not await has_permission( + user.id, 'features.calendar', request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.UNAUTHORIZED, ) @@ -70,14 +77,14 @@ async def _check_calendar_access( @router.get('/', response_model=list[CalendarModel]) async def get_calendars(request: Request, user: UserModel = Depends(get_verified_user)): """List user's calendars (owned + shared). Auto-creates defaults on first call.""" - await check_calendar_enabled(request) + await check_calendar_permission(request, user) return await Calendars.get_calendars_by_user(user.id) @router.post('/create', response_model=CalendarModel) async def create_calendar(request: Request, form_data: CalendarForm, user: UserModel = Depends(get_verified_user)): """Create a new user calendar.""" - await check_calendar_enabled(request) + await check_calendar_permission(request, user) return await Calendars.insert_new_calendar(user.id, form_data) @@ -105,7 +112,7 @@ async def get_events( - Stored events from the database - Virtual events computed from active automation RRULEs (Scheduled Tasks calendar) """ - await check_calendar_enabled(request) + await check_calendar_permission(request, user) from datetime import datetime try: @@ -218,7 +225,7 @@ async def get_events( @router.post('/events/create', response_model=CalendarEventModel) async def create_event(request: Request, form_data: CalendarEventForm, user: UserModel = Depends(get_verified_user)): - await check_calendar_enabled(request) + await check_calendar_permission(request, user) await _check_calendar_access(form_data.calendar_id, user, 'write') return await CalendarEvents.insert_new_event(user.id, form_data) @@ -231,7 +238,7 @@ async def search_events( limit: int = 30, user: UserModel = Depends(get_verified_user), ): - await check_calendar_enabled(request) + await check_calendar_permission(request, user) return await CalendarEvents.search_events( user_id=user.id, query=query, skip=skip, limit=limit ) @@ -239,7 +246,7 @@ async def search_events( @router.get('/events/{event_id}', response_model=CalendarEventModel) async def get_event(request: Request, event_id: str, user: UserModel = Depends(get_verified_user)): - await check_calendar_enabled(request) + await check_calendar_permission(request, user) event = await CalendarEvents.get_event_by_id(event_id) if not event: raise HTTPException(status_code=404, detail='Event not found') @@ -253,7 +260,7 @@ async def get_event(request: Request, event_id: str, user: UserModel = Depends(g async def update_event( request: Request, event_id: str, form_data: CalendarEventUpdateForm, user: UserModel = Depends(get_verified_user) ): - await check_calendar_enabled(request) + await check_calendar_permission(request, user) event = await CalendarEvents.get_event_by_id(event_id) if not event: raise HTTPException(status_code=404, detail='Event not found') @@ -268,7 +275,7 @@ async def update_event( @router.delete('/events/{event_id}/delete') async def delete_event(request: Request, event_id: str, user: UserModel = Depends(get_verified_user)): - await check_calendar_enabled(request) + await check_calendar_permission(request, user) event = await CalendarEvents.get_event_by_id(event_id) if not event: raise HTTPException(status_code=404, detail='Event not found') @@ -286,7 +293,7 @@ async def rsvp_event( request: Request, event_id: str, form_data: RSVPForm, user: UserModel = Depends(get_verified_user) ): """Update own RSVP status for an event.""" - await check_calendar_enabled(request) + await check_calendar_permission(request, user) if form_data.status not in ('accepted', 'declined', 'tentative', 'pending'): raise HTTPException(status_code=400, detail='Invalid status') @@ -303,7 +310,7 @@ async def rsvp_event( @router.get('/{calendar_id}', response_model=CalendarModel) async def get_calendar_by_id(request: Request, calendar_id: str, user: UserModel = Depends(get_verified_user)): - await check_calendar_enabled(request) + await check_calendar_permission(request, user) cal = await _check_calendar_access(calendar_id, user, 'read') return cal @@ -312,7 +319,7 @@ async def get_calendar_by_id(request: Request, calendar_id: str, user: UserModel async def update_calendar( request: Request, calendar_id: str, form_data: CalendarUpdateForm, user: UserModel = Depends(get_verified_user) ): - await check_calendar_enabled(request) + await check_calendar_permission(request, user) cal = await _check_calendar_access(calendar_id, user, 'write') # Only owner/admin can change access grants @@ -327,7 +334,7 @@ async def update_calendar( @router.delete('/{calendar_id}/delete') async def delete_calendar(request: Request, calendar_id: str, user: UserModel = Depends(get_verified_user)): - await check_calendar_enabled(request) + await check_calendar_permission(request, user) cal = await _check_calendar_access(calendar_id, user, 'write') # Only owner/admin can delete @@ -343,7 +350,7 @@ async def delete_calendar(request: Request, calendar_id: str, user: UserModel = @router.post('/{calendar_id}/default') async def set_default_calendar(request: Request, calendar_id: str, user: UserModel = Depends(get_verified_user)): - await check_calendar_enabled(request) + await check_calendar_permission(request, user) cal = await Calendars.set_default_calendar(user.id, calendar_id) if not cal: raise HTTPException(status_code=404, detail='Calendar not found') diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 1c47fc75a6..471ec8540d 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -564,6 +564,7 @@ async def get_builtin_tools( if ( is_builtin_tool_enabled('calendar') and getattr(request.app.state.config, 'ENABLE_CALENDAR', False) + and await has_user_permission('calendar') ): builtin_functions.extend( [search_calendar_events, create_calendar_event, update_calendar_event, delete_calendar_event] diff --git a/src/lib/components/admin/Users/Groups/Permissions.svelte b/src/lib/components/admin/Users/Groups/Permissions.svelte index 7bd8fd00e0..cbfcb67b0a 100644 --- a/src/lib/components/admin/Users/Groups/Permissions.svelte +++ b/src/lib/components/admin/Users/Groups/Permissions.svelte @@ -916,6 +916,22 @@
{/if}
+ +
+
+
+ {$i18n.t('Calendar')} +
+ +
+ {#if defaultPermissions?.features?.calendar && !permissions.features.calendar} +
+
+ {$i18n.t('This is a default user permission and will remain enabled.')} +
+
+ {/if} +

diff --git a/static/favicon.png b/static/favicon.png index 63735ad4616fa452325af0fe351139dca01ca0ab..10c84f440ced21353ee824440758cbd080c7bf55 100644 GIT binary patch literal 21666 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 literal 10655 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 literal 10655 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 Date: Sun, 19 Apr 2026 22:40:59 +0900 Subject: [PATCH 10/41] refac --- static/static/favicon.ico | Bin 15086 -> 4286 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/static/static/favicon.ico b/static/static/favicon.ico index 14c5f9c6d437ed109a8579031cf181ee52bdf30e..b819d42f96d1b745d1d815521c0997d588f451ef 100644 GIT binary patch literal 4286 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 bfagL*MEiZNd%>mb_i9fBsuCLy9d!Q>dBe1} From 37eba1c5a66b3145c122a6b40e5c29707526d121 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 19 Apr 2026 22:45:54 +0900 Subject: [PATCH 11/41] chore: format --- backend/open_webui/config.py | 4 +- .../56359461a091_add_calendar_tables.py | 82 ++++----- backend/open_webui/models/automations.py | 13 +- backend/open_webui/models/calendar.py | 98 ++++------- backend/open_webui/routers/calendar.py | 67 ++++--- backend/open_webui/routers/knowledge.py | 4 +- backend/open_webui/static/favicon.ico | Bin 15086 -> 4286 bytes backend/open_webui/static/favicon.png | Bin 10655 -> 21666 bytes backend/open_webui/tools/builtin.py | 13 +- backend/open_webui/utils/middleware.py | 30 +++- src/lib/apis/calendar/index.ts | 1 - .../calendar/CalendarEventChip.svelte | 6 +- .../calendar/CalendarEventModal.svelte | 6 +- .../components/calendar/CalendarView.svelte | 165 +++++++++++++++--- src/lib/components/chat/Chat.svelte | 2 +- .../chat/Messages/ResponseMessage.svelte | 2 +- src/lib/i18n/locales/ar-BH/translation.json | 16 ++ src/lib/i18n/locales/ar/translation.json | 16 ++ src/lib/i18n/locales/az-AZ/translation.json | 16 ++ src/lib/i18n/locales/bg-BG/translation.json | 16 ++ src/lib/i18n/locales/bn-BD/translation.json | 16 ++ src/lib/i18n/locales/bo-TB/translation.json | 16 ++ src/lib/i18n/locales/bs-BA/translation.json | 16 ++ src/lib/i18n/locales/ca-ES/translation.json | 16 ++ src/lib/i18n/locales/ceb-PH/translation.json | 16 ++ src/lib/i18n/locales/cs-CZ/translation.json | 16 ++ src/lib/i18n/locales/da-DK/translation.json | 16 ++ src/lib/i18n/locales/de-DE/translation.json | 16 ++ src/lib/i18n/locales/dg-DG/translation.json | 16 ++ src/lib/i18n/locales/el-GR/translation.json | 16 ++ src/lib/i18n/locales/en-GB/translation.json | 16 ++ src/lib/i18n/locales/en-US/translation.json | 16 ++ src/lib/i18n/locales/es-ES/translation.json | 16 ++ src/lib/i18n/locales/et-EE/translation.json | 16 ++ src/lib/i18n/locales/eu-ES/translation.json | 16 ++ src/lib/i18n/locales/fa-IR/translation.json | 16 ++ src/lib/i18n/locales/fi-FI/translation.json | 16 ++ src/lib/i18n/locales/fr-CA/translation.json | 16 ++ src/lib/i18n/locales/fr-FR/translation.json | 16 ++ src/lib/i18n/locales/gl-ES/translation.json | 16 ++ src/lib/i18n/locales/he-IL/translation.json | 16 ++ src/lib/i18n/locales/hi-IN/translation.json | 16 ++ src/lib/i18n/locales/hr-HR/translation.json | 16 ++ src/lib/i18n/locales/hu-HU/translation.json | 16 ++ src/lib/i18n/locales/id-ID/translation.json | 16 ++ src/lib/i18n/locales/ie-GA/translation.json | 16 ++ src/lib/i18n/locales/it-IT/translation.json | 16 ++ src/lib/i18n/locales/ja-JP/translation.json | 16 ++ src/lib/i18n/locales/ka-GE/translation.json | 16 ++ src/lib/i18n/locales/kab-DZ/translation.json | 16 ++ src/lib/i18n/locales/ko-KR/translation.json | 16 ++ src/lib/i18n/locales/lt-LT/translation.json | 16 ++ src/lib/i18n/locales/lv-LV/translation.json | 16 ++ src/lib/i18n/locales/ms-MY/translation.json | 16 ++ src/lib/i18n/locales/nb-NO/translation.json | 16 ++ src/lib/i18n/locales/nl-NL/translation.json | 16 ++ src/lib/i18n/locales/pa-IN/translation.json | 16 ++ src/lib/i18n/locales/pl-PL/translation.json | 16 ++ src/lib/i18n/locales/pt-BR/translation.json | 16 ++ src/lib/i18n/locales/pt-PT/translation.json | 16 ++ src/lib/i18n/locales/ro-RO/translation.json | 16 ++ src/lib/i18n/locales/ru-RU/translation.json | 16 ++ src/lib/i18n/locales/sk-SK/translation.json | 16 ++ src/lib/i18n/locales/sr-RS/translation.json | 16 ++ src/lib/i18n/locales/sv-SE/translation.json | 16 ++ src/lib/i18n/locales/ta-IN/translation.json | 16 ++ src/lib/i18n/locales/th-TH/translation.json | 16 ++ src/lib/i18n/locales/tk-TM/translation.json | 16 ++ src/lib/i18n/locales/tr-TR/translation.json | 16 ++ src/lib/i18n/locales/ug-CN/translation.json | 16 ++ src/lib/i18n/locales/uk-UA/translation.json | 16 ++ src/lib/i18n/locales/ur-PK/translation.json | 16 ++ .../i18n/locales/uz-Cyrl-UZ/translation.json | 16 ++ .../i18n/locales/uz-Latn-Uz/translation.json | 16 ++ src/lib/i18n/locales/vi-VN/translation.json | 16 ++ src/lib/i18n/locales/zh-CN/translation.json | 16 ++ src/lib/i18n/locales/zh-TW/translation.json | 16 ++ src/routes/(app)/automations/+page.svelte | 5 +- .../(app)/automations/[id]/+page.svelte | 5 +- 79 files changed, 1272 insertions(+), 207 deletions(-) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index c43ead1d79..d2c88cb2fb 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1524,9 +1524,7 @@ USER_PERMISSIONS_FEATURES_AUTOMATIONS = ( os.environ.get('USER_PERMISSIONS_FEATURES_AUTOMATIONS', 'False').lower() == 'true' ) -USER_PERMISSIONS_FEATURES_CALENDAR = ( - os.environ.get('USER_PERMISSIONS_FEATURES_CALENDAR', 'True').lower() == 'true' -) +USER_PERMISSIONS_FEATURES_CALENDAR = os.environ.get('USER_PERMISSIONS_FEATURES_CALENDAR', 'True').lower() == 'true' USER_PERMISSIONS_SETTINGS_INTERFACE = os.environ.get('USER_PERMISSIONS_SETTINGS_INTERFACE', 'True').lower() == 'true' diff --git a/backend/open_webui/migrations/versions/56359461a091_add_calendar_tables.py b/backend/open_webui/migrations/versions/56359461a091_add_calendar_tables.py index a0812578c8..e556440f56 100644 --- a/backend/open_webui/migrations/versions/56359461a091_add_calendar_tables.py +++ b/backend/open_webui/migrations/versions/56359461a091_add_calendar_tables.py @@ -5,6 +5,7 @@ Revises: c1d2e3f4a5b6 Create Date: 2026-04-19 16:20:58.162045 """ + from typing import Sequence, Union from alembic import op @@ -19,52 +20,55 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - op.create_table('calendar', - sa.Column('id', sa.Text(), nullable=False), - sa.Column('user_id', sa.Text(), nullable=False), - sa.Column('name', sa.Text(), nullable=False), - sa.Column('color', sa.Text(), nullable=True), - sa.Column('is_default', sa.Boolean(), nullable=False), - sa.Column('data', sa.JSON(), nullable=True), - sa.Column('meta', sa.JSON(), nullable=True), - sa.Column('created_at', sa.BigInteger(), nullable=False), - sa.Column('updated_at', sa.BigInteger(), nullable=False), - sa.PrimaryKeyConstraint('id') + op.create_table( + 'calendar', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('name', sa.Text(), nullable=False), + sa.Column('color', sa.Text(), nullable=True), + sa.Column('is_default', sa.Boolean(), nullable=False), + sa.Column('data', sa.JSON(), nullable=True), + sa.Column('meta', sa.JSON(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=False), + sa.Column('updated_at', sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint('id'), ) op.create_index('ix_calendar_user', 'calendar', ['user_id'], unique=False) - op.create_table('calendar_event', - sa.Column('id', sa.Text(), nullable=False), - sa.Column('calendar_id', sa.Text(), nullable=False), - sa.Column('user_id', sa.Text(), nullable=False), - sa.Column('title', sa.Text(), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('start_at', sa.BigInteger(), nullable=False), - sa.Column('end_at', sa.BigInteger(), nullable=True), - sa.Column('all_day', sa.Boolean(), nullable=False), - sa.Column('rrule', sa.Text(), nullable=True), - sa.Column('color', sa.Text(), nullable=True), - sa.Column('location', sa.Text(), nullable=True), - sa.Column('data', sa.JSON(), nullable=True), - sa.Column('meta', sa.JSON(), nullable=True), - sa.Column('is_cancelled', sa.Boolean(), nullable=False), - sa.Column('created_at', sa.BigInteger(), nullable=False), - sa.Column('updated_at', sa.BigInteger(), nullable=False), - sa.PrimaryKeyConstraint('id') + op.create_table( + 'calendar_event', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('calendar_id', sa.Text(), nullable=False), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('title', sa.Text(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('start_at', sa.BigInteger(), nullable=False), + sa.Column('end_at', sa.BigInteger(), nullable=True), + sa.Column('all_day', sa.Boolean(), nullable=False), + sa.Column('rrule', sa.Text(), nullable=True), + sa.Column('color', sa.Text(), nullable=True), + sa.Column('location', sa.Text(), nullable=True), + sa.Column('data', sa.JSON(), nullable=True), + sa.Column('meta', sa.JSON(), nullable=True), + sa.Column('is_cancelled', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.BigInteger(), nullable=False), + sa.Column('updated_at', sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint('id'), ) op.create_index('ix_calendar_event_calendar', 'calendar_event', ['calendar_id', 'start_at'], unique=False) op.create_index('ix_calendar_event_user_date', 'calendar_event', ['user_id', 'start_at'], unique=False) - op.create_table('calendar_event_attendee', - sa.Column('id', sa.Text(), nullable=False), - sa.Column('event_id', sa.Text(), nullable=False), - sa.Column('user_id', sa.Text(), nullable=False), - sa.Column('status', sa.Text(), nullable=False), - sa.Column('meta', sa.JSON(), nullable=True), - sa.Column('created_at', sa.BigInteger(), nullable=False), - sa.Column('updated_at', sa.BigInteger(), nullable=False), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('event_id', 'user_id', name='uq_event_attendee') + op.create_table( + 'calendar_event_attendee', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('event_id', sa.Text(), nullable=False), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('status', sa.Text(), nullable=False), + sa.Column('meta', sa.JSON(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=False), + sa.Column('updated_at', sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('event_id', 'user_id', name='uq_event_attendee'), ) op.create_index('ix_calendar_event_attendee_user', 'calendar_event_attendee', ['user_id', 'status'], unique=False) diff --git a/backend/open_webui/models/automations.py b/backend/open_webui/models/automations.py index c7c78a7c8e..05f449ad13 100644 --- a/backend/open_webui/models/automations.py +++ b/backend/open_webui/models/automations.py @@ -153,15 +153,11 @@ class AutomationTable: row = await db.get(Automation, id) return AutomationModel.model_validate(row) if row else None - async def get_active_by_user( - self, user_id: str, db: Optional[AsyncSession] = None - ) -> list[AutomationModel]: + async def get_active_by_user(self, user_id: str, db: Optional[AsyncSession] = None) -> list[AutomationModel]: """Get active automations for a user (for calendar RRULE expansion).""" async with get_async_db_context(db) as db: result = await db.execute( - select(Automation) - .filter_by(user_id=user_id, is_active=True) - .order_by(Automation.created_at.desc()) + select(Automation).filter_by(user_id=user_id, is_active=True).order_by(Automation.created_at.desc()) ) return [AutomationModel.model_validate(r) for r in result.scalars().all()] @@ -291,9 +287,8 @@ class AutomationTable: timezone_by_user_id: dict[str, Optional[str]] = {} if user_ids: from open_webui.models.users import User - tz_result = await db.execute( - select(User.id, User.timezone).where(User.id.in_(user_ids)) - ) + + tz_result = await db.execute(select(User.id, User.timezone).where(User.id.in_(user_ids))) timezone_by_user_id = {uid: tz for uid, tz in tz_result.all()} for row in rows: diff --git a/backend/open_webui/models/calendar.py b/backend/open_webui/models/calendar.py index 859632c494..9d2d71a45b 100644 --- a/backend/open_webui/models/calendar.py +++ b/backend/open_webui/models/calendar.py @@ -232,9 +232,7 @@ class CalendarEventListResponse(BaseModel): class CalendarTable: - async def _get_access_grants( - self, calendar_id: str, db: Optional[AsyncSession] = None - ) -> list[AccessGrantModel]: + async def _get_access_grants(self, calendar_id: str, db: Optional[AsyncSession] = None) -> list[AccessGrantModel]: return await AccessGrants.get_grants_by_resource('calendar', calendar_id, db=db) async def _to_calendar_model( @@ -245,15 +243,11 @@ class CalendarTable: ) -> CalendarModel: cal_data = CalendarModel.model_validate(cal).model_dump(exclude={'access_grants'}) cal_data['access_grants'] = ( - access_grants - if access_grants is not None - else await self._get_access_grants(cal_data['id'], db=db) + access_grants if access_grants is not None else await self._get_access_grants(cal_data['id'], db=db) ) return CalendarModel.model_validate(cal_data) - async def get_or_create_defaults( - self, user_id: str, db: Optional[AsyncSession] = None - ) -> list[CalendarModel]: + async def get_or_create_defaults(self, user_id: str, db: Optional[AsyncSession] = None) -> list[CalendarModel]: """Return user's calendars, creating 'Personal' and 'Scheduled Tasks' if none exist.""" async with get_async_db_context(db) as db: result = await db.execute( @@ -289,9 +283,7 @@ class CalendarTable: await db.commit() return [CalendarModel.model_validate(c) for c in defaults] - async def get_calendars_by_user( - self, user_id: str, db: Optional[AsyncSession] = None - ) -> list[CalendarModel]: + async def get_calendars_by_user(self, user_id: str, db: Optional[AsyncSession] = None) -> list[CalendarModel]: """Owned + shared calendars.""" async with get_async_db_context(db) as db: user_groups = await Groups.get_groups_by_member_id(user_id, db=db) @@ -317,14 +309,9 @@ class CalendarTable: cal_ids = [c.id for c in calendars] grants_map = await AccessGrants.get_grants_by_resources('calendar', cal_ids, db=db) - return [ - await self._to_calendar_model(c, access_grants=grants_map.get(c.id, []), db=db) - for c in calendars - ] + return [await self._to_calendar_model(c, access_grants=grants_map.get(c.id, []), db=db) for c in calendars] - async def get_calendar_by_id( - self, id: str, db: Optional[AsyncSession] = None - ) -> Optional[CalendarModel]: + async def get_calendar_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[CalendarModel]: async with get_async_db_context(db) as db: result = await db.execute(select(Calendar).filter(Calendar.id == id)) cal = result.scalars().first() @@ -414,9 +401,7 @@ class CalendarTable: .values(is_default=False) ) # Set the new default - result = await db.execute( - select(Calendar).filter(Calendar.id == calendar_id, Calendar.user_id == user_id) - ) + result = await db.execute(select(Calendar).filter(Calendar.id == calendar_id, Calendar.user_id == user_id)) cal = result.scalars().first() if not cal: return None @@ -435,15 +420,11 @@ class CalendarTable: return False # Delete attendees for all events in this calendar - event_ids_result = await db.execute( - select(CalendarEvent.id).filter(CalendarEvent.calendar_id == id) - ) + event_ids_result = await db.execute(select(CalendarEvent.id).filter(CalendarEvent.calendar_id == id)) event_ids = [r[0] for r in event_ids_result.all()] if event_ids: await db.execute( - delete(CalendarEventAttendee).filter( - CalendarEventAttendee.event_id.in_(event_ids) - ) + delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id.in_(event_ids)) ) # Delete events @@ -465,9 +446,7 @@ class CalendarEventTable: self, event_id: str, db: Optional[AsyncSession] = None ) -> list[CalendarEventAttendeeModel]: async with get_async_db_context(db) as db: - result = await db.execute( - select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id) - ) + result = await db.execute(select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id)) rows = result.scalars().all() return [CalendarEventAttendeeModel.model_validate(r) for r in rows] @@ -515,9 +494,7 @@ class CalendarEventTable: return await self._to_event_model(event, db=db) - async def get_event_by_id( - self, id: str, db: Optional[AsyncSession] = None - ) -> Optional[CalendarEventModel]: + async def get_event_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[CalendarEventModel]: async with get_async_db_context(db) as db: result = await db.execute(select(CalendarEvent).filter(CalendarEvent.id == id)) event = result.scalars().first() @@ -559,9 +536,7 @@ class CalendarEventTable: # Also get event IDs where user is an attendee attendee_event_ids_result = await db.execute( - select(CalendarEventAttendee.event_id).filter( - CalendarEventAttendee.user_id == user_id - ) + select(CalendarEventAttendee.event_id).filter(CalendarEventAttendee.user_id == user_id) ) attendee_event_ids = [r[0] for r in attendee_event_ids_result.all()] @@ -608,16 +583,12 @@ class CalendarEventTable: # Batch-load attendees for all events in one query (avoid N+1) event_ids = [event.id for event, _user in items] att_result = await db.execute( - select(CalendarEventAttendee).filter( - CalendarEventAttendee.event_id.in_(event_ids) - ) + select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id.in_(event_ids)) ) att_rows = att_result.scalars().all() att_map: dict[str, list[CalendarEventAttendeeModel]] = {} for a in att_rows: - att_map.setdefault(a.event_id, []).append( - CalendarEventAttendeeModel.model_validate(a) - ) + att_map.setdefault(a.event_id, []).append(CalendarEventAttendeeModel.model_validate(a)) events = [] for event, user in items: @@ -697,16 +668,12 @@ class CalendarEventTable: # Batch-load attendees event_ids = [event.id for event, _user in items] att_result = await db.execute( - select(CalendarEventAttendee).filter( - CalendarEventAttendee.event_id.in_(event_ids) - ) + select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id.in_(event_ids)) ) att_rows = att_result.scalars().all() att_map: dict[str, list[CalendarEventAttendeeModel]] = {} for a in att_rows: - att_map.setdefault(a.event_id, []).append( - CalendarEventAttendeeModel.model_validate(a) - ) + att_map.setdefault(a.event_id, []).append(CalendarEventAttendeeModel.model_validate(a)) events = [] for event, user in items: @@ -732,8 +699,16 @@ class CalendarEventTable: update_data = form_data.model_dump(exclude_unset=True) for field in [ - 'calendar_id', 'title', 'description', 'start_at', 'end_at', - 'all_day', 'rrule', 'color', 'location', 'is_cancelled', + 'calendar_id', + 'title', + 'description', + 'start_at', + 'end_at', + 'all_day', + 'rrule', + 'color', + 'location', + 'is_cancelled', ]: if field in update_data: setattr(event, field, update_data[field]) @@ -750,13 +725,10 @@ class CalendarEventTable: await db.commit() return await self._to_event_model(event, db=db) - async def delete_event_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: try: async with get_async_db_context(db) as db: - await db.execute( - delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == id) - ) + await db.execute(delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == id)) await db.execute(delete(CalendarEvent).filter(CalendarEvent.id == id)) await db.commit() return True @@ -774,9 +746,7 @@ class CalendarEventAttendeeTable: """ async with get_async_db_context(db) as db: # Remove existing - await db.execute( - delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id) - ) + await db.execute(delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id)) now = int(time.time_ns()) models = [] @@ -819,20 +789,14 @@ class CalendarEventAttendeeTable: self, event_id: str, db: Optional[AsyncSession] = None ) -> list[CalendarEventAttendeeModel]: async with get_async_db_context(db) as db: - result = await db.execute( - select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id) - ) + result = await db.execute(select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id)) return [CalendarEventAttendeeModel.model_validate(r) for r in result.scalars().all()] - async def get_events_by_attendee( - self, user_id: str, db: Optional[AsyncSession] = None - ) -> list[str]: + async def get_events_by_attendee(self, user_id: str, db: Optional[AsyncSession] = None) -> list[str]: """Return event IDs where user is an attendee.""" async with get_async_db_context(db) as db: result = await db.execute( - select(CalendarEventAttendee.event_id).filter( - CalendarEventAttendee.user_id == user_id - ) + select(CalendarEventAttendee.event_id).filter(CalendarEventAttendee.user_id == user_id) ) return [r[0] for r in result.all()] diff --git a/backend/open_webui/routers/calendar.py b/backend/open_webui/routers/calendar.py index 5fb7cb6f9d..47093ca788 100644 --- a/backend/open_webui/routers/calendar.py +++ b/backend/open_webui/routers/calendar.py @@ -47,9 +47,7 @@ async def check_calendar_permission(request: Request, user): ) -async def _check_calendar_access( - calendar_id: str, user: UserModel, permission: str = 'write' -) -> CalendarModel: +async def _check_calendar_access(calendar_id: str, user: UserModel, permission: str = 'write') -> CalendarModel: """Verify user has access to a calendar. Returns the calendar or raises 403/404.""" cal = await Calendars.get_calendar_by_id(calendar_id) if not cal: @@ -139,9 +137,7 @@ async def get_events( for event in events: event_dict = event.model_dump() if event_dict.get('rrule'): - instances = expand_recurring_event( - event_dict, start_ns, end_ns, tz=user.timezone - ) + instances = expand_recurring_event(event_dict, start_ns, end_ns, tz=user.timezone) for inst in instances: expanded.append(CalendarEventUserResponse(**{**inst, 'user': event.user})) else: @@ -189,34 +185,34 @@ async def get_events( expanded.append(CalendarEventUserResponse(**inst)) # Past runs: single range query joined with automation - runs_with_auto = await AutomationRuns.get_runs_by_user_range( - user.id, start_ns, end_ns - ) + runs_with_auto = await AutomationRuns.get_runs_by_user_range(user.id, start_ns, end_ns) for run, auto in runs_with_auto: - expanded.append(CalendarEventUserResponse( - id=f'run_{run.id}', - calendar_id=scheduled_cal.id, - user_id=user.id, - title=auto.name, - description=run.error if run.status == 'error' else '', - start_at=run.created_at, - end_at=None, - all_day=False, - color=None, - location=None, - data=None, - meta={ - 'automation_id': auto.id, - 'run_id': run.id, - 'chat_id': run.chat_id, - 'status': run.status, - }, - is_cancelled=False, - attendees=[], - created_at=run.created_at, - updated_at=run.created_at, - user=None, - )) + expanded.append( + CalendarEventUserResponse( + id=f'run_{run.id}', + calendar_id=scheduled_cal.id, + user_id=user.id, + title=auto.name, + description=run.error if run.status == 'error' else '', + start_at=run.created_at, + end_at=None, + all_day=False, + color=None, + location=None, + data=None, + meta={ + 'automation_id': auto.id, + 'run_id': run.id, + 'chat_id': run.chat_id, + 'status': run.status, + }, + is_cancelled=False, + attendees=[], + created_at=run.created_at, + updated_at=run.created_at, + user=None, + ) + ) except Exception as e: log.warning(f'Failed to compute automation events: {e}', exc_info=True) @@ -239,9 +235,7 @@ async def search_events( user: UserModel = Depends(get_verified_user), ): await check_calendar_permission(request, user) - return await CalendarEvents.search_events( - user_id=user.id, query=query, skip=skip, limit=limit - ) + return await CalendarEvents.search_events(user_id=user.id, query=query, skip=skip, limit=limit) @router.get('/events/{event_id}', response_model=CalendarEventModel) @@ -341,7 +335,6 @@ async def delete_calendar(request: Request, calendar_id: str, user: UserModel = if cal.user_id != user.id and user.role != 'admin': raise HTTPException(status_code=403, detail='Only owner can delete calendar') - result = await Calendars.delete_calendar_by_id(calendar_id) if not result: raise HTTPException(status_code=500, detail='Failed to delete') diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index d8d92b2428..f503169fc0 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -539,9 +539,7 @@ async def update_knowledge_access_by_id( 'sharing.public_knowledge', ) - knowledge.access_grants = await AccessGrants.set_access_grants( - 'knowledge', id, form_data.access_grants, db=db - ) + knowledge.access_grants = await AccessGrants.set_access_grants('knowledge', id, form_data.access_grants, db=db) return KnowledgeFilesResponse( **knowledge.model_dump(), diff --git a/backend/open_webui/static/favicon.ico b/backend/open_webui/static/favicon.ico index 14c5f9c6d437ed109a8579031cf181ee52bdf30e..b819d42f96d1b745d1d815521c0997d588f451ef 100644 GIT binary patch literal 4286 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 bfagL*MEiZNd%>mb_i9fBsuCLy9d!Q>dBe1} diff --git a/backend/open_webui/static/favicon.png b/backend/open_webui/static/favicon.png index 63735ad4616fa452325af0fe351139dca01ca0ab..10c84f440ced21353ee824440758cbd080c7bf55 100644 GIT binary patch literal 21666 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 literal 10655 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 dict: } - - - async def search_calendar_events( query: Optional[str] = None, start: Optional[str] = None, @@ -2915,7 +2912,11 @@ async def search_calendar_events( return json.dumps({'error': f'Invalid start datetime: {e}'}) try: - end_ns = _dt_to_ns(end, tz) if end else int(time.time() * 1_000) * 1_000_000 + 365 * 86400 * 1_000_000_000_000 + end_ns = ( + _dt_to_ns(end, tz) + if end + else int(time.time() * 1_000) * 1_000_000 + 365 * 86400 * 1_000_000_000_000 + ) except (ValueError, TypeError) as e: return json.dumps({'error': f'Invalid end datetime: {e}'}) @@ -2929,7 +2930,8 @@ async def search_calendar_events( if query: q = query.lower() items = [ - e for e in items + e + for e in items if q in (e.title or '').lower() or q in (e.description or '').lower() or q in (e.location or '').lower() @@ -3229,4 +3231,3 @@ async def delete_calendar_event( except Exception as e: log.exception(f'delete_calendar_event error: {e}') return json.dumps({'error': str(e)}) - diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index d44112b4ec..036dc9bf39 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -427,7 +427,11 @@ def _render_openai_tool_call_handler(item: dict, done: bool) -> str: if atype == 'search': queries = action.get('queries') or [] query = action.get('query', '') - summary = f'Search: {", ".join(str(q) for q in queries)}' if queries else (f'Search: {query}' if query else '') + summary = ( + f'Search: {", ".join(str(q) for q in queries)}' + if queries + else (f'Search: {query}' if query else '') + ) elif atype == 'open_page': summary = f'Open page: {action.get("url", "")}' if action.get('url') else '' elif atype == 'find_in_page': @@ -490,9 +494,13 @@ def serialize_output(output: list) -> str: files = result_item.get('files') embeds = result_item.get('embeds', '') - parts.append(f'
\nTool Executed\n{html.escape(json.dumps(result_text, ensure_ascii=False))}\n
') + parts.append( + f'
\nTool Executed\n{html.escape(json.dumps(result_text, ensure_ascii=False))}\n
' + ) else: - parts.append(f'
\nExecuting...\n
') + parts.append( + f'
\nExecuting...\n
' + ) elif item_type == 'function_call_output': # Already handled inline with function_call above @@ -529,9 +537,13 @@ def serialize_output(output: list) -> str: ) if status == 'completed' or duration is not None or not is_last_item: - parts.append(f'
\nThought for {duration or 0} seconds\n{display}\n
') + parts.append( + f'
\nThought for {duration or 0} seconds\n{display}\n
' + ) else: - parts.append(f'
\nThinking…\n{display}\n
') + parts.append( + f'
\nThinking…\n{display}\n
' + ) elif item_type == 'open_webui:code_interpreter': # Code interpreter needs to inspect/mutate prior accumulated content @@ -570,9 +582,13 @@ def serialize_output(output: list) -> str: output_attr = f' output="{html.escape(output_json)}"' if status == 'completed' or duration is not None or not is_last_item: - parts.append(f'
\nAnalyzed\n{display}\n
') + parts.append( + f'
\nAnalyzed\n{display}\n
' + ) else: - parts.append(f'
\nAnalyzing…\n{display}\n
') + parts.append( + f'
\nAnalyzing…\n{display}\n
' + ) return '\n'.join(parts).strip() diff --git a/src/lib/apis/calendar/index.ts b/src/lib/apis/calendar/index.ts index fa75f9a6a7..b496a42bc2 100644 --- a/src/lib/apis/calendar/index.ts +++ b/src/lib/apis/calendar/index.ts @@ -418,7 +418,6 @@ export const rsvpCalendarEvent = async ( return res; }; - export const searchCalendarEvents = async ( token: string, query: string | null, diff --git a/src/lib/components/calendar/CalendarEventChip.svelte b/src/lib/components/calendar/CalendarEventChip.svelte index c63eac1063..b1c3552e6c 100644 --- a/src/lib/components/calendar/CalendarEventChip.svelte +++ b/src/lib/components/calendar/CalendarEventChip.svelte @@ -21,7 +21,11 @@ style="background-color: {event.color || calendarColor || '#3b82f6'};" > - {#if !event.all_day}{new Date(event.start_at / 1_000_000).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }).replace(' ', '')}{/if} + {#if !event.all_day}{new Date(event.start_at / 1_000_000) + .toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) + .replace(' ', '')}{/if} {event.title} diff --git a/src/lib/components/calendar/CalendarEventModal.svelte b/src/lib/components/calendar/CalendarEventModal.svelte index 5540bda2fe..020a89f923 100644 --- a/src/lib/components/calendar/CalendarEventModal.svelte +++ b/src/lib/components/calendar/CalendarEventModal.svelte @@ -8,7 +8,11 @@ import Spinner from '$lib/components/common/Spinner.svelte'; import type { CalendarModel, CalendarEventModel, CalendarEventForm } from '$lib/apis/calendar'; - import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from '$lib/apis/calendar'; + import { + createCalendarEvent, + updateCalendarEvent, + deleteCalendarEvent + } from '$lib/apis/calendar'; const i18n = getContext('i18n'); const dispatch = createEventDispatcher(); diff --git a/src/lib/components/calendar/CalendarView.svelte b/src/lib/components/calendar/CalendarView.svelte index 0b67d7de98..5100a58b94 100644 --- a/src/lib/components/calendar/CalendarView.svelte +++ b/src/lib/components/calendar/CalendarView.svelte @@ -21,11 +21,24 @@ const NS = 1_000_000; const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const MONTH_NAMES = [ - 'January', 'February', 'March', 'April', 'May', 'June', - 'July', 'August', 'September', 'October', 'November', 'December' + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December' ]; - $: calColorMap = calendars.reduce((acc, c) => ({ ...acc, [c.id]: c.color }), {} as Record); + $: calColorMap = calendars.reduce( + (acc, c) => ({ ...acc, [c.id]: c.color }), + {} as Record + ); $: filteredEvents = events.filter((e) => visibleCalendarIds.has(e.calendar_id)); // Pre-group events by day key so the template reactively updates when events change @@ -102,7 +115,11 @@ }); } - function getEventsForHour(day: Date, hour: number, eventsList: CalendarEventModel[] = filteredEvents): CalendarEventModel[] { + function getEventsForHour( + day: Date, + hour: number, + eventsList: CalendarEventModel[] = filteredEvents + ): CalendarEventModel[] { const hourStartMs = new Date(day.getFullYear(), day.getMonth(), day.getDate(), hour).getTime(); const hourEndMs = hourStartMs + 3_600_000; return eventsList.filter((e) => { @@ -157,9 +174,10 @@ dispatch('eventClick', event); } - $: headerText = view === 'day' - ? `${DAY_NAMES[currentDate.getDay()]}, ${MONTH_NAMES[currentDate.getMonth()]} ${currentDate.getDate()}, ${currentDate.getFullYear()}` - : `${MONTH_NAMES[currentDate.getMonth()]} ${currentDate.getFullYear()}`; + $: headerText = + view === 'day' + ? `${DAY_NAMES[currentDate.getDay()]}, ${MONTH_NAMES[currentDate.getMonth()]} ${currentDate.getDate()}, ${currentDate.getFullYear()}` + : `${MONTH_NAMES[currentDate.getMonth()]} ${currentDate.getFullYear()}`;
@@ -168,7 +186,10 @@
{#if $mobile}
- + -
@@ -232,7 +285,19 @@ class="md:hidden px-2 py-1.5 rounded-xl bg-black text-white dark:bg-white dark:text-black transition text-sm flex items-center" on:click={() => dispatch('newEvent')} > - +
@@ -244,13 +309,19 @@
{#each DAY_NAMES as day} -
{$i18n.t(day)}
+
+ {$i18n.t(day)} +
{/each}
-
+
{#each monthDays as day, i} - {@const dayKey = new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime().toString()} + {@const dayKey = new Date(day.getFullYear(), day.getMonth(), day.getDate()) + .getTime() + .toString()} {@const dayEvents = eventsByDay[dayKey] || []} {@const col = i % 7} {@const row = Math.floor(i / 7)} @@ -293,18 +364,34 @@
- + {:else if view === 'week'}
-
+
-
+
{#each weekDays as day} -
-
{DAY_NAMES[day.getDay()]}
-
+
+
+ {DAY_NAMES[day.getDay()]} +
+
{day.getDate()}
@@ -313,12 +400,22 @@
{#each hours as hour} -
-
{hour > 0 ? formatHour(hour) : ''}
+
+
+ {hour > 0 ? formatHour(hour) : ''} +
{#each weekDays as day} {@const hourEvents = getEventsForHour(day, hour, filteredEvents)}
- + {:else}
-
+
{#each hours as hour} {@const hourEvents = getEventsForHour(currentDate, hour, filteredEvents)} -
-
{hour > 0 ? formatHour(hour) : ''}
+
+
+ {hour > 0 ? formatHour(hour) : ''} +
+ + {/if}
-
{$i18n.t('Settings')}
- + {/if} + + {#if $user?.role === 'admin' || $user?.permissions?.workspace?.models || $user?.permissions?.workspace?.knowledge || $user?.permissions?.workspace?.prompts || $user?.permissions?.workspace?.tools} +
+ {/if} {#if $config?.features?.enable_automations && ($user?.role === 'admin' || $user?.permissions?.features?.automations)} - { - if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return; - e.preventDefault(); - show = false; - goto('/automations'); - if ($mobile) { - await tick(); - showSidebar.set(false); - } - }} - > - -
{$i18n.t('Automations')}
- + + + {/if} +
{/if} {#if $config?.features?.enable_calendar && ($user?.role === 'admin' || $user?.permissions?.features?.calendar)} - { - if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return; - e.preventDefault(); - show = false; - goto('/calendar'); - }} - > - -
{$i18n.t('Calendar')}
- + + + {/if} +
{/if} {#if role === 'admin'} - { - if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) { - return; - } - e.preventDefault(); - show = false; - goto('/playground'); - if ($mobile) { - await tick(); - showSidebar.set(false); - } - }} - > -
- -
-
{$i18n.t('Playground')}
-
+ {/if} +
+
+ + {#if role === 'admin'} Date: Sun, 19 Apr 2026 23:17:25 +0900 Subject: [PATCH 14/41] refac --- src/routes/(app)/calendar/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/(app)/calendar/+page.svelte b/src/routes/(app)/calendar/+page.svelte index 25671fc791..6e7296efb3 100644 --- a/src/routes/(app)/calendar/+page.svelte +++ b/src/routes/(app)/calendar/+page.svelte @@ -159,7 +159,7 @@ {#if loaded}
- -
{#if ($models ?? []).length > 0 && (($settings?.pinnedModels ?? []).length > 0 || $config?.default_pinned_models)} Date: Sun, 19 Apr 2026 23:46:32 +0900 Subject: [PATCH 18/41] refac --- .../components/layout/Sidebar/UserMenu.svelte | 130 +++++++++--------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/src/lib/components/layout/Sidebar/UserMenu.svelte b/src/lib/components/layout/Sidebar/UserMenu.svelte index 05668e25d5..ec86a11a9d 100644 --- a/src/lib/components/layout/Sidebar/UserMenu.svelte +++ b/src/lib/components/layout/Sidebar/UserMenu.svelte @@ -234,50 +234,6 @@
{/if} - {#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))} - - {/if} - {#if $user?.role === 'admin' || $user?.permissions?.workspace?.models || $user?.permissions?.workspace?.knowledge || $user?.permissions?.workspace?.prompts || $user?.permissions?.workspace?.tools}
{/if} - {#if $config?.features?.enable_automations && ($user?.role === 'admin' || $user?.permissions?.features?.automations)} + {#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))}
{ if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return; e.preventDefault(); show = false; - goto('/automations'); + goto('/notes'); if ($mobile) { await tick(); showSidebar.set(false); @@ -353,35 +309,22 @@ }} >
- - - +
-
{$i18n.t('Automations')}
+
{$i18n.t('Notes')}
{#if shiftKey}
{/if} + {#if $config?.features?.enable_automations && ($user?.role === 'admin' || $user?.permissions?.features?.automations)} + + {/if} + {#if role === 'admin'} - {#if pinnedItems.includes('notes') && ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))} - - {/if} - - {#if pinnedItems.includes('workspace') && ($user?.role === 'admin' || $user?.permissions?.workspace?.models || $user?.permissions?.workspace?.knowledge || $user?.permissions?.workspace?.prompts || $user?.permissions?.workspace?.tools)} - - {/if} - - {#if pinnedItems.includes('automations') && $config?.features?.enable_automations && ($user?.role === 'admin' || $user?.permissions?.features?.automations)} - - {/if} - - {#if pinnedItems.includes('calendar') && $config?.features?.enable_calendar && ($user?.role === 'admin' || $user?.permissions?.features?.calendar)} - - {/if} - - {#if pinnedItems.includes('playground') && $user?.role === 'admin'} - - {/if} + {#each pinnedItems as itemId (itemId)} + {@const meta = getMenuItemMeta(itemId)} + {#if meta && isMenuItemVisible(itemId)} + + {/if} + {/each}
From eb16ae92a5b8f93fe3fde9fba709bcfd85792f6d Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 19 Apr 2026 23:49:45 +0900 Subject: [PATCH 20/41] chore: format --- src/lib/components/layout/Sidebar.svelte | 47 ++++++++++++++++++++---- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index 10243df067..fcea714f02 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -856,7 +856,7 @@
- {#each pinnedItems as itemId (itemId)} + {#each pinnedItems as itemId (itemId)} {@const meta = getMenuItemMeta(itemId)} {#if meta && isMenuItemVisible(itemId)}
@@ -877,16 +877,49 @@ {#if itemId === 'notes'} {:else if itemId === 'workspace'} - - + + {:else if itemId === 'automations'} - - + + {:else if itemId === 'calendar'} - - + + {:else if itemId === 'playground'} From f6d1969067269ce3ff12a21ff090f73ddf88b793 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 19 Apr 2026 23:55:17 +0900 Subject: [PATCH 21/41] refac --- .../components/layout/Sidebar/UserMenu.svelte | 136 +++++++++--------- 1 file changed, 68 insertions(+), 68 deletions(-) diff --git a/src/lib/components/layout/Sidebar/UserMenu.svelte b/src/lib/components/layout/Sidebar/UserMenu.svelte index ec86a11a9d..d7b79752c6 100644 --- a/src/lib/components/layout/Sidebar/UserMenu.svelte +++ b/src/lib/components/layout/Sidebar/UserMenu.svelte @@ -234,6 +234,74 @@
{/if} + + + {#if role === 'admin'} + { + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) { + return; + } + e.preventDefault(); + show = false; + goto('/admin'); + if ($mobile) { + await tick(); + showSidebar.set(false); + } + }} + > +
+ +
+
{$i18n.t('Admin Panel')}
+
+ {/if} + + + +
+ {#if $user?.role === 'admin' || $user?.permissions?.workspace?.models || $user?.permissions?.workspace?.knowledge || $user?.permissions?.workspace?.prompts || $user?.permissions?.workspace?.tools}
{/if} -
- - - - - - {#if role === 'admin'} -
{ - if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) { - return; - } - e.preventDefault(); - show = false; - goto('/admin'); - if ($mobile) { - await tick(); - showSidebar.set(false); - } - }} - > -
- -
-
{$i18n.t('Admin Panel')}
-
- {/if} - {#if help}
From 1d501cfa3f96b3a9a5f4f7ce996947671fd09f29 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 20 Apr 2026 00:07:34 +0900 Subject: [PATCH 22/41] refac --- backend/open_webui/models/calendar.py | 60 ++++++--------------------- 1 file changed, 13 insertions(+), 47 deletions(-) diff --git a/backend/open_webui/models/calendar.py b/backend/open_webui/models/calendar.py index 9afa7c15e2..4841e7b2dd 100644 --- a/backend/open_webui/models/calendar.py +++ b/backend/open_webui/models/calendar.py @@ -248,7 +248,7 @@ class CalendarTable: return CalendarModel.model_validate(cal_data) async def get_or_create_defaults(self, user_id: str, db: Optional[AsyncSession] = None) -> list[CalendarModel]: - """Return user's calendars, creating 'Personal' and 'Scheduled Tasks' if none exist.""" + """Return user's calendars, creating 'Personal' default if none exist.""" async with get_async_db_context(db) as db: result = await db.execute( select(Calendar).filter(Calendar.user_id == user_id).order_by(Calendar.created_at.asc()) @@ -259,29 +259,18 @@ class CalendarTable: return [CalendarModel.model_validate(c) for c in calendars] now = int(time.time_ns()) - defaults = [ - Calendar( - id=str(uuid4()), - user_id=user_id, - name='Personal', - color='#3b82f6', - is_default=True, - created_at=now, - updated_at=now, - ), - Calendar( - id=str(uuid4()), - user_id=user_id, - name='Scheduled Tasks', - color='#8b5cf6', - created_at=now + 1, - updated_at=now + 1, - ), - ] - for cal in defaults: - db.add(cal) + cal = Calendar( + id=str(uuid4()), + user_id=user_id, + name='Personal', + color='#3b82f6', + is_default=True, + created_at=now, + updated_at=now, + ) + db.add(cal) await db.commit() - return [CalendarModel.model_validate(c) for c in defaults] + return [CalendarModel.model_validate(cal)] async def get_calendars_by_user(self, user_id: str, db: Optional[AsyncSession] = None) -> list[CalendarModel]: """Owned + shared calendars.""" @@ -317,30 +306,7 @@ class CalendarTable: cal = result.scalars().first() return await self._to_calendar_model(cal, db=db) if cal else None - async def get_scheduled_tasks_calendar( - self, user_id: str, db: Optional[AsyncSession] = None - ) -> Optional[CalendarModel]: - """Get the user's Scheduled Tasks calendar (for automation integration).""" - async with get_async_db_context(db) as db: - result = await db.execute( - select(Calendar).filter( - Calendar.user_id == user_id, - Calendar.name == 'Scheduled Tasks', - ) - ) - cal = result.scalars().first() - if not cal: - # Ensure defaults exist then retry - await self.get_or_create_defaults(user_id, db=db) - result = await db.execute( - select(Calendar).filter( - Calendar.user_id == user_id, - Calendar.name == 'Scheduled Tasks', - ) - ) - cal = result.scalars().first() - # Lightweight return — skip access_grants loading since we only need id/color - return CalendarModel.model_validate(cal) if cal else None + async def insert_new_calendar( self, user_id: str, form_data: CalendarForm, db: Optional[AsyncSession] = None From 24dd5b461eb44d306c823389e0f664c45db042e8 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 20 Apr 2026 00:09:24 +0900 Subject: [PATCH 23/41] refac --- backend/open_webui/routers/calendar.py | 51 +++++++++++++++---- .../calendar/CalendarEventModal.svelte | 2 +- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/backend/open_webui/routers/calendar.py b/backend/open_webui/routers/calendar.py index 47093ca788..cde2ea0484 100644 --- a/backend/open_webui/routers/calendar.py +++ b/backend/open_webui/routers/calendar.py @@ -30,6 +30,8 @@ log = logging.getLogger(__name__) router = APIRouter() +SCHEDULED_TASKS_CALENDAR_ID = '__scheduled_tasks__' + async def check_calendar_permission(request: Request, user): """Check global feature flag AND per-user permission for calendar access.""" @@ -47,6 +49,17 @@ async def check_calendar_permission(request: Request, user): ) +async def _user_has_automations(request: Request, user) -> bool: + """Check if automations feature is available to this user.""" + if not getattr(request.app.state.config, 'ENABLE_AUTOMATIONS', False): + return False + if user.role == 'admin': + return True + return await has_permission( + user.id, 'features.automations', request.app.state.config.USER_PERMISSIONS + ) + + async def _check_calendar_access(calendar_id: str, user: UserModel, permission: str = 'write') -> CalendarModel: """Verify user has access to a calendar. Returns the calendar or raises 403/404.""" cal = await Calendars.get_calendar_by_id(calendar_id) @@ -74,9 +87,26 @@ async def _check_calendar_access(calendar_id: str, user: UserModel, permission: @router.get('/', response_model=list[CalendarModel]) async def get_calendars(request: Request, user: UserModel = Depends(get_verified_user)): - """List user's calendars (owned + shared). Auto-creates defaults on first call.""" + """List user's calendars (owned + shared), plus a virtual Scheduled Tasks calendar + when automations are available.""" await check_calendar_permission(request, user) - return await Calendars.get_calendars_by_user(user.id) + calendars = await Calendars.get_calendars_by_user(user.id) + + if await _user_has_automations(request, user): + now = int(time.time_ns()) + calendars.append( + CalendarModel( + id=SCHEDULED_TASKS_CALENDAR_ID, + user_id=user.id, + name='Scheduled Tasks', + color='#8b5cf6', + is_default=False, + created_at=now, + updated_at=now, + ) + ) + + return calendars @router.post('/create', response_model=CalendarModel) @@ -144,11 +174,12 @@ async def get_events( expanded.append(event) # 2. Virtual automation events (Scheduled Tasks calendar) - try: - from open_webui.models.automations import Automations, AutomationRuns + if await _user_has_automations(request, user) and ( + cal_id_list is None or SCHEDULED_TASKS_CALENDAR_ID in cal_id_list + ): + try: + from open_webui.models.automations import Automations, AutomationRuns - scheduled_cal = await Calendars.get_scheduled_tasks_calendar(user.id) - if scheduled_cal and (cal_id_list is None or scheduled_cal.id in cal_id_list): # Future runs: expand RRULEs for active automations only active_automations = await Automations.get_active_by_user(user.id) for auto in active_automations: @@ -158,7 +189,7 @@ async def get_events( virtual = { 'id': f'auto_{auto.id}', - 'calendar_id': scheduled_cal.id, + 'calendar_id': SCHEDULED_TASKS_CALENDAR_ID, 'user_id': user.id, 'title': auto.name, 'description': auto.data.get('prompt', '') if auto.data else '', @@ -190,7 +221,7 @@ async def get_events( expanded.append( CalendarEventUserResponse( id=f'run_{run.id}', - calendar_id=scheduled_cal.id, + calendar_id=SCHEDULED_TASKS_CALENDAR_ID, user_id=user.id, title=auto.name, description=run.error if run.status == 'error' else '', @@ -213,8 +244,8 @@ async def get_events( user=None, ) ) - except Exception as e: - log.warning(f'Failed to compute automation events: {e}', exc_info=True) + except Exception as e: + log.warning(f'Failed to compute automation events: {e}', exc_info=True) return [e.model_dump() if hasattr(e, 'model_dump') else e for e in expanded] diff --git a/src/lib/components/calendar/CalendarEventModal.svelte b/src/lib/components/calendar/CalendarEventModal.svelte index ad7bf9b0df..bba3b3426b 100644 --- a/src/lib/components/calendar/CalendarEventModal.svelte +++ b/src/lib/components/calendar/CalendarEventModal.svelte @@ -184,7 +184,7 @@ class="w-full text-sm bg-transparent outline-hidden cursor-pointer" bind:value={calendarId} > - {#each calendars.filter((c) => c.name !== 'Scheduled Tasks') as cal (cal.id)} + {#each calendars.filter((c) => c.id !== '__scheduled_tasks__') as cal (cal.id)} {/each} From 4e31fa4427037c0ffd4ad704308203639bf05df8 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 20 Apr 2026 00:12:53 +0900 Subject: [PATCH 24/41] refac --- .../calendar/CalendarSidebar.svelte | 2 +- .../components/calendar/CalendarView.svelte | 164 ---------------- src/routes/(app)/calendar/+page.svelte | 180 ++++++++++++++++-- 3 files changed, 170 insertions(+), 176 deletions(-) diff --git a/src/lib/components/calendar/CalendarSidebar.svelte b/src/lib/components/calendar/CalendarSidebar.svelte index 8ed0df4727..dc98df05e0 100644 --- a/src/lib/components/calendar/CalendarSidebar.svelte +++ b/src/lib/components/calendar/CalendarSidebar.svelte @@ -71,7 +71,7 @@
-
+
{miniMonthNames[miniMonth]} {miniYear}
- -
- {/if} - -
-
- {headerText} - - -
- -
- - - - - -
-
-
- - {#if view === 'month'}
diff --git a/src/routes/(app)/calendar/+page.svelte b/src/routes/(app)/calendar/+page.svelte index 6e7296efb3..2267c1c799 100644 --- a/src/routes/(app)/calendar/+page.svelte +++ b/src/routes/(app)/calendar/+page.svelte @@ -14,6 +14,11 @@ import CalendarEventModal from '$lib/components/calendar/CalendarEventModal.svelte'; import Spinner from '$lib/components/common/Spinner.svelte'; import Plus from '$lib/components/icons/Plus.svelte'; + import Tooltip from '$lib/components/common/Tooltip.svelte'; + import SidebarIcon from '$lib/components/icons/Sidebar.svelte'; + import Select from '$lib/components/common/Select.svelte'; + import Check from '$lib/components/icons/Check.svelte'; + import ChevronDown from '$lib/components/icons/ChevronDown.svelte'; const i18n = getContext('i18n'); @@ -29,6 +34,22 @@ let editEvent: CalendarEventModel | null = null; let defaultStartAt: number | null = null; + const MONTH_NAMES = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December' + ]; + const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + function getVisibleRange(): { start: string; end: string } { const d = new Date(currentDate); let start: Date; @@ -128,8 +149,29 @@ showEventModal = true; } + function navigateCalendar(delta: number) { + const d = new Date(currentDate); + if (view === 'month') { + d.setDate(1); + d.setMonth(d.getMonth() + delta); + } else if (view === 'week') d.setDate(d.getDate() + delta * 7); + else d.setDate(d.getDate() + delta); + currentDate = d; + handleNavigate(); + } + + function goToToday() { + currentDate = new Date(); + handleNavigate(); + } + $: defaultCalendarId = calendars.find((c) => c.is_default)?.id || calendars[0]?.id || ''; + $: headerText = + view === 'day' + ? `${DAY_NAMES[currentDate.getDay()]}, ${MONTH_NAMES[currentDate.getMonth()]} ${currentDate.getDate()}, ${currentDate.getFullYear()}` + : `${MONTH_NAMES[currentDate.getMonth()]} ${currentDate.getFullYear()}`; + onMount(async () => { await loadCalendars(); await refresh(); @@ -157,17 +199,134 @@ : ''} max-w-full" > {#if loaded} + + +
-