feat: calendar
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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<string, any> | null;
|
||||
meta: Record<string, any> | 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<string, any> | 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<string, any> | null;
|
||||
meta: Record<string, any> | 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<string, any>;
|
||||
meta?: Record<string, any>;
|
||||
attendees?: { user_id: string; status?: string }[];
|
||||
};
|
||||
|
||||
export type CalendarForm = {
|
||||
name: string;
|
||||
color?: string;
|
||||
data?: Record<string, any>;
|
||||
meta?: Record<string, any>;
|
||||
access_grants?: { target_type: string; target_id: string; permission: string }[];
|
||||
};
|
||||
|
||||
// ── Calendars ─────────────────────────────────
|
||||
|
||||
export const getCalendars = async (token: string): Promise<CalendarModel[]> => {
|
||||
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<CalendarModel> => {
|
||||
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<CalendarForm>
|
||||
): Promise<CalendarModel> => {
|
||||
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<boolean> => {
|
||||
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<CalendarEventModel[]> => {
|
||||
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<CalendarEventModel> => {
|
||||
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<CalendarEventModel> => {
|
||||
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<CalendarEventForm>
|
||||
): Promise<CalendarEventModel> => {
|
||||
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<boolean> => {
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { CalendarEventModel } from '$lib/apis/calendar';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
|
||||
export let event: CalendarEventModel;
|
||||
export let calendarColor: string | null = null;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
<Tooltip content="{event.title}{event.location ? ` · ${event.location}` : ''}">
|
||||
<button
|
||||
class="w-full text-left text-xs flex items-start gap-1.5 py-[1px] px-0.5 rounded-md
|
||||
{event.meta?.automation_id ? 'opacity-60' : ''}
|
||||
hover:bg-gray-50 dark:hover:bg-gray-800/50 transition truncate"
|
||||
on:click|stopPropagation={() => dispatch('click', event)}
|
||||
>
|
||||
<span
|
||||
class="shrink-0 size-[7px] rounded-full mt-[5px]"
|
||||
style="background-color: {event.color || calendarColor || '#3b82f6'};"
|
||||
></span>
|
||||
<span class="truncate">
|
||||
{#if !event.all_day}<span class="text-gray-500 dark:text-gray-400">{new Date(event.start_at / 1_000_000).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }).replace(' ', '')}</span>{/if}
|
||||
{event.title}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
@@ -0,0 +1,260 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, getContext } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
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';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let show = false;
|
||||
export let event: CalendarEventModel | null = null;
|
||||
export let calendars: CalendarModel[] = [];
|
||||
export let defaultCalendarId: string = '';
|
||||
export let defaultStartAt: number | null = null;
|
||||
|
||||
let title = '';
|
||||
let description = '';
|
||||
let calendarId = '';
|
||||
let startDate = '';
|
||||
let startTime = '';
|
||||
let endDate = '';
|
||||
let endTime = '';
|
||||
let allDay = false;
|
||||
let location = '';
|
||||
let loading = false;
|
||||
|
||||
const NS = 1_000_000;
|
||||
|
||||
function nsToDateStr(ns: number): string {
|
||||
return new Date(ns / NS).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function nsToTimeStr(ns: number): string {
|
||||
return new Date(ns / NS).toTimeString().slice(0, 5);
|
||||
}
|
||||
|
||||
function dateTimeToNs(dateStr: string, timeStr: string): number {
|
||||
return new Date(`${dateStr}T${timeStr || '00:00'}`).getTime() * NS;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (event) {
|
||||
title = event.title;
|
||||
description = event.description || '';
|
||||
calendarId = event.calendar_id;
|
||||
startDate = nsToDateStr(event.start_at);
|
||||
startTime = nsToTimeStr(event.start_at);
|
||||
endDate = event.end_at ? nsToDateStr(event.end_at) : '';
|
||||
endTime = event.end_at ? nsToTimeStr(event.end_at) : '';
|
||||
allDay = event.all_day;
|
||||
location = event.location || '';
|
||||
} else {
|
||||
title = '';
|
||||
description = '';
|
||||
calendarId = defaultCalendarId || (calendars.length > 0 ? calendars[0].id : '');
|
||||
if (defaultStartAt) {
|
||||
startDate = nsToDateStr(defaultStartAt);
|
||||
startTime = nsToTimeStr(defaultStartAt);
|
||||
const endNs = defaultStartAt + 60 * 60 * 1000 * NS;
|
||||
endDate = nsToDateStr(endNs);
|
||||
endTime = nsToTimeStr(endNs);
|
||||
} else {
|
||||
const now = new Date();
|
||||
startDate = now.toISOString().slice(0, 10);
|
||||
startTime = now.toTimeString().slice(0, 5);
|
||||
const later = new Date(now.getTime() + 60 * 60 * 1000);
|
||||
endDate = later.toISOString().slice(0, 10);
|
||||
endTime = later.toTimeString().slice(0, 5);
|
||||
}
|
||||
allDay = false;
|
||||
location = '';
|
||||
}
|
||||
}
|
||||
|
||||
$: if (show) reset();
|
||||
|
||||
const submitHandler = async () => {
|
||||
if (!title.trim()) {
|
||||
toast.error($i18n.t('Title is required'));
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const startNs = dateTimeToNs(startDate, allDay ? '00:00' : startTime);
|
||||
const endNs = endDate ? dateTimeToNs(endDate, allDay ? '23:59' : endTime) : undefined;
|
||||
|
||||
if (event && !event.meta?.automation_id) {
|
||||
const result = await updateCalendarEvent(localStorage.token, event.id, {
|
||||
calendar_id: calendarId,
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
start_at: startNs,
|
||||
end_at: endNs,
|
||||
all_day: allDay,
|
||||
location: location.trim() || undefined
|
||||
});
|
||||
if (result) {
|
||||
toast.success($i18n.t('Event updated'));
|
||||
dispatch('save', result);
|
||||
show = false;
|
||||
}
|
||||
} else {
|
||||
const form: CalendarEventForm = {
|
||||
calendar_id: calendarId,
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
start_at: startNs,
|
||||
end_at: endNs,
|
||||
all_day: allDay,
|
||||
location: location.trim() || undefined
|
||||
};
|
||||
const result = await createCalendarEvent(localStorage.token, form);
|
||||
if (result) {
|
||||
toast.success($i18n.t('Event created'));
|
||||
dispatch('save', result);
|
||||
show = false;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(`${err}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteHandler = async () => {
|
||||
if (!event || event.meta?.automation_id) return;
|
||||
loading = true;
|
||||
try {
|
||||
await deleteCalendarEvent(localStorage.token, event.id);
|
||||
toast.success($i18n.t('Event deleted'));
|
||||
dispatch('delete', event);
|
||||
show = false;
|
||||
} catch (err) {
|
||||
toast.error(`${err}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal size="md" bind:show>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
|
||||
<input
|
||||
class="w-full text-lg bg-transparent outline-hidden font-primary placeholder:text-gray-300 dark:placeholder:text-gray-700"
|
||||
type="text"
|
||||
bind:value={title}
|
||||
placeholder={$i18n.t('Event title')}
|
||||
/>
|
||||
<button
|
||||
class="self-center shrink-0 ml-2"
|
||||
aria-label={$i18n.t('Close')}
|
||||
on:click={() => (show = false)}
|
||||
>
|
||||
<XMark className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Details -->
|
||||
<div class="px-5 pb-2 flex flex-col gap-3">
|
||||
<!-- Calendar -->
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Calendar')}</div>
|
||||
<select
|
||||
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)}
|
||||
<option value={cal.id}>{cal.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Date / Time -->
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">{$i18n.t('When')}</div>
|
||||
<div class="flex items-center gap-2 text-sm flex-wrap">
|
||||
<input type="date" class="bg-transparent outline-hidden" bind:value={startDate} />
|
||||
{#if !allDay}
|
||||
<input type="time" class="bg-transparent outline-hidden" bind:value={startTime} />
|
||||
<span class="text-gray-300 dark:text-gray-600">–</span>
|
||||
<input type="time" class="bg-transparent outline-hidden" bind:value={endTime} />
|
||||
{/if}
|
||||
<label class="flex items-center gap-1.5 cursor-pointer text-xs text-gray-400 ml-auto">
|
||||
<input type="checkbox" class="accent-blue-500" bind:checked={allDay} />
|
||||
{$i18n.t('All day')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Location')}</div>
|
||||
<input
|
||||
class="w-full text-sm bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700"
|
||||
placeholder={$i18n.t('Add location')}
|
||||
bind:value={location}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Description')}</div>
|
||||
<textarea
|
||||
class="w-full text-sm bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 resize-none min-h-[4rem]"
|
||||
placeholder={$i18n.t('Add description')}
|
||||
bind:value={description}
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom toolbar -->
|
||||
<div class="flex items-center justify-between px-4 pb-3.5 pt-1 gap-2">
|
||||
<div class="flex items-center gap-0.5 flex-1 min-w-0">
|
||||
{#if event && !event.meta?.automation_id}
|
||||
<button
|
||||
class="px-3 py-1 text-xs text-gray-400 hover:text-red-500 transition"
|
||||
type="button"
|
||||
on:click={deleteHandler}
|
||||
disabled={loading}
|
||||
>
|
||||
{$i18n.t('Delete')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
class="px-3 py-1 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-200 transition"
|
||||
type="button"
|
||||
on:click={() => (show = false)}
|
||||
>
|
||||
{$i18n.t('Cancel')}
|
||||
</button>
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 {loading
|
||||
? 'cursor-not-allowed'
|
||||
: ''}"
|
||||
on:click={submitHandler}
|
||||
type="button"
|
||||
disabled={loading}
|
||||
>
|
||||
{event && !event.meta?.automation_id ? $i18n.t('Save') : $i18n.t('Create')}
|
||||
{#if loading}
|
||||
<span class="shrink-0"><Spinner /></span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -0,0 +1,174 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import type { CalendarModel } from '$lib/apis/calendar';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let calendars: CalendarModel[] = [];
|
||||
export let visibleCalendarIds: Set<string> = new Set();
|
||||
export let currentDate: Date = new Date();
|
||||
export let onToggle: (id: string) => void = () => {};
|
||||
export let onCreateCalendar: () => void = () => {};
|
||||
export let onDateSelect: (date: Date) => void = () => {};
|
||||
|
||||
// Mini calendar state
|
||||
$: miniMonth = currentDate.getMonth();
|
||||
$: miniYear = currentDate.getFullYear();
|
||||
|
||||
$: miniMonthStart = new Date(miniYear, miniMonth, 1);
|
||||
$: miniCalStart = (() => {
|
||||
const d = new Date(miniMonthStart);
|
||||
d.setDate(d.getDate() - d.getDay());
|
||||
return d;
|
||||
})();
|
||||
|
||||
$: miniDays = (() => {
|
||||
const days: Date[] = [];
|
||||
const d = new Date(miniCalStart);
|
||||
for (let i = 0; i < 42; i++) {
|
||||
days.push(new Date(d));
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
return days;
|
||||
})();
|
||||
|
||||
$: miniMonthNames = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December'
|
||||
];
|
||||
|
||||
function isToday(d: Date): boolean {
|
||||
return d.toDateString() === new Date().toDateString();
|
||||
}
|
||||
|
||||
function isSelected(d: Date): boolean {
|
||||
return d.toDateString() === currentDate.toDateString();
|
||||
}
|
||||
|
||||
function navigateMini(delta: number) {
|
||||
if (miniMonth + delta > 11) {
|
||||
miniMonth = 0;
|
||||
miniYear++;
|
||||
} else if (miniMonth + delta < 0) {
|
||||
miniMonth = 11;
|
||||
miniYear--;
|
||||
} else {
|
||||
miniMonth += delta;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Mini Month Calendar -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between px-1 mb-1.5 mt-2">
|
||||
<div class="text-xs font-medium">{miniMonthNames[miniMonth]} {miniYear}</div>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<button
|
||||
class="p-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition"
|
||||
on:click={() => navigateMini(-1)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="size-3"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 19.5 8.25 12l7.5-7.5"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
class="p-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition"
|
||||
on:click={() => navigateMini(1)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="size-3"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m8.25 4.5 7.5 7.5-7.5 7.5"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 text-center text-[10px] text-gray-400 dark:text-gray-500 mb-0.5">
|
||||
{#each ['S', 'M', 'T', 'W', 'T', 'F', 'S'] as d}
|
||||
<div class="py-0.5">{d}</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 text-center text-xs">
|
||||
{#each miniDays as day}
|
||||
<button
|
||||
class="w-7 h-7 flex items-center justify-center rounded-full transition
|
||||
{day.getMonth() !== miniMonth ? 'text-gray-300 dark:text-gray-600' : ''}
|
||||
{isToday(day) ? 'bg-blue-500 text-white' : ''}
|
||||
{day.toDateString() === currentDate.toDateString() && !isToday(day)
|
||||
? 'bg-gray-200 dark:bg-gray-700'
|
||||
: ''}
|
||||
{!isToday(day) && day.toDateString() !== currentDate.toDateString()
|
||||
? 'hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
: ''}"
|
||||
on:click={() => onDateSelect(day)}
|
||||
>
|
||||
{day.getDate()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calendar List -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1 px-1">
|
||||
<div class="text-[11px] text-gray-400 dark:text-gray-500 uppercase tracking-wider">
|
||||
{$i18n.t('Calendars')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each calendars as cal (cal.id)}
|
||||
<button
|
||||
class="flex items-center gap-2 px-2 py-1 rounded-lg text-xs transition
|
||||
hover:bg-gray-50 dark:hover:bg-gray-800/50 w-full text-left"
|
||||
on:click={() => onToggle(cal.id)}
|
||||
>
|
||||
<span
|
||||
class="shrink-0 size-2.5 rounded-full transition-opacity"
|
||||
style="background-color: {cal.color || '#3b82f6'}; opacity: {visibleCalendarIds.has(
|
||||
cal.id
|
||||
)
|
||||
? '1'
|
||||
: '0.25'};"
|
||||
></span>
|
||||
<span
|
||||
class="truncate flex-1 {visibleCalendarIds.has(cal.id)
|
||||
? ''
|
||||
: 'text-gray-400 dark:text-gray-500'}"
|
||||
>
|
||||
{cal.name}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,380 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, getContext } from 'svelte';
|
||||
import { mobile, showSidebar } from '$lib/stores';
|
||||
import type { CalendarEventModel, CalendarModel } from '$lib/apis/calendar';
|
||||
import CalendarEventChip from './CalendarEventChip.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');
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let events: CalendarEventModel[] = [];
|
||||
export let calendars: CalendarModel[] = [];
|
||||
export let visibleCalendarIds: Set<string> = new Set();
|
||||
export let view: 'month' | 'week' | 'day' = 'month';
|
||||
export let currentDate: Date = new Date();
|
||||
|
||||
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'
|
||||
];
|
||||
|
||||
$: calColorMap = calendars.reduce((acc, c) => ({ ...acc, [c.id]: c.color }), {} as Record<string, string | null>);
|
||||
$: filteredEvents = events.filter((e) => visibleCalendarIds.has(e.calendar_id));
|
||||
|
||||
// Pre-group events by day key so the template reactively updates when events change
|
||||
$: eventsByDay = (() => {
|
||||
const map: Record<string, CalendarEventModel[]> = {};
|
||||
for (const e of filteredEvents) {
|
||||
const startMs = e.start_at / NS;
|
||||
const endMs = (e.end_at || e.start_at) / NS;
|
||||
// Get local midnight for event start/end
|
||||
const startDate = new Date(startMs);
|
||||
const endDate = new Date(endMs);
|
||||
const d = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
|
||||
const last = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate()).getTime();
|
||||
while (d.getTime() <= last) {
|
||||
const key = d.getTime().toString();
|
||||
(map[key] ??= []).push(e);
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
|
||||
$: monthStart = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1);
|
||||
$: calendarStart = (() => {
|
||||
const d = new Date(monthStart);
|
||||
d.setDate(d.getDate() - d.getDay());
|
||||
return d;
|
||||
})();
|
||||
|
||||
$: monthDays = (() => {
|
||||
const days: Date[] = [];
|
||||
const d = new Date(calendarStart);
|
||||
for (let i = 0; i < 42; i++) {
|
||||
days.push(new Date(d));
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
return days;
|
||||
})();
|
||||
|
||||
$: weekStart = (() => {
|
||||
const d = new Date(currentDate);
|
||||
d.setDate(d.getDate() - d.getDay());
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
})();
|
||||
|
||||
$: weekDays = (() => {
|
||||
const days: Date[] = [];
|
||||
const d = new Date(weekStart);
|
||||
for (let i = 0; i < 7; i++) {
|
||||
days.push(new Date(d));
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
return days;
|
||||
})();
|
||||
|
||||
$: hours = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
function isToday(d: Date): boolean {
|
||||
return d.toDateString() === new Date().toDateString();
|
||||
}
|
||||
|
||||
function isCurrentMonth(d: Date): boolean {
|
||||
return d.getMonth() === currentDate.getMonth();
|
||||
}
|
||||
|
||||
function getEventsForDay(day: Date): CalendarEventModel[] {
|
||||
const dayStartMs = new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime();
|
||||
const dayEndMs = dayStartMs + 86_400_000;
|
||||
return filteredEvents.filter((e) => {
|
||||
const startMs = e.start_at / NS;
|
||||
const endMs = (e.end_at || e.start_at) / NS;
|
||||
return startMs < dayEndMs && endMs >= dayStartMs;
|
||||
});
|
||||
}
|
||||
|
||||
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) => {
|
||||
const startMs = e.start_at / NS;
|
||||
return startMs >= hourStartMs && startMs < hourEndMs;
|
||||
});
|
||||
}
|
||||
|
||||
function formatHour(h: number): string {
|
||||
if (h === 0) return '12 AM';
|
||||
if (h < 12) return `${h} AM`;
|
||||
if (h === 12) return '12 PM';
|
||||
return `${h - 12} PM`;
|
||||
}
|
||||
|
||||
function navigate(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;
|
||||
dispatch('navigate', { date: currentDate });
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
currentDate = new Date();
|
||||
dispatch('navigate', { date: currentDate });
|
||||
}
|
||||
|
||||
function handleDayClick(day: Date) {
|
||||
currentDate = day;
|
||||
const ms = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 9).getTime();
|
||||
dispatch('createEvent', { start_at: ms * NS });
|
||||
}
|
||||
|
||||
function goToDayView(day: Date) {
|
||||
currentDate = day;
|
||||
view = 'day';
|
||||
dispatch('viewChange', view);
|
||||
dispatch('navigate', { date: currentDate });
|
||||
}
|
||||
|
||||
function handleHourClick(day: Date, hour: number) {
|
||||
currentDate = day;
|
||||
const ms = new Date(day.getFullYear(), day.getMonth(), day.getDate(), hour).getTime();
|
||||
dispatch('createEvent', { start_at: ms * NS });
|
||||
}
|
||||
|
||||
function handleEventClick(event: CalendarEventModel) {
|
||||
dispatch('eventClick', event);
|
||||
}
|
||||
|
||||
$: headerText = view === 'day'
|
||||
? `${DAY_NAMES[currentDate.getDay()]}, ${MONTH_NAMES[currentDate.getMonth()]} ${currentDate.getDate()}, ${currentDate.getFullYear()}`
|
||||
: `${MONTH_NAMES[currentDate.getMonth()]} ${currentDate.getFullYear()}`;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full w-full min-h-0 min-w-0">
|
||||
<!-- Navbar — matches admin/workspace/notes pattern -->
|
||||
<nav class="px-2.5 pt-1.5 pb-2 backdrop-blur-xl drag-region select-none shrink-0">
|
||||
<div class="flex items-center gap-1">
|
||||
{#if $mobile}
|
||||
<div class="{$showSidebar ? 'md:hidden' : ''} flex flex-none items-center">
|
||||
<Tooltip content={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')} interactive={true}>
|
||||
<button
|
||||
id="sidebar-toggle-button"
|
||||
class="cursor-pointer flex rounded-lg hover:bg-gray-100 dark:hover:bg-gray-850 transition"
|
||||
on:click={() => showSidebar.set(!$showSidebar)}
|
||||
>
|
||||
<div class="self-center p-1.5">
|
||||
<SidebarIcon />
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex w-full items-center">
|
||||
<div class="flex items-center gap-0.5 py-1">
|
||||
<span class="min-w-fit px-1 text-sm select-none">{headerText}</span>
|
||||
<button class="p-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-850 transition" on:click={() => navigate(-1)} aria-label="Previous">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-3.5 text-gray-400"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" /></svg>
|
||||
</button>
|
||||
<button class="p-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-850 transition" on:click={() => navigate(1)} aria-label="Next">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-3.5 text-gray-400"><path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
class="text-xs px-2 py-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-850 transition text-gray-500 hover:text-gray-700 dark:hover:text-white"
|
||||
on:click={goToToday}
|
||||
>
|
||||
{$i18n.t('Today')}
|
||||
</button>
|
||||
|
||||
<Select
|
||||
bind:value={view}
|
||||
items={[
|
||||
{ value: 'day', label: $i18n.t('Day') },
|
||||
{ value: 'week', label: $i18n.t('Week') },
|
||||
{ value: 'month', label: $i18n.t('Month') }
|
||||
]}
|
||||
onChange={() => dispatch('viewChange', view)}
|
||||
triggerClass="relative flex items-center gap-1.5 px-3 py-1.5 bg-gray-50 dark:bg-gray-850 rounded-xl text-xs"
|
||||
contentClass="rounded-2xl w-40 p-1 border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
|
||||
align="end"
|
||||
>
|
||||
<svelte:fragment slot="trigger" let:selectedLabel>
|
||||
<span class="inline-flex h-input px-0.5 outline-hidden bg-transparent">
|
||||
{selectedLabel}
|
||||
</span>
|
||||
<ChevronDown className="size-3.5" strokeWidth="2.5" />
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="item" let:item let:selected>
|
||||
{item.label}
|
||||
<div class="ml-auto {selected ? '' : 'invisible'}">
|
||||
<Check />
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Select>
|
||||
|
||||
<button
|
||||
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')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" class="size-3"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Month View -->
|
||||
{#if view === 'month'}
|
||||
<div class="flex-1 flex flex-col min-h-0 px-3 pb-3">
|
||||
<div class="grid grid-cols-7">
|
||||
{#each DAY_NAMES as day}
|
||||
<div class="px-2 py-1.5 text-xs text-gray-400 dark:text-gray-500 text-left">{$i18n.t(day)}</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 grid grid-cols-7 auto-rows-fr min-h-0 rounded-2xl overflow-hidden bg-white dark:bg-gray-900 border border-gray-100/30 dark:border-gray-850/30">
|
||||
{#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)}
|
||||
<button
|
||||
class="p-1 min-h-0 text-left overflow-hidden transition cursor-pointer flex flex-col
|
||||
{isCurrentMonth(day) ? '' : 'opacity-40'}
|
||||
hover:bg-gray-50/80 dark:hover:bg-gray-850/30
|
||||
{col > 0 ? 'border-l border-gray-100/20 dark:border-gray-850/20' : ''}
|
||||
{row > 0 ? 'border-t border-gray-100/20 dark:border-gray-850/20' : ''}"
|
||||
on:click={() => handleDayClick(day)}
|
||||
>
|
||||
<div class="flex justify-start px-0.5 mb-0.5">
|
||||
<span
|
||||
class="text-xs w-6 h-6 flex items-center justify-center rounded-full
|
||||
{isToday(day) ? 'bg-blue-500 text-white' : 'text-gray-500 dark:text-gray-400'}"
|
||||
>
|
||||
{day.getDate()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0 flex-1 overflow-hidden">
|
||||
{#each dayEvents.slice(0, 3) as evt (evt.instance_id || evt.id)}
|
||||
<CalendarEventChip
|
||||
event={evt}
|
||||
calendarColor={calColorMap[evt.calendar_id]}
|
||||
on:click={() => handleEventClick(evt)}
|
||||
/>
|
||||
{/each}
|
||||
{#if dayEvents.length > 3}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events --><!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="text-[10px] text-gray-400 dark:text-gray-500 px-1 mt-auto hover:text-gray-700 dark:hover:text-gray-200 text-left w-full truncate z-10"
|
||||
on:click|stopPropagation={() => goToDayView(day)}
|
||||
>
|
||||
+{dayEvents.length - 3} more
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Week View -->
|
||||
{:else if view === 'week'}
|
||||
<div class="flex-1 flex flex-col min-h-0 px-3 pb-3">
|
||||
<div class="flex-1 rounded-2xl bg-white dark:bg-gray-900 border border-gray-100/30 dark:border-gray-850/30 overflow-hidden relative">
|
||||
<div class="absolute inset-0 overflow-x-auto flex flex-col">
|
||||
<div class="min-w-[700px] flex flex-col flex-1">
|
||||
<div class="grid grid-cols-[52px_repeat(7,1fr)] shrink-0 border-b border-gray-100/30 dark:border-gray-850/30">
|
||||
<div></div>
|
||||
{#each weekDays as day}
|
||||
<div class="text-center py-2.5 {day.getDay() > 0 ? 'border-l border-gray-100/20 dark:border-gray-850/20' : ''}">
|
||||
<div class="text-[11px] text-gray-400 dark:text-gray-500">{DAY_NAMES[day.getDay()]}</div>
|
||||
<div class="text-sm mt-0.5 w-7 h-7 flex items-center justify-center mx-auto rounded-full {isToday(day) ? 'bg-blue-500 text-white' : ''}">
|
||||
{day.getDate()}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#each hours as hour}
|
||||
<div class="grid grid-cols-[52px_repeat(7,1fr)] min-h-[52px] {hour > 0 ? 'border-t border-gray-100/15 dark:border-gray-850/15' : ''}">
|
||||
<div class="text-[10px] text-gray-400 dark:text-gray-500 text-right pr-2 select-none -mt-1.5 z-10">{hour > 0 ? formatHour(hour) : ''}</div>
|
||||
{#each weekDays as day}
|
||||
{@const hourEvents = getEventsForHour(day, hour, filteredEvents)}
|
||||
<button
|
||||
class="px-0.5 py-0.5 {day.getDay() > 0 ? 'border-l border-gray-100/15 dark:border-gray-850/15' : ''} hover:bg-gray-50/50 dark:hover:bg-gray-850/20 transition cursor-pointer min-w-0 flex flex-col"
|
||||
on:click={() => handleHourClick(day, hour)}
|
||||
>
|
||||
<div class="flex flex-col gap-0.5 w-full min-h-0">
|
||||
{#each hourEvents.slice(0, 3) as evt (evt.instance_id || evt.id)}
|
||||
<CalendarEventChip
|
||||
event={evt}
|
||||
calendarColor={calColorMap[evt.calendar_id]}
|
||||
on:click={() => handleEventClick(evt)}
|
||||
/>
|
||||
{/each}
|
||||
{#if hourEvents.length > 3}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events --><!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="text-[10px] text-gray-400 dark:text-gray-500 px-1 mt-auto hover:text-gray-700 dark:hover:text-gray-200 text-left w-full truncate z-10"
|
||||
on:click|stopPropagation={() => goToDayView(day)}
|
||||
>
|
||||
+{hourEvents.length - 3} more
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Day View -->
|
||||
{:else}
|
||||
<div class="flex-1 flex flex-col min-h-0 px-3 pb-3">
|
||||
<div class="flex-1 rounded-2xl overflow-hidden bg-white dark:bg-gray-900 border border-gray-100/30 dark:border-gray-850/30 overflow-y-auto">
|
||||
{#each hours as hour}
|
||||
{@const hourEvents = getEventsForHour(currentDate, hour, filteredEvents)}
|
||||
<div class="flex min-h-[52px] {hour > 0 ? 'border-t border-gray-100/15 dark:border-gray-850/15' : ''}">
|
||||
<div class="w-14 shrink-0 text-[10px] text-gray-400 dark:text-gray-500 text-right pr-3 -mt-1.5 select-none">{hour > 0 ? formatHour(hour) : ''}</div>
|
||||
<button
|
||||
class="flex-1 border-l border-gray-100/15 dark:border-gray-850/15 px-1.5 py-0.5
|
||||
hover:bg-gray-50/50 dark:hover:bg-gray-850/20 transition cursor-pointer flex flex-col text-left justify-start"
|
||||
on:click={() => handleHourClick(currentDate, hour)}
|
||||
>
|
||||
<div class="flex flex-col gap-0.5 w-full">
|
||||
{#each hourEvents as evt (evt.instance_id || evt.id)}
|
||||
<CalendarEventChip
|
||||
event={evt}
|
||||
calendarColor={calColorMap[evt.calendar_id]}
|
||||
on:click={() => handleEventClick(evt)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -250,6 +250,38 @@
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if $user?.role === 'admin' || $user?.permissions?.features?.calendar}
|
||||
<a
|
||||
href="/calendar"
|
||||
draggable="false"
|
||||
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
|
||||
on:click={async (e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
|
||||
e.preventDefault();
|
||||
show = false;
|
||||
goto('/calendar');
|
||||
}}
|
||||
>
|
||||
<div class="self-center mr-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="size-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="self-center truncate">{$i18n.t('Calendar')}</div>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if role === 'admin'}
|
||||
<a
|
||||
href="/playground"
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<script lang="ts">
|
||||
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<string> = 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<CalendarEventModel>) {
|
||||
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;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$i18n.t('Calendar')} • {$WEBUI_NAME}</title>
|
||||
</svelte:head>
|
||||
|
||||
<CalendarEventModal
|
||||
bind:show={showEventModal}
|
||||
event={editEvent}
|
||||
{calendars}
|
||||
{defaultCalendarId}
|
||||
{defaultStartAt}
|
||||
on:save={() => refresh()}
|
||||
on:delete={() => refresh()}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
|
||||
? 'md:max-w-[calc(100%-var(--sidebar-width))]'
|
||||
: ''} max-w-full"
|
||||
>
|
||||
{#if loaded}
|
||||
<div class="flex flex-1 min-h-0">
|
||||
<!-- Sidebar -->
|
||||
<div class="hidden md:flex flex-col w-56 shrink-0 px-3 pt-3 overflow-y-auto">
|
||||
<button
|
||||
class="px-2 py-1.5 mt-0.5 mb-2.5 rounded-xl bg-black text-white dark:bg-white dark:text-black transition text-sm flex items-center justify-center gap-1"
|
||||
on:click={handleNewEvent}
|
||||
>
|
||||
<Plus className="size-3" strokeWidth="2.5" />
|
||||
<span class="text-xs">{$i18n.t('New Event')}</span>
|
||||
</button>
|
||||
|
||||
<CalendarSidebar
|
||||
{calendars}
|
||||
{visibleCalendarIds}
|
||||
{currentDate}
|
||||
onToggle={toggleCalendar}
|
||||
onDateSelect={handleDateSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Calendar -->
|
||||
<div class="flex-1 flex flex-col min-h-0 pt-0.5">
|
||||
<CalendarView
|
||||
{events}
|
||||
{calendars}
|
||||
{visibleCalendarIds}
|
||||
bind:view
|
||||
bind:currentDate
|
||||
on:createEvent={handleCreateEvent}
|
||||
on:eventClick={handleEventClick}
|
||||
on:navigate={handleNavigate}
|
||||
on:viewChange={handleNavigate}
|
||||
on:newEvent={handleNewEvent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-full h-full flex justify-center items-center">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user