mirror of
https://github.com/mruwnik/memory.git
synced 2026-01-02 09:12:58 +01:00
Calendar view
This commit is contained in:
parent
5935f4741c
commit
53f97485c2
170
db/migrations/versions/20260101_120000_add_task_and_calendar.py
Normal file
170
db/migrations/versions/20260101_120000_add_task_and_calendar.py
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
"""Add task, calendar_event, and calendar_accounts tables
|
||||||
|
|
||||||
|
Revision ID: g3b4c5d6e7f8
|
||||||
|
Revises: add_exclude_folder_ids
|
||||||
|
Create Date: 2026-01-01 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "g3b4c5d6e7f8"
|
||||||
|
down_revision: Union[str, None] = "add_exclude_folder_ids"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create calendar_accounts table (source for syncing)
|
||||||
|
op.create_table(
|
||||||
|
"calendar_accounts",
|
||||||
|
sa.Column("id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("name", sa.Text(), nullable=False),
|
||||||
|
sa.Column("calendar_type", sa.Text(), nullable=False),
|
||||||
|
sa.Column("caldav_url", sa.Text(), nullable=True),
|
||||||
|
sa.Column("caldav_username", sa.Text(), nullable=True),
|
||||||
|
sa.Column("caldav_password", sa.Text(), nullable=True),
|
||||||
|
sa.Column("google_account_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"calendar_ids",
|
||||||
|
postgresql.ARRAY(sa.Text()),
|
||||||
|
server_default="{}",
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"tags",
|
||||||
|
postgresql.ARRAY(sa.Text()),
|
||||||
|
server_default="{}",
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("check_interval", sa.Integer(), server_default="15", nullable=False),
|
||||||
|
sa.Column("sync_past_days", sa.Integer(), server_default="30", nullable=False),
|
||||||
|
sa.Column("sync_future_days", sa.Integer(), server_default="90", nullable=False),
|
||||||
|
sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("sync_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("active", sa.Boolean(), server_default="true", nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["google_account_id"], ["google_accounts.id"], ondelete="SET NULL"
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.CheckConstraint("calendar_type IN ('caldav', 'google')"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"calendar_accounts_active_idx",
|
||||||
|
"calendar_accounts",
|
||||||
|
["active", "last_sync_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"calendar_accounts_type_idx",
|
||||||
|
"calendar_accounts",
|
||||||
|
["calendar_type"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create task table
|
||||||
|
op.create_table(
|
||||||
|
"task",
|
||||||
|
sa.Column("id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("task_title", sa.Text(), nullable=False),
|
||||||
|
sa.Column("due_date", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("priority", sa.Text(), nullable=True),
|
||||||
|
sa.Column("status", sa.Text(), server_default="pending", nullable=False),
|
||||||
|
sa.Column("recurrence", sa.Text(), nullable=True),
|
||||||
|
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("source_item_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["id"], ["source_item.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["source_item_id"], ["source_item.id"], ondelete="SET NULL"
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"status IN ('pending', 'in_progress', 'done', 'cancelled')",
|
||||||
|
name="task_status_check",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"priority IS NULL OR priority IN ('low', 'medium', 'high', 'urgent')",
|
||||||
|
name="task_priority_check",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("task_due_date_idx", "task", ["due_date"], unique=False)
|
||||||
|
op.create_index("task_status_idx", "task", ["status"], unique=False)
|
||||||
|
op.create_index("task_priority_idx", "task", ["priority"], unique=False)
|
||||||
|
op.create_index("task_source_item_idx", "task", ["source_item_id"], unique=False)
|
||||||
|
|
||||||
|
# Create calendar_event table
|
||||||
|
op.create_table(
|
||||||
|
"calendar_event",
|
||||||
|
sa.Column("id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("event_title", sa.Text(), nullable=False),
|
||||||
|
sa.Column("start_time", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("end_time", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("all_day", sa.Boolean(), server_default="false", nullable=False),
|
||||||
|
sa.Column("location", sa.Text(), nullable=True),
|
||||||
|
sa.Column("recurrence_rule", sa.Text(), nullable=True),
|
||||||
|
sa.Column("calendar_account_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("calendar_name", sa.Text(), nullable=True),
|
||||||
|
sa.Column("external_id", sa.Text(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"event_metadata",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
server_default="{}",
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["id"], ["source_item.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["calendar_account_id"], ["calendar_accounts.id"], ondelete="SET NULL"
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("calendar_event_start_idx", "calendar_event", ["start_time"], unique=False)
|
||||||
|
op.create_index("calendar_event_end_idx", "calendar_event", ["end_time"], unique=False)
|
||||||
|
op.create_index("calendar_event_account_idx", "calendar_event", ["calendar_account_id"], unique=False)
|
||||||
|
op.create_index("calendar_event_calendar_idx", "calendar_event", ["calendar_name"], unique=False)
|
||||||
|
op.create_index(
|
||||||
|
"calendar_event_external_idx",
|
||||||
|
"calendar_event",
|
||||||
|
["calendar_account_id", "external_id"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("external_id IS NOT NULL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop calendar_event
|
||||||
|
op.drop_index("calendar_event_external_idx", table_name="calendar_event")
|
||||||
|
op.drop_index("calendar_event_calendar_idx", table_name="calendar_event")
|
||||||
|
op.drop_index("calendar_event_account_idx", table_name="calendar_event")
|
||||||
|
op.drop_index("calendar_event_end_idx", table_name="calendar_event")
|
||||||
|
op.drop_index("calendar_event_start_idx", table_name="calendar_event")
|
||||||
|
op.drop_table("calendar_event")
|
||||||
|
|
||||||
|
# Drop task
|
||||||
|
op.drop_index("task_source_item_idx", table_name="task")
|
||||||
|
op.drop_index("task_priority_idx", table_name="task")
|
||||||
|
op.drop_index("task_status_idx", table_name="task")
|
||||||
|
op.drop_index("task_due_date_idx", table_name="task")
|
||||||
|
op.drop_table("task")
|
||||||
|
|
||||||
|
# Drop calendar_accounts
|
||||||
|
op.drop_index("calendar_accounts_type_idx", table_name="calendar_accounts")
|
||||||
|
op.drop_index("calendar_accounts_active_idx", table_name="calendar_accounts")
|
||||||
|
op.drop_table("calendar_accounts")
|
||||||
@ -206,7 +206,7 @@ services:
|
|||||||
<<: *worker-base
|
<<: *worker-base
|
||||||
environment:
|
environment:
|
||||||
<<: *worker-env
|
<<: *worker-env
|
||||||
QUEUES: "backup,blogs,comic,discord,ebooks,email,forums,github,google,people,photo_embed,maintenance,notes,scheduler"
|
QUEUES: "backup,blogs,calendar,comic,discord,ebooks,email,forums,github,google,people,photo_embed,maintenance,notes,scheduler"
|
||||||
|
|
||||||
ingest-hub:
|
ingest-hub:
|
||||||
<<: *worker-base
|
<<: *worker-base
|
||||||
|
|||||||
@ -2030,3 +2030,190 @@ a.folder-item-name:hover {
|
|||||||
color: #b45309;
|
color: #b45309;
|
||||||
border: 1px solid #f59e0b;
|
border: 1px solid #f59e0b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Calendar Panel Events Section === */
|
||||||
|
|
||||||
|
.calendar-account-card {
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-events-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-events-section h5 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #4a5568;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-events {
|
||||||
|
color: #718096;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-groups {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-group {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-group-header:hover {
|
||||||
|
background: #edf2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-group-header.expanded {
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-expand-icon {
|
||||||
|
color: #718096;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
width: 12px;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-group-name {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-event-count {
|
||||||
|
background: #edf2f7;
|
||||||
|
color: #4a5568;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-events-list {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-event-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
border-bottom: 1px solid #f0f4f8;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-event-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-event-item:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-event-item.all-day {
|
||||||
|
background: #f0f9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-event-item.all-day:hover {
|
||||||
|
background: #e0f2fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-date-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
min-width: 100px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-date {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-time {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-time.all-day-badge {
|
||||||
|
color: #3182ce;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-info-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-info-col .event-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #2d3748;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-location {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #718096;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-recurring-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: #e9d8fd;
|
||||||
|
color: #6b46c1;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 500;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.calendar-event-item {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-date-col {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,7 +4,7 @@ import './App.css'
|
|||||||
|
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
import { useOAuth } from '@/hooks/useOAuth'
|
import { useOAuth } from '@/hooks/useOAuth'
|
||||||
import { Loading, LoginPrompt, AuthError, Dashboard, Search, Sources } from '@/components'
|
import { Loading, LoginPrompt, AuthError, Dashboard, Search, Sources, Calendar } from '@/components'
|
||||||
|
|
||||||
// AuthWrapper handles redirects based on auth state
|
// AuthWrapper handles redirects based on auth state
|
||||||
const AuthWrapper = () => {
|
const AuthWrapper = () => {
|
||||||
@ -102,6 +102,14 @@ const AuthWrapper = () => {
|
|||||||
)
|
)
|
||||||
} />
|
} />
|
||||||
|
|
||||||
|
<Route path="/ui/calendar" element={
|
||||||
|
isAuthenticated ? (
|
||||||
|
<Calendar />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/ui/login" replace />
|
||||||
|
)
|
||||||
|
} />
|
||||||
|
|
||||||
{/* Default redirect */}
|
{/* Default redirect */}
|
||||||
<Route path="/" element={
|
<Route path="/" element={
|
||||||
<Navigate to={isAuthenticated ? "/ui/dashboard" : "/ui/login"} replace />
|
<Navigate to={isAuthenticated ? "/ui/dashboard" : "/ui/login"} replace />
|
||||||
|
|||||||
@ -31,6 +31,11 @@ const Dashboard = ({ onLogout }) => {
|
|||||||
<p>Manage email, GitHub, RSS feeds, and Google Drive</p>
|
<p>Manage email, GitHub, RSS feeds, and Google Drive</p>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<Link to="/ui/calendar" className="feature-card">
|
||||||
|
<h3>Calendar</h3>
|
||||||
|
<p>View upcoming events from your calendars</p>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<div className="feature-card" onClick={async () => console.log(await listNotes())}>
|
<div className="feature-card" onClick={async () => console.log(await listNotes())}>
|
||||||
<h3>📝 Notes</h3>
|
<h3>📝 Notes</h3>
|
||||||
<p>Create and manage your notes</p>
|
<p>Create and manage your notes</p>
|
||||||
|
|||||||
423
frontend/src/components/calendar/Calendar.css
Normal file
423
frontend/src/components/calendar/Calendar.css
Normal file
@ -0,0 +1,423 @@
|
|||||||
|
.calendar-view {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
min-height: calc(100vh - 3rem);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #333;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #555;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-btn {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #555;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-btn:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-error {
|
||||||
|
background: #fee;
|
||||||
|
border: 1px solid #fcc;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-error p {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: #c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-error button {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
background: #c00;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
grid-template-rows: auto repeat(6, minmax(90px, 1fr));
|
||||||
|
width: 100%;
|
||||||
|
min-height: 600px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day-header {
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: #fafafa;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell {
|
||||||
|
border-right: 1px solid #f0f0f0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
padding: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell:nth-child(7n) {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell.other-month {
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell.other-month .cell-date {
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell.today {
|
||||||
|
background: #f0f7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-cell.today .cell-date {
|
||||||
|
background: #4a90d9;
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-date {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
padding: 0.25rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-events {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #e8f0fe;
|
||||||
|
color: #1a73e8;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1.3;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item.all-day {
|
||||||
|
background: #1a73e8;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-time {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-right: 3px;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-events {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #666;
|
||||||
|
padding: 2px 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-events:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
padding: 1.5rem 2.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-footer {
|
||||||
|
margin-top: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-link {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-link:hover {
|
||||||
|
color: #333;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.calendar-view {
|
||||||
|
padding: 1rem;
|
||||||
|
height: auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header h1 {
|
||||||
|
order: 2;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-nav {
|
||||||
|
order: 3;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
min-height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day-header {
|
||||||
|
padding: 0.5rem 0.25rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-date {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
padding: 1px 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-time {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Event Detail Modal */
|
||||||
|
.event-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow: auto;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-modal-header .modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-modal-header .modal-close:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-modal-content {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-detail:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value.recurring-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: #e9d8fd;
|
||||||
|
color: #6b46c1;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.event-modal {
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-modal-header {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-modal-header h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-modal-content {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
275
frontend/src/components/calendar/Calendar.tsx
Normal file
275
frontend/src/components/calendar/Calendar.tsx
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useSources, CalendarEvent } from '@/hooks/useSources'
|
||||||
|
import './Calendar.css'
|
||||||
|
|
||||||
|
const DAYS_OF_WEEK = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December'
|
||||||
|
]
|
||||||
|
|
||||||
|
interface DayCell {
|
||||||
|
date: Date
|
||||||
|
isCurrentMonth: boolean
|
||||||
|
isToday: boolean
|
||||||
|
events: CalendarEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const Calendar = () => {
|
||||||
|
const { getUpcomingEvents } = useSources()
|
||||||
|
const [events, setEvents] = useState<CalendarEvent[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [currentDate, setCurrentDate] = useState(new Date())
|
||||||
|
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null)
|
||||||
|
|
||||||
|
const loadEvents = useCallback(async (date: Date) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
// Calculate range for the month view (include overflow days)
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = date.getMonth()
|
||||||
|
// Start from first day of previous month (for overflow)
|
||||||
|
const startDate = new Date(year, month - 1, 1)
|
||||||
|
// End at last day of next month (for overflow)
|
||||||
|
const endDate = new Date(year, month + 2, 0)
|
||||||
|
|
||||||
|
const data = await getUpcomingEvents({
|
||||||
|
startDate: startDate.toISOString(),
|
||||||
|
endDate: endDate.toISOString(),
|
||||||
|
limit: 200,
|
||||||
|
})
|
||||||
|
setEvents(data)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to load events')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [getUpcomingEvents])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadEvents(currentDate)
|
||||||
|
}, [loadEvents, currentDate])
|
||||||
|
|
||||||
|
// Generate calendar grid for current month view
|
||||||
|
const calendarDays = useMemo((): DayCell[] => {
|
||||||
|
const year = currentDate.getFullYear()
|
||||||
|
const month = currentDate.getMonth()
|
||||||
|
|
||||||
|
// First day of the month
|
||||||
|
const firstDay = new Date(year, month, 1)
|
||||||
|
// Last day of the month
|
||||||
|
const lastDay = new Date(year, month + 1, 0)
|
||||||
|
|
||||||
|
// Get the day of week for first day (0 = Sunday, convert to Monday start)
|
||||||
|
let startDayOfWeek = firstDay.getDay()
|
||||||
|
startDayOfWeek = startDayOfWeek === 0 ? 6 : startDayOfWeek - 1 // Convert to Monday = 0
|
||||||
|
|
||||||
|
const days: DayCell[] = []
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
// Add days from previous month to fill the first week
|
||||||
|
const prevMonth = new Date(year, month, 0)
|
||||||
|
for (let i = startDayOfWeek - 1; i >= 0; i--) {
|
||||||
|
const date = new Date(year, month - 1, prevMonth.getDate() - i)
|
||||||
|
days.push({
|
||||||
|
date,
|
||||||
|
isCurrentMonth: false,
|
||||||
|
isToday: date.getTime() === today.getTime(),
|
||||||
|
events: getEventsForDate(date, events),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add days of current month
|
||||||
|
for (let day = 1; day <= lastDay.getDate(); day++) {
|
||||||
|
const date = new Date(year, month, day)
|
||||||
|
days.push({
|
||||||
|
date,
|
||||||
|
isCurrentMonth: true,
|
||||||
|
isToday: date.getTime() === today.getTime(),
|
||||||
|
events: getEventsForDate(date, events),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add days from next month to complete the grid (6 rows)
|
||||||
|
const remainingDays = 42 - days.length // 6 weeks * 7 days
|
||||||
|
for (let day = 1; day <= remainingDays; day++) {
|
||||||
|
const date = new Date(year, month + 1, day)
|
||||||
|
days.push({
|
||||||
|
date,
|
||||||
|
isCurrentMonth: false,
|
||||||
|
isToday: date.getTime() === today.getTime(),
|
||||||
|
events: getEventsForDate(date, events),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return days
|
||||||
|
}, [currentDate, events])
|
||||||
|
|
||||||
|
const goToPreviousMonth = () => {
|
||||||
|
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToNextMonth = () => {
|
||||||
|
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToToday = () => {
|
||||||
|
setCurrentDate(new Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatEventTime = (event: CalendarEvent) => {
|
||||||
|
if (event.all_day) return ''
|
||||||
|
const date = new Date(event.start_time)
|
||||||
|
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }).replace(' ', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="calendar-view">
|
||||||
|
<div className="calendar-header">
|
||||||
|
<Link to="/ui/dashboard" className="back-btn">Back</Link>
|
||||||
|
<h1>{MONTH_NAMES[currentDate.getMonth()]} {currentDate.getFullYear()}</h1>
|
||||||
|
<div className="calendar-nav">
|
||||||
|
<button onClick={goToPreviousMonth} className="nav-btn"><</button>
|
||||||
|
<button onClick={goToToday} className="today-btn">Today</button>
|
||||||
|
<button onClick={goToNextMonth} className="nav-btn">></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="calendar-error">
|
||||||
|
<p>{error}</p>
|
||||||
|
<button onClick={() => loadEvents(currentDate)}>Retry</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="calendar-grid">
|
||||||
|
{/* Day headers */}
|
||||||
|
{DAYS_OF_WEEK.map(day => (
|
||||||
|
<div key={day} className="calendar-day-header">{day}</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Calendar cells */}
|
||||||
|
{calendarDays.map((day, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`calendar-cell ${!day.isCurrentMonth ? 'other-month' : ''} ${day.isToday ? 'today' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="cell-date">{day.date.getDate()}</div>
|
||||||
|
<div className="cell-events">
|
||||||
|
{day.events.slice(0, 4).map((event, eventIndex) => (
|
||||||
|
<div
|
||||||
|
key={`${event.id}-${event.start_time}`}
|
||||||
|
className={`event-item ${event.all_day ? 'all-day' : ''}`}
|
||||||
|
title={`${event.event_title}${event.location ? ` - ${event.location}` : ''}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setSelectedEvent(event)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!event.all_day && (
|
||||||
|
<span className="event-time">{formatEventTime(event)}</span>
|
||||||
|
)}
|
||||||
|
<span className="event-title">{event.event_title}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{day.events.length > 4 && (
|
||||||
|
<div className="more-events">+{day.events.length - 4} more</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <div className="loading-overlay">Loading events...</div>}
|
||||||
|
|
||||||
|
{/* Event Detail Modal */}
|
||||||
|
{selectedEvent && (
|
||||||
|
<div className="event-modal-overlay" onClick={() => setSelectedEvent(null)}>
|
||||||
|
<div className="event-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="event-modal-header">
|
||||||
|
<h2>{selectedEvent.event_title}</h2>
|
||||||
|
<button className="modal-close" onClick={() => setSelectedEvent(null)}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="event-modal-content">
|
||||||
|
<div className="event-detail">
|
||||||
|
<span className="detail-label">Date</span>
|
||||||
|
<span className="detail-value">
|
||||||
|
{new Date(selectedEvent.start_time).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="event-detail">
|
||||||
|
<span className="detail-label">Time</span>
|
||||||
|
<span className="detail-value">
|
||||||
|
{selectedEvent.all_day ? 'All day' : (
|
||||||
|
<>
|
||||||
|
{new Date(selectedEvent.start_time).toLocaleTimeString('en-US', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
|
{selectedEvent.end_time && (
|
||||||
|
<> – {new Date(selectedEvent.end_time).toLocaleTimeString('en-US', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedEvent.location && (
|
||||||
|
<div className="event-detail">
|
||||||
|
<span className="detail-label">Location</span>
|
||||||
|
<span className="detail-value">{selectedEvent.location}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedEvent.calendar_name && (
|
||||||
|
<div className="event-detail">
|
||||||
|
<span className="detail-label">Calendar</span>
|
||||||
|
<span className="detail-value">{selectedEvent.calendar_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedEvent.recurrence_rule && (
|
||||||
|
<div className="event-detail">
|
||||||
|
<span className="detail-label">Repeats</span>
|
||||||
|
<span className="detail-value recurring-badge">Recurring event</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="calendar-footer">
|
||||||
|
<Link to="/ui/sources" className="config-link">Configure calendar accounts</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventsForDate(date: Date, events: CalendarEvent[]): CalendarEvent[] {
|
||||||
|
const dateStr = date.toISOString().split('T')[0]
|
||||||
|
return events.filter(event => {
|
||||||
|
const eventDate = new Date(event.start_time).toISOString().split('T')[0]
|
||||||
|
return eventDate === dateStr
|
||||||
|
}).sort((a, b) => {
|
||||||
|
// All-day events first, then by time
|
||||||
|
if (a.all_day && !b.all_day) return -1
|
||||||
|
if (!a.all_day && b.all_day) return 1
|
||||||
|
return new Date(a.start_time).getTime() - new Date(b.start_time).getTime()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Calendar
|
||||||
1
frontend/src/components/calendar/index.ts
Normal file
1
frontend/src/components/calendar/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from './Calendar'
|
||||||
@ -2,5 +2,6 @@ export { default as Loading } from './Loading'
|
|||||||
export { default as Dashboard } from './Dashboard'
|
export { default as Dashboard } from './Dashboard'
|
||||||
export { default as Search } from './search'
|
export { default as Search } from './search'
|
||||||
export { default as Sources } from './sources'
|
export { default as Sources } from './sources'
|
||||||
|
export { default as Calendar } from './calendar'
|
||||||
export { default as LoginPrompt } from './auth/LoginPrompt'
|
export { default as LoginPrompt } from './auth/LoginPrompt'
|
||||||
export { default as AuthError } from './auth/AuthError'
|
export { default as AuthError } from './auth/AuthError'
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useSources, EmailAccount, ArticleFeed, GithubAccount, GoogleAccount, GoogleFolder, GoogleOAuthConfig, DriveItem, BrowseResponse, GoogleFolderCreate } from '@/hooks/useSources'
|
import { useSources, EmailAccount, ArticleFeed, GithubAccount, GoogleAccount, GoogleFolder, GoogleOAuthConfig, DriveItem, BrowseResponse, GoogleFolderCreate, CalendarAccount } from '@/hooks/useSources'
|
||||||
import {
|
import {
|
||||||
SourceCard,
|
SourceCard,
|
||||||
Modal,
|
Modal,
|
||||||
@ -15,7 +15,7 @@ import {
|
|||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
} from './shared'
|
} from './shared'
|
||||||
|
|
||||||
type TabType = 'email' | 'feeds' | 'github' | 'google'
|
type TabType = 'email' | 'feeds' | 'github' | 'google' | 'calendar'
|
||||||
|
|
||||||
const Sources = () => {
|
const Sources = () => {
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('email')
|
const [activeTab, setActiveTab] = useState<TabType>('email')
|
||||||
@ -52,6 +52,12 @@ const Sources = () => {
|
|||||||
>
|
>
|
||||||
Google Drive
|
Google Drive
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tab ${activeTab === 'calendar' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('calendar')}
|
||||||
|
>
|
||||||
|
Calendar
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sources-content">
|
<div className="sources-content">
|
||||||
@ -59,6 +65,7 @@ const Sources = () => {
|
|||||||
{activeTab === 'feeds' && <FeedsPanel />}
|
{activeTab === 'feeds' && <FeedsPanel />}
|
||||||
{activeTab === 'github' && <GitHubPanel />}
|
{activeTab === 'github' && <GitHubPanel />}
|
||||||
{activeTab === 'google' && <GoogleDrivePanel />}
|
{activeTab === 'google' && <GoogleDrivePanel />}
|
||||||
|
{activeTab === 'calendar' && <CalendarPanel />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -1640,6 +1647,407 @@ const ExclusionBrowser = ({ accountId, folder, onSave, onCancel }: ExclusionBrow
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Calendar Panel ===
|
||||||
|
|
||||||
|
import { CalendarEvent } from '@/hooks/useSources'
|
||||||
|
|
||||||
|
interface GroupedEvents {
|
||||||
|
[calendarName: string]: CalendarEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const CalendarPanel = () => {
|
||||||
|
const {
|
||||||
|
listCalendarAccounts, createCalendarAccount, updateCalendarAccount,
|
||||||
|
deleteCalendarAccount, syncCalendarAccount, listGoogleAccounts, getUpcomingEvents
|
||||||
|
} = useSources()
|
||||||
|
const [accounts, setAccounts] = useState<CalendarAccount[]>([])
|
||||||
|
const [googleAccounts, setGoogleAccounts] = useState<GoogleAccount[]>([])
|
||||||
|
const [events, setEvents] = useState<CalendarEvent[]>([])
|
||||||
|
const [expandedCalendars, setExpandedCalendars] = useState<Set<string>>(new Set())
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [editingAccount, setEditingAccount] = useState<CalendarAccount | null>(null)
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const [calendarData, googleData, eventsData] = await Promise.all([
|
||||||
|
listCalendarAccounts(),
|
||||||
|
listGoogleAccounts(),
|
||||||
|
getUpcomingEvents({ days: 365, limit: 200 })
|
||||||
|
])
|
||||||
|
setAccounts(calendarData)
|
||||||
|
setGoogleAccounts(googleData)
|
||||||
|
setEvents(eventsData)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to load accounts')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [listCalendarAccounts, listGoogleAccounts, getUpcomingEvents])
|
||||||
|
|
||||||
|
useEffect(() => { loadData() }, [loadData])
|
||||||
|
|
||||||
|
const handleCreate = async (data: any) => {
|
||||||
|
await createCalendarAccount(data)
|
||||||
|
setShowForm(false)
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdate = async (data: any) => {
|
||||||
|
if (editingAccount) {
|
||||||
|
await updateCalendarAccount(editingAccount.id, data)
|
||||||
|
setEditingAccount(null)
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
await deleteCalendarAccount(id)
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleActive = async (account: CalendarAccount) => {
|
||||||
|
await updateCalendarAccount(account.id, { active: !account.active })
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSync = async (id: number) => {
|
||||||
|
await syncCalendarAccount(id)
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCalendar = (calendarName: string) => {
|
||||||
|
const newExpanded = new Set(expandedCalendars)
|
||||||
|
if (newExpanded.has(calendarName)) {
|
||||||
|
newExpanded.delete(calendarName)
|
||||||
|
} else {
|
||||||
|
newExpanded.add(calendarName)
|
||||||
|
}
|
||||||
|
setExpandedCalendars(newExpanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group events by calendar name
|
||||||
|
const groupedEvents: GroupedEvents = events.reduce((acc, event) => {
|
||||||
|
const calName = event.calendar_name || 'Unknown'
|
||||||
|
if (!acc[calName]) acc[calName] = []
|
||||||
|
acc[calName].push(event)
|
||||||
|
return acc
|
||||||
|
}, {} as GroupedEvents)
|
||||||
|
|
||||||
|
const formatEventDate = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatEventTime = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <LoadingState />
|
||||||
|
if (error) return <ErrorState message={error} onRetry={loadData} />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="source-panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h3>Calendar Accounts</h3>
|
||||||
|
<button className="add-btn" onClick={() => setShowForm(true)}>Add Calendar</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{accounts.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
message="No calendar accounts configured"
|
||||||
|
actionLabel="Add Calendar"
|
||||||
|
onAction={() => setShowForm(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="source-list">
|
||||||
|
{accounts.map(account => (
|
||||||
|
<div key={account.id} className="calendar-account-card">
|
||||||
|
<div className="source-card-header">
|
||||||
|
<div className="source-card-info">
|
||||||
|
<h4>{account.name}</h4>
|
||||||
|
<p className="source-subtitle">
|
||||||
|
{account.calendar_type === 'google'
|
||||||
|
? `Google Calendar (${account.google_account?.email || 'linked'})`
|
||||||
|
: `CalDAV: ${account.caldav_url}`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="source-card-actions-inline">
|
||||||
|
<StatusBadge active={account.active} onClick={() => handleToggleActive(account)} />
|
||||||
|
<SyncButton onSync={() => handleSync(account.id)} disabled={!account.active} label="Sync" />
|
||||||
|
<button className="edit-btn" onClick={() => setEditingAccount(account)}>Edit</button>
|
||||||
|
<button className="delete-btn" onClick={() => handleDelete(account.id)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="source-details">
|
||||||
|
<span>Type: {account.calendar_type === 'google' ? 'Google Calendar' : 'CalDAV'}</span>
|
||||||
|
<SyncStatus lastSyncAt={account.last_sync_at} />
|
||||||
|
{account.sync_error && (
|
||||||
|
<span className="sync-error">Error: {account.sync_error}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events grouped by calendar */}
|
||||||
|
<div className="calendar-events-section">
|
||||||
|
<h5>Calendars & Events</h5>
|
||||||
|
{Object.keys(groupedEvents).length === 0 ? (
|
||||||
|
<p className="no-events">No events synced yet</p>
|
||||||
|
) : (
|
||||||
|
<div className="calendar-groups">
|
||||||
|
{Object.entries(groupedEvents).map(([calendarName, calEvents]) => (
|
||||||
|
<div key={calendarName} className="calendar-group">
|
||||||
|
<button
|
||||||
|
className={`calendar-group-header ${expandedCalendars.has(calendarName) ? 'expanded' : ''}`}
|
||||||
|
onClick={() => toggleCalendar(calendarName)}
|
||||||
|
>
|
||||||
|
<span className="calendar-expand-icon">
|
||||||
|
{expandedCalendars.has(calendarName) ? '▼' : '▶'}
|
||||||
|
</span>
|
||||||
|
<span className="calendar-group-name">{calendarName}</span>
|
||||||
|
<span className="calendar-event-count">{calEvents.length} events</span>
|
||||||
|
</button>
|
||||||
|
{expandedCalendars.has(calendarName) && (
|
||||||
|
<div className="calendar-events-list">
|
||||||
|
{calEvents.map((event, idx) => (
|
||||||
|
<div key={`${event.id}-${idx}`} className={`calendar-event-item ${event.all_day ? 'all-day' : ''}`}>
|
||||||
|
<div className="event-date-col">
|
||||||
|
<span className="event-date">{formatEventDate(event.start_time)}</span>
|
||||||
|
{!event.all_day && (
|
||||||
|
<span className="event-time">{formatEventTime(event.start_time)}</span>
|
||||||
|
)}
|
||||||
|
{event.all_day && <span className="event-time all-day-badge">All day</span>}
|
||||||
|
</div>
|
||||||
|
<div className="event-info-col">
|
||||||
|
<span className="event-title">{event.event_title}</span>
|
||||||
|
{event.location && <span className="event-location">{event.location}</span>}
|
||||||
|
{event.recurrence_rule && <span className="event-recurring-badge">Recurring</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<CalendarForm
|
||||||
|
googleAccounts={googleAccounts}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
onCancel={() => setShowForm(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingAccount && (
|
||||||
|
<CalendarForm
|
||||||
|
account={editingAccount}
|
||||||
|
googleAccounts={googleAccounts}
|
||||||
|
onSubmit={handleUpdate}
|
||||||
|
onCancel={() => setEditingAccount(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CalendarFormProps {
|
||||||
|
account?: CalendarAccount
|
||||||
|
googleAccounts: GoogleAccount[]
|
||||||
|
onSubmit: (data: any) => Promise<void>
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CalendarForm = ({ account, googleAccounts, onSubmit, onCancel }: CalendarFormProps) => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: account?.name || '',
|
||||||
|
calendar_type: account?.calendar_type || 'caldav' as 'caldav' | 'google',
|
||||||
|
caldav_url: account?.caldav_url || '',
|
||||||
|
caldav_username: account?.caldav_username || '',
|
||||||
|
caldav_password: '',
|
||||||
|
google_account_id: account?.google_account_id || undefined as number | undefined,
|
||||||
|
tags: account?.tags || [],
|
||||||
|
check_interval: account?.check_interval || 15,
|
||||||
|
sync_past_days: account?.sync_past_days || 30,
|
||||||
|
sync_future_days: account?.sync_future_days || 90,
|
||||||
|
})
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data: any = {
|
||||||
|
name: formData.name,
|
||||||
|
calendar_type: formData.calendar_type,
|
||||||
|
tags: formData.tags,
|
||||||
|
check_interval: formData.check_interval,
|
||||||
|
sync_past_days: formData.sync_past_days,
|
||||||
|
sync_future_days: formData.sync_future_days,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.calendar_type === 'caldav') {
|
||||||
|
data.caldav_url = formData.caldav_url
|
||||||
|
data.caldav_username = formData.caldav_username
|
||||||
|
if (formData.caldav_password) {
|
||||||
|
data.caldav_password = formData.caldav_password
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data.google_account_id = formData.google_account_id
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSubmit(data)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to save')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={account ? 'Edit Calendar Account' : 'Add Calendar Account'} onClose={onCancel}>
|
||||||
|
<form onSubmit={handleSubmit} className="source-form">
|
||||||
|
{error && <div className="form-error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
required
|
||||||
|
placeholder="My Calendar"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Calendar Type</label>
|
||||||
|
<select
|
||||||
|
value={formData.calendar_type}
|
||||||
|
onChange={e => setFormData({ ...formData, calendar_type: e.target.value as 'caldav' | 'google' })}
|
||||||
|
disabled={!!account}
|
||||||
|
>
|
||||||
|
<option value="caldav">CalDAV (Radicale, Nextcloud, etc.)</option>
|
||||||
|
<option value="google">Google Calendar</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.calendar_type === 'caldav' ? (
|
||||||
|
<>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>CalDAV Server URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={formData.caldav_url}
|
||||||
|
onChange={e => setFormData({ ...formData, caldav_url: e.target.value })}
|
||||||
|
required={!account}
|
||||||
|
placeholder="https://caldav.example.com/user/calendar/"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.caldav_username}
|
||||||
|
onChange={e => setFormData({ ...formData, caldav_username: e.target.value })}
|
||||||
|
required={!account}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Password {account && '(leave blank to keep current)'}</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={formData.caldav_password}
|
||||||
|
onChange={e => setFormData({ ...formData, caldav_password: e.target.value })}
|
||||||
|
required={!account}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Google Account</label>
|
||||||
|
{googleAccounts.length === 0 ? (
|
||||||
|
<p className="form-hint">
|
||||||
|
No Google accounts connected. Connect a Google account in the Google Drive tab first.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={formData.google_account_id || ''}
|
||||||
|
onChange={e => setFormData({ ...formData, google_account_id: parseInt(e.target.value) || undefined })}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select a Google account...</option>
|
||||||
|
{googleAccounts.map(ga => (
|
||||||
|
<option key={ga.id} value={ga.id}>{ga.email}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Sync Past Days</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.sync_past_days}
|
||||||
|
onChange={e => setFormData({ ...formData, sync_past_days: parseInt(e.target.value) || 30 })}
|
||||||
|
min={0}
|
||||||
|
max={365}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Sync Future Days</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.sync_future_days}
|
||||||
|
onChange={e => setFormData({ ...formData, sync_future_days: parseInt(e.target.value) || 90 })}
|
||||||
|
min={0}
|
||||||
|
max={365}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IntervalInput
|
||||||
|
value={formData.check_interval}
|
||||||
|
onChange={check_interval => setFormData({ ...formData, check_interval })}
|
||||||
|
label="Check interval"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Tags</label>
|
||||||
|
<TagsInput
|
||||||
|
tags={formData.tags}
|
||||||
|
onChange={tags => setFormData({ ...formData, tags })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="cancel-btn" onClick={onCancel}>Cancel</button>
|
||||||
|
<button type="submit" className="submit-btn" disabled={submitting}>
|
||||||
|
{submitting ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface GoogleFolderFormProps {
|
interface GoogleFolderFormProps {
|
||||||
accountId: number
|
accountId: number
|
||||||
onSubmit: (data: any) => Promise<void>
|
onSubmit: (data: any) => Promise<void>
|
||||||
|
|||||||
@ -227,12 +227,79 @@ export interface BrowseResponse {
|
|||||||
next_page_token: string | null
|
next_page_token: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Types for Calendar Accounts
|
||||||
|
export interface CalendarGoogleAccountInfo {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarAccount {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
calendar_type: 'caldav' | 'google'
|
||||||
|
caldav_url: string | null
|
||||||
|
caldav_username: string | null
|
||||||
|
google_account_id: number | null
|
||||||
|
google_account: CalendarGoogleAccountInfo | null
|
||||||
|
calendar_ids: string[]
|
||||||
|
tags: string[]
|
||||||
|
check_interval: number
|
||||||
|
sync_past_days: number
|
||||||
|
sync_future_days: number
|
||||||
|
last_sync_at: string | null
|
||||||
|
sync_error: string | null
|
||||||
|
active: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarAccountCreate {
|
||||||
|
name: string
|
||||||
|
calendar_type: 'caldav' | 'google'
|
||||||
|
caldav_url?: string
|
||||||
|
caldav_username?: string
|
||||||
|
caldav_password?: string
|
||||||
|
google_account_id?: number
|
||||||
|
calendar_ids?: string[]
|
||||||
|
tags?: string[]
|
||||||
|
check_interval?: number
|
||||||
|
sync_past_days?: number
|
||||||
|
sync_future_days?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarAccountUpdate {
|
||||||
|
name?: string
|
||||||
|
caldav_url?: string
|
||||||
|
caldav_username?: string
|
||||||
|
caldav_password?: string
|
||||||
|
google_account_id?: number
|
||||||
|
calendar_ids?: string[]
|
||||||
|
tags?: string[]
|
||||||
|
check_interval?: number
|
||||||
|
sync_past_days?: number
|
||||||
|
sync_future_days?: number
|
||||||
|
active?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
// Task response
|
// Task response
|
||||||
export interface TaskResponse {
|
export interface TaskResponse {
|
||||||
task_id: string
|
task_id: string
|
||||||
status: string
|
status: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calendar Event
|
||||||
|
export interface CalendarEvent {
|
||||||
|
id: number
|
||||||
|
event_title: string
|
||||||
|
start_time: string
|
||||||
|
end_time: string | null
|
||||||
|
all_day: boolean
|
||||||
|
location: string | null
|
||||||
|
calendar_name: string | null
|
||||||
|
recurrence_rule: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export const useSources = () => {
|
export const useSources = () => {
|
||||||
const { apiCall } = useAuth()
|
const { apiCall } = useAuth()
|
||||||
|
|
||||||
@ -525,6 +592,64 @@ export const useSources = () => {
|
|||||||
if (!response.ok) throw new Error('Failed to delete Google OAuth config')
|
if (!response.ok) throw new Error('Failed to delete Google OAuth config')
|
||||||
}, [apiCall])
|
}, [apiCall])
|
||||||
|
|
||||||
|
// === Calendar Accounts ===
|
||||||
|
|
||||||
|
const listCalendarAccounts = useCallback(async (): Promise<CalendarAccount[]> => {
|
||||||
|
const response = await apiCall('/calendar-accounts')
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch calendar accounts')
|
||||||
|
return response.json()
|
||||||
|
}, [apiCall])
|
||||||
|
|
||||||
|
const createCalendarAccount = useCallback(async (data: CalendarAccountCreate): Promise<CalendarAccount> => {
|
||||||
|
const response = await apiCall('/calendar-accounts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || 'Failed to create calendar account')
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}, [apiCall])
|
||||||
|
|
||||||
|
const updateCalendarAccount = useCallback(async (id: number, data: CalendarAccountUpdate): Promise<CalendarAccount> => {
|
||||||
|
const response = await apiCall(`/calendar-accounts/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || 'Failed to update calendar account')
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}, [apiCall])
|
||||||
|
|
||||||
|
const deleteCalendarAccount = useCallback(async (id: number): Promise<void> => {
|
||||||
|
const response = await apiCall(`/calendar-accounts/${id}`, { method: 'DELETE' })
|
||||||
|
if (!response.ok) throw new Error('Failed to delete calendar account')
|
||||||
|
}, [apiCall])
|
||||||
|
|
||||||
|
const syncCalendarAccount = useCallback(async (id: number, forceFull = false): Promise<TaskResponse> => {
|
||||||
|
const response = await apiCall(`/calendar-accounts/${id}/sync?force_full=${forceFull}`, { method: 'POST' })
|
||||||
|
if (!response.ok) throw new Error('Failed to sync calendar account')
|
||||||
|
return response.json()
|
||||||
|
}, [apiCall])
|
||||||
|
|
||||||
|
const getUpcomingEvents = useCallback(async (
|
||||||
|
options: { days?: number; limit?: number; startDate?: string; endDate?: string } = {}
|
||||||
|
): Promise<CalendarEvent[]> => {
|
||||||
|
const { days = 7, limit = 100, startDate, endDate } = options
|
||||||
|
let url = `/calendar-accounts/events/upcoming?limit=${limit}`
|
||||||
|
if (startDate && endDate) {
|
||||||
|
url += `&start_date=${encodeURIComponent(startDate)}&end_date=${encodeURIComponent(endDate)}`
|
||||||
|
} else {
|
||||||
|
url += `&days=${days}`
|
||||||
|
}
|
||||||
|
const response = await apiCall(url)
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch upcoming events')
|
||||||
|
return response.json()
|
||||||
|
}, [apiCall])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Email
|
// Email
|
||||||
listEmailAccounts,
|
listEmailAccounts,
|
||||||
@ -564,5 +689,13 @@ export const useSources = () => {
|
|||||||
getGoogleOAuthConfig,
|
getGoogleOAuthConfig,
|
||||||
uploadGoogleOAuthConfig,
|
uploadGoogleOAuthConfig,
|
||||||
deleteGoogleOAuthConfig,
|
deleteGoogleOAuthConfig,
|
||||||
|
// Calendar Accounts
|
||||||
|
listCalendarAccounts,
|
||||||
|
createCalendarAccount,
|
||||||
|
updateCalendarAccount,
|
||||||
|
deleteCalendarAccount,
|
||||||
|
syncCalendarAccount,
|
||||||
|
// Calendar Events
|
||||||
|
getUpcomingEvents,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ export default defineConfig({
|
|||||||
'/auth': 'http://localhost:8000',
|
'/auth': 'http://localhost:8000',
|
||||||
'/health': 'http://localhost:8000',
|
'/health': 'http://localhost:8000',
|
||||||
'/email-accounts': 'http://localhost:8000',
|
'/email-accounts': 'http://localhost:8000',
|
||||||
|
'/calendar-accounts': 'http://localhost:8000',
|
||||||
'/article-feeds': 'http://localhost:8000',
|
'/article-feeds': 'http://localhost:8000',
|
||||||
'/github': 'http://localhost:8000',
|
'/github': 'http://localhost:8000',
|
||||||
'/google-drive': 'http://localhost:8000',
|
'/google-drive': 'http://localhost:8000',
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
discord.py==2.3.2
|
discord.py==2.3.2
|
||||||
uvicorn==0.29.0
|
uvicorn==0.29.0
|
||||||
|
caldav
|
||||||
@ -1,2 +1,3 @@
|
|||||||
boto3
|
boto3
|
||||||
awscli==1.42.64
|
awscli==1.42.64
|
||||||
|
caldav
|
||||||
@ -13,6 +13,7 @@ from memory.api.MCP.servers.core import core_mcp
|
|||||||
from memory.api.MCP.servers.github import github_mcp
|
from memory.api.MCP.servers.github import github_mcp
|
||||||
from memory.api.MCP.servers.meta import meta_mcp
|
from memory.api.MCP.servers.meta import meta_mcp
|
||||||
from memory.api.MCP.servers.meta import set_auth_provider as set_meta_auth
|
from memory.api.MCP.servers.meta import set_auth_provider as set_meta_auth
|
||||||
|
from memory.api.MCP.servers.organizer import organizer_mcp
|
||||||
from memory.api.MCP.servers.people import people_mcp
|
from memory.api.MCP.servers.people import people_mcp
|
||||||
from memory.api.MCP.servers.schedule import schedule_mcp
|
from memory.api.MCP.servers.schedule import schedule_mcp
|
||||||
from memory.api.MCP.servers.schedule import set_auth_provider as set_schedule_auth
|
from memory.api.MCP.servers.schedule import set_auth_provider as set_schedule_auth
|
||||||
@ -163,6 +164,7 @@ set_meta_auth(get_current_user)
|
|||||||
# Tools will be prefixed with their server name (e.g., core_search_knowledge_base)
|
# Tools will be prefixed with their server name (e.g., core_search_knowledge_base)
|
||||||
mcp.mount(core_mcp, prefix="core")
|
mcp.mount(core_mcp, prefix="core")
|
||||||
mcp.mount(github_mcp, prefix="github")
|
mcp.mount(github_mcp, prefix="github")
|
||||||
|
mcp.mount(organizer_mcp, prefix="organizer")
|
||||||
mcp.mount(people_mcp, prefix="people")
|
mcp.mount(people_mcp, prefix="people")
|
||||||
mcp.mount(schedule_mcp, prefix="schedule")
|
mcp.mount(schedule_mcp, prefix="schedule")
|
||||||
mcp.mount(books_mcp, prefix="books")
|
mcp.mount(books_mcp, prefix="books")
|
||||||
|
|||||||
@ -172,7 +172,7 @@ class SimpleOAuthProvider(OAuthProvider):
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
||||||
"""Get OAuth client information."""
|
"""Get OAuth client information."""
|
||||||
with make_session() as session:
|
with make_session() as session:
|
||||||
client = session.get(OAuthClientInformation, client_id)
|
client = session.get(OAuthClientInformation, client_id)
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from memory.api.MCP.servers.core import core_mcp
|
from memory.api.MCP.servers.core import core_mcp
|
||||||
from memory.api.MCP.servers.github import github_mcp
|
from memory.api.MCP.servers.github import github_mcp
|
||||||
|
from memory.api.MCP.servers.organizer import organizer_mcp
|
||||||
from memory.api.MCP.servers.people import people_mcp
|
from memory.api.MCP.servers.people import people_mcp
|
||||||
from memory.api.MCP.servers.schedule import schedule_mcp
|
from memory.api.MCP.servers.schedule import schedule_mcp
|
||||||
from memory.api.MCP.servers.books import books_mcp
|
from memory.api.MCP.servers.books import books_mcp
|
||||||
@ -10,6 +11,7 @@ from memory.api.MCP.servers.meta import meta_mcp
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"core_mcp",
|
"core_mcp",
|
||||||
"github_mcp",
|
"github_mcp",
|
||||||
|
"organizer_mcp",
|
||||||
"people_mcp",
|
"people_mcp",
|
||||||
"schedule_mcp",
|
"schedule_mcp",
|
||||||
"books_mcp",
|
"books_mcp",
|
||||||
|
|||||||
45
src/memory/api/MCP/servers/organizer.py
Normal file
45
src/memory/api/MCP/servers/organizer.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
"""
|
||||||
|
MCP subserver for organizational tools: calendar, todos, reminders.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
|
from memory.common.calendar import get_events_in_range, parse_date_range
|
||||||
|
from memory.common.db.connection import make_session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
organizer_mcp = FastMCP("memory-organizer")
|
||||||
|
|
||||||
|
|
||||||
|
@organizer_mcp.tool()
|
||||||
|
async def get_upcoming_events(
|
||||||
|
start_date: str | None = None,
|
||||||
|
end_date: str | None = None,
|
||||||
|
days: int = 7,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Get calendar events within a time span.
|
||||||
|
Use to check the user's schedule, find meetings, or plan around events.
|
||||||
|
Automatically expands recurring events to show all occurrences in the range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: ISO format start date (e.g., "2024-01-15" or "2024-01-15T09:00:00Z").
|
||||||
|
Defaults to now if not provided.
|
||||||
|
end_date: ISO format end date. Defaults to start_date + days if not provided.
|
||||||
|
days: Number of days from start_date if end_date not specified (default 7, max 365)
|
||||||
|
limit: Maximum number of events to return (default 50, max 200)
|
||||||
|
|
||||||
|
Returns: List of events with id, event_title, start_time, end_time, all_day,
|
||||||
|
location, calendar_name, recurrence_rule. Sorted by start_time.
|
||||||
|
"""
|
||||||
|
days = min(max(days, 1), 365)
|
||||||
|
limit = min(max(limit, 1), 200)
|
||||||
|
|
||||||
|
range_start, range_end = parse_date_range(start_date, end_date, days)
|
||||||
|
|
||||||
|
with make_session() as session:
|
||||||
|
return get_events_in_range(session, range_start, range_end, limit)
|
||||||
@ -27,6 +27,7 @@ from memory.api.google_drive import router as google_drive_router
|
|||||||
from memory.api.email_accounts import router as email_accounts_router
|
from memory.api.email_accounts import router as email_accounts_router
|
||||||
from memory.api.article_feeds import router as article_feeds_router
|
from memory.api.article_feeds import router as article_feeds_router
|
||||||
from memory.api.github_sources import router as github_sources_router
|
from memory.api.github_sources import router as github_sources_router
|
||||||
|
from memory.api.calendar_accounts import router as calendar_accounts_router
|
||||||
from memory.api.MCP.base import mcp
|
from memory.api.MCP.base import mcp
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -157,6 +158,7 @@ app.include_router(google_drive_router)
|
|||||||
app.include_router(email_accounts_router)
|
app.include_router(email_accounts_router)
|
||||||
app.include_router(article_feeds_router)
|
app.include_router(article_feeds_router)
|
||||||
app.include_router(github_sources_router)
|
app.include_router(github_sources_router)
|
||||||
|
app.include_router(calendar_accounts_router)
|
||||||
|
|
||||||
|
|
||||||
# Add health check to MCP server instead of main app
|
# Add health check to MCP server instead of main app
|
||||||
|
|||||||
@ -59,7 +59,9 @@ def feed_to_response(feed: ArticleFeed) -> ArticleFeedResponse:
|
|||||||
description=cast(str | None, feed.description),
|
description=cast(str | None, feed.description),
|
||||||
tags=list(feed.tags or []),
|
tags=list(feed.tags or []),
|
||||||
check_interval=cast(int, feed.check_interval),
|
check_interval=cast(int, feed.check_interval),
|
||||||
last_checked_at=feed.last_checked_at.isoformat() if feed.last_checked_at else None,
|
last_checked_at=feed.last_checked_at.isoformat()
|
||||||
|
if feed.last_checked_at
|
||||||
|
else None,
|
||||||
active=cast(bool, feed.active),
|
active=cast(bool, feed.active),
|
||||||
created_at=feed.created_at.isoformat() if feed.created_at else "",
|
created_at=feed.created_at.isoformat() if feed.created_at else "",
|
||||||
updated_at=feed.updated_at.isoformat() if feed.updated_at else "",
|
updated_at=feed.updated_at.isoformat() if feed.updated_at else "",
|
||||||
@ -171,13 +173,16 @@ def trigger_sync(
|
|||||||
db: Session = Depends(get_session),
|
db: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Manually trigger a sync for an article feed."""
|
"""Manually trigger a sync for an article feed."""
|
||||||
from memory.workers.tasks.blogs import sync_article_feed
|
from memory.common.celery_app import app, SYNC_ARTICLE_FEED
|
||||||
|
|
||||||
feed = db.get(ArticleFeed, feed_id)
|
feed = db.get(ArticleFeed, feed_id)
|
||||||
if not feed:
|
if not feed:
|
||||||
raise HTTPException(status_code=404, detail="Feed not found")
|
raise HTTPException(status_code=404, detail="Feed not found")
|
||||||
|
|
||||||
task = sync_article_feed.delay(feed_id)
|
task = app.send_task(
|
||||||
|
SYNC_ARTICLE_FEED,
|
||||||
|
args=[feed_id],
|
||||||
|
)
|
||||||
|
|
||||||
return {"task_id": task.id, "status": "scheduled"}
|
return {"task_id": task.id, "status": "scheduled"}
|
||||||
|
|
||||||
|
|||||||
303
src/memory/api/calendar_accounts.py
Normal file
303
src/memory/api/calendar_accounts.py
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
"""API endpoints for Calendar Account management."""
|
||||||
|
|
||||||
|
from typing import Literal, cast
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from memory.api.auth import get_current_user
|
||||||
|
from memory.common.calendar import get_events_in_range, parse_date_range
|
||||||
|
from memory.common.db.connection import get_session
|
||||||
|
from memory.common.db.models import User
|
||||||
|
from memory.common.db.models.sources import CalendarAccount, GoogleAccount
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/calendar-accounts", tags=["calendar-accounts"])
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarAccountCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
calendar_type: Literal["caldav", "google"]
|
||||||
|
# CalDAV fields
|
||||||
|
caldav_url: str | None = None
|
||||||
|
caldav_username: str | None = None
|
||||||
|
caldav_password: str | None = None
|
||||||
|
# Google Calendar fields
|
||||||
|
google_account_id: int | None = None
|
||||||
|
# Common fields
|
||||||
|
calendar_ids: list[str] = []
|
||||||
|
tags: list[str] = []
|
||||||
|
check_interval: int = 15 # Minutes
|
||||||
|
sync_past_days: int = 30
|
||||||
|
sync_future_days: int = 90
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarAccountUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
caldav_url: str | None = None
|
||||||
|
caldav_username: str | None = None
|
||||||
|
caldav_password: str | None = None
|
||||||
|
google_account_id: int | None = None
|
||||||
|
calendar_ids: list[str] | None = None
|
||||||
|
tags: list[str] | None = None
|
||||||
|
check_interval: int | None = None
|
||||||
|
sync_past_days: int | None = None
|
||||||
|
sync_future_days: int | None = None
|
||||||
|
active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class GoogleAccountInfo(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
email: str
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarEventResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
event_title: str
|
||||||
|
start_time: str
|
||||||
|
end_time: str | None
|
||||||
|
all_day: bool
|
||||||
|
location: str | None
|
||||||
|
calendar_name: str | None
|
||||||
|
recurrence_rule: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarAccountResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
calendar_type: str
|
||||||
|
caldav_url: str | None
|
||||||
|
caldav_username: str | None
|
||||||
|
google_account_id: int | None
|
||||||
|
google_account: GoogleAccountInfo | None
|
||||||
|
calendar_ids: list[str]
|
||||||
|
tags: list[str]
|
||||||
|
check_interval: int
|
||||||
|
sync_past_days: int
|
||||||
|
sync_future_days: int
|
||||||
|
last_sync_at: str | None
|
||||||
|
sync_error: str | None
|
||||||
|
active: bool
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
def account_to_response(account: CalendarAccount) -> CalendarAccountResponse:
|
||||||
|
"""Convert a CalendarAccount model to a response model."""
|
||||||
|
google_info = None
|
||||||
|
if account.google_account:
|
||||||
|
google_info = GoogleAccountInfo(
|
||||||
|
id=cast(int, account.google_account.id),
|
||||||
|
name=cast(str, account.google_account.name),
|
||||||
|
email=cast(str, account.google_account.email),
|
||||||
|
)
|
||||||
|
|
||||||
|
return CalendarAccountResponse(
|
||||||
|
id=cast(int, account.id),
|
||||||
|
name=cast(str, account.name),
|
||||||
|
calendar_type=cast(str, account.calendar_type),
|
||||||
|
caldav_url=cast(str | None, account.caldav_url),
|
||||||
|
caldav_username=cast(str | None, account.caldav_username),
|
||||||
|
google_account_id=cast(int | None, account.google_account_id),
|
||||||
|
google_account=google_info,
|
||||||
|
calendar_ids=list(account.calendar_ids or []),
|
||||||
|
tags=list(account.tags or []),
|
||||||
|
check_interval=cast(int, account.check_interval),
|
||||||
|
sync_past_days=cast(int, account.sync_past_days),
|
||||||
|
sync_future_days=cast(int, account.sync_future_days),
|
||||||
|
last_sync_at=account.last_sync_at.isoformat() if account.last_sync_at else None,
|
||||||
|
sync_error=cast(str | None, account.sync_error),
|
||||||
|
active=cast(bool, account.active),
|
||||||
|
created_at=account.created_at.isoformat() if account.created_at else "",
|
||||||
|
updated_at=account.updated_at.isoformat() if account.updated_at else "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_accounts(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
) -> list[CalendarAccountResponse]:
|
||||||
|
"""List all calendar accounts."""
|
||||||
|
accounts = db.query(CalendarAccount).all()
|
||||||
|
return [account_to_response(account) for account in accounts]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
def create_account(
|
||||||
|
data: CalendarAccountCreate,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
) -> CalendarAccountResponse:
|
||||||
|
"""Create a new calendar account."""
|
||||||
|
# Validate based on type
|
||||||
|
if data.calendar_type == "caldav":
|
||||||
|
if not data.caldav_url or not data.caldav_username or not data.caldav_password:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="CalDAV accounts require caldav_url, caldav_username, and caldav_password",
|
||||||
|
)
|
||||||
|
elif data.calendar_type == "google":
|
||||||
|
if not data.google_account_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Google Calendar accounts require google_account_id",
|
||||||
|
)
|
||||||
|
# Verify the Google account exists
|
||||||
|
google_account = db.get(GoogleAccount, data.google_account_id)
|
||||||
|
if not google_account:
|
||||||
|
raise HTTPException(status_code=400, detail="Google account not found")
|
||||||
|
|
||||||
|
account = CalendarAccount(
|
||||||
|
name=data.name,
|
||||||
|
calendar_type=data.calendar_type,
|
||||||
|
caldav_url=data.caldav_url,
|
||||||
|
caldav_username=data.caldav_username,
|
||||||
|
caldav_password=data.caldav_password,
|
||||||
|
google_account_id=data.google_account_id,
|
||||||
|
calendar_ids=data.calendar_ids,
|
||||||
|
tags=data.tags,
|
||||||
|
check_interval=data.check_interval,
|
||||||
|
sync_past_days=data.sync_past_days,
|
||||||
|
sync_future_days=data.sync_future_days,
|
||||||
|
)
|
||||||
|
db.add(account)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(account)
|
||||||
|
|
||||||
|
return account_to_response(account)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{account_id}")
|
||||||
|
def get_account(
|
||||||
|
account_id: int,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
) -> CalendarAccountResponse:
|
||||||
|
"""Get a single calendar account."""
|
||||||
|
account = db.get(CalendarAccount, account_id)
|
||||||
|
if not account:
|
||||||
|
raise HTTPException(status_code=404, detail="Account not found")
|
||||||
|
return account_to_response(account)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{account_id}")
|
||||||
|
def update_account(
|
||||||
|
account_id: int,
|
||||||
|
updates: CalendarAccountUpdate,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
) -> CalendarAccountResponse:
|
||||||
|
"""Update a calendar account."""
|
||||||
|
account = db.get(CalendarAccount, account_id)
|
||||||
|
if not account:
|
||||||
|
raise HTTPException(status_code=404, detail="Account not found")
|
||||||
|
|
||||||
|
if updates.name is not None:
|
||||||
|
account.name = updates.name
|
||||||
|
if updates.caldav_url is not None:
|
||||||
|
account.caldav_url = updates.caldav_url
|
||||||
|
if updates.caldav_username is not None:
|
||||||
|
account.caldav_username = updates.caldav_username
|
||||||
|
if updates.caldav_password is not None:
|
||||||
|
account.caldav_password = updates.caldav_password
|
||||||
|
if updates.google_account_id is not None:
|
||||||
|
# Verify the Google account exists
|
||||||
|
google_account = db.get(GoogleAccount, updates.google_account_id)
|
||||||
|
if not google_account:
|
||||||
|
raise HTTPException(status_code=400, detail="Google account not found")
|
||||||
|
account.google_account_id = updates.google_account_id
|
||||||
|
if updates.calendar_ids is not None:
|
||||||
|
account.calendar_ids = updates.calendar_ids
|
||||||
|
if updates.tags is not None:
|
||||||
|
account.tags = updates.tags
|
||||||
|
if updates.check_interval is not None:
|
||||||
|
account.check_interval = updates.check_interval
|
||||||
|
if updates.sync_past_days is not None:
|
||||||
|
account.sync_past_days = updates.sync_past_days
|
||||||
|
if updates.sync_future_days is not None:
|
||||||
|
account.sync_future_days = updates.sync_future_days
|
||||||
|
if updates.active is not None:
|
||||||
|
account.active = updates.active
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(account)
|
||||||
|
|
||||||
|
return account_to_response(account)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{account_id}")
|
||||||
|
def delete_account(
|
||||||
|
account_id: int,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Delete a calendar account."""
|
||||||
|
account = db.get(CalendarAccount, account_id)
|
||||||
|
if not account:
|
||||||
|
raise HTTPException(status_code=404, detail="Account not found")
|
||||||
|
|
||||||
|
db.delete(account)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{account_id}/sync")
|
||||||
|
def trigger_sync(
|
||||||
|
account_id: int,
|
||||||
|
force_full: bool = False,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Manually trigger a sync for a calendar account."""
|
||||||
|
from memory.common.celery_app import app, SYNC_CALENDAR_ACCOUNT
|
||||||
|
|
||||||
|
account = db.get(CalendarAccount, account_id)
|
||||||
|
if not account:
|
||||||
|
raise HTTPException(status_code=404, detail="Account not found")
|
||||||
|
|
||||||
|
task = app.send_task(
|
||||||
|
SYNC_CALENDAR_ACCOUNT,
|
||||||
|
args=[account_id],
|
||||||
|
kwargs={"force_full": force_full},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"task_id": task.id, "status": "scheduled"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events/upcoming")
|
||||||
|
def get_upcoming_events(
|
||||||
|
days: int = Query(default=7, ge=1, le=365),
|
||||||
|
limit: int = Query(default=10, ge=1, le=200),
|
||||||
|
start_date: str | None = Query(default=None, description="ISO format start date"),
|
||||||
|
end_date: str | None = Query(default=None, description="ISO format end date"),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
) -> list[CalendarEventResponse]:
|
||||||
|
"""Get calendar events within a date range.
|
||||||
|
|
||||||
|
If start_date/end_date provided, uses those. Otherwise uses days from now.
|
||||||
|
Expands recurring events to show future occurrences.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
range_start, range_end = parse_date_range(start_date, end_date, days)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
events = get_events_in_range(db, range_start, range_end, limit)
|
||||||
|
|
||||||
|
return [
|
||||||
|
CalendarEventResponse(
|
||||||
|
id=e["id"],
|
||||||
|
event_title=e["event_title"],
|
||||||
|
start_time=e["start_time"],
|
||||||
|
end_time=e["end_time"],
|
||||||
|
all_day=e["all_day"],
|
||||||
|
location=e["location"],
|
||||||
|
calendar_name=e["calendar_name"],
|
||||||
|
recurrence_rule=e["recurrence_rule"],
|
||||||
|
)
|
||||||
|
for e in events
|
||||||
|
]
|
||||||
@ -192,13 +192,17 @@ def trigger_sync(
|
|||||||
db: Session = Depends(get_session),
|
db: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Manually trigger a sync for an email account."""
|
"""Manually trigger a sync for an email account."""
|
||||||
from memory.workers.tasks.email import sync_account
|
from memory.common.celery_app import app, SYNC_ACCOUNT
|
||||||
|
|
||||||
account = db.get(EmailAccount, account_id)
|
account = db.get(EmailAccount, account_id)
|
||||||
if not account:
|
if not account:
|
||||||
raise HTTPException(status_code=404, detail="Account not found")
|
raise HTTPException(status_code=404, detail="Account not found")
|
||||||
|
|
||||||
task = sync_account.delay(account_id, since_date=since_date)
|
task = app.send_task(
|
||||||
|
SYNC_ACCOUNT,
|
||||||
|
args=[account_id],
|
||||||
|
kwargs={"since_date": since_date},
|
||||||
|
)
|
||||||
|
|
||||||
return {"task_id": task.id, "status": "scheduled"}
|
return {"task_id": task.id, "status": "scheduled"}
|
||||||
|
|
||||||
|
|||||||
@ -124,7 +124,9 @@ def repo_to_response(repo: GithubRepo) -> GithubRepoResponse:
|
|||||||
check_interval=cast(int, repo.check_interval),
|
check_interval=cast(int, repo.check_interval),
|
||||||
full_sync_interval=cast(int, repo.full_sync_interval),
|
full_sync_interval=cast(int, repo.full_sync_interval),
|
||||||
last_sync_at=repo.last_sync_at.isoformat() if repo.last_sync_at else None,
|
last_sync_at=repo.last_sync_at.isoformat() if repo.last_sync_at else None,
|
||||||
last_full_sync_at=repo.last_full_sync_at.isoformat() if repo.last_full_sync_at else None,
|
last_full_sync_at=repo.last_full_sync_at.isoformat()
|
||||||
|
if repo.last_full_sync_at
|
||||||
|
else None,
|
||||||
active=cast(bool, repo.active),
|
active=cast(bool, repo.active),
|
||||||
created_at=repo.created_at.isoformat() if repo.created_at else "",
|
created_at=repo.created_at.isoformat() if repo.created_at else "",
|
||||||
)
|
)
|
||||||
@ -432,7 +434,7 @@ def trigger_repo_sync(
|
|||||||
db: Session = Depends(get_session),
|
db: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Manually trigger a sync for a repo."""
|
"""Manually trigger a sync for a repo."""
|
||||||
from memory.workers.tasks.github import sync_github_repo
|
from memory.common.celery_app import app, SYNC_GITHUB_REPO
|
||||||
|
|
||||||
repo = (
|
repo = (
|
||||||
db.query(GithubRepo)
|
db.query(GithubRepo)
|
||||||
@ -442,6 +444,10 @@ def trigger_repo_sync(
|
|||||||
if not repo:
|
if not repo:
|
||||||
raise HTTPException(status_code=404, detail="Repo not found")
|
raise HTTPException(status_code=404, detail="Repo not found")
|
||||||
|
|
||||||
task = sync_github_repo.delay(repo_id, force_full=force_full)
|
task = app.send_task(
|
||||||
|
SYNC_GITHUB_REPO,
|
||||||
|
args=[repo_id],
|
||||||
|
kwargs={"force_full": force_full},
|
||||||
|
)
|
||||||
|
|
||||||
return {"task_id": task.id, "status": "scheduled"}
|
return {"task_id": task.id, "status": "scheduled"}
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
# Allow Google to return additional scopes (like 'openid') without raising an error
|
# Allow Google to return additional scopes (like 'openid') without raising an error
|
||||||
@ -17,7 +16,11 @@ from sqlalchemy.orm import Session
|
|||||||
from memory.common import settings
|
from memory.common import settings
|
||||||
from memory.common.db.connection import get_session, make_session
|
from memory.common.db.connection import get_session, make_session
|
||||||
from memory.common.db.models import User
|
from memory.common.db.models import User
|
||||||
from memory.common.db.models.sources import GoogleAccount, GoogleFolder, GoogleOAuthConfig
|
from memory.common.db.models.sources import (
|
||||||
|
GoogleAccount,
|
||||||
|
GoogleFolder,
|
||||||
|
GoogleOAuthConfig,
|
||||||
|
)
|
||||||
from memory.api.auth import get_current_user
|
from memory.api.auth import get_current_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/google-drive", tags=["google-drive"])
|
router = APIRouter(prefix="/google-drive", tags=["google-drive"])
|
||||||
@ -25,7 +28,11 @@ router = APIRouter(prefix="/google-drive", tags=["google-drive"])
|
|||||||
|
|
||||||
def get_oauth_config(session: Session) -> GoogleOAuthConfig:
|
def get_oauth_config(session: Session) -> GoogleOAuthConfig:
|
||||||
"""Get the OAuth config from database, falling back to env vars if not found."""
|
"""Get the OAuth config from database, falling back to env vars if not found."""
|
||||||
config = session.query(GoogleOAuthConfig).filter(GoogleOAuthConfig.name == "default").first()
|
config = (
|
||||||
|
session.query(GoogleOAuthConfig)
|
||||||
|
.filter(GoogleOAuthConfig.name == "default")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
if config:
|
if config:
|
||||||
return config
|
return config
|
||||||
|
|
||||||
@ -116,6 +123,7 @@ class OAuthConfigResponse(BaseModel):
|
|||||||
# Browse endpoint models
|
# Browse endpoint models
|
||||||
class DriveItem(BaseModel):
|
class DriveItem(BaseModel):
|
||||||
"""A file or folder in Google Drive."""
|
"""A file or folder in Google Drive."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
mime_type: str
|
mime_type: str
|
||||||
@ -126,6 +134,7 @@ class DriveItem(BaseModel):
|
|||||||
|
|
||||||
class BrowseResponse(BaseModel):
|
class BrowseResponse(BaseModel):
|
||||||
"""Response from browsing a Google Drive folder."""
|
"""Response from browsing a Google Drive folder."""
|
||||||
|
|
||||||
folder_id: str
|
folder_id: str
|
||||||
folder_name: str
|
folder_name: str
|
||||||
parent_id: str | None = None
|
parent_id: str | None = None
|
||||||
@ -151,15 +160,21 @@ async def upload_oauth_config(
|
|||||||
raise HTTPException(status_code=400, detail=f"Invalid JSON file: {e}")
|
raise HTTPException(status_code=400, detail=f"Invalid JSON file: {e}")
|
||||||
|
|
||||||
# Check if config already exists
|
# Check if config already exists
|
||||||
existing = db.query(GoogleOAuthConfig).filter(GoogleOAuthConfig.name == name).first()
|
existing = (
|
||||||
|
db.query(GoogleOAuthConfig).filter(GoogleOAuthConfig.name == name).first()
|
||||||
|
)
|
||||||
if existing:
|
if existing:
|
||||||
# Update existing config
|
# Update existing config
|
||||||
creds = json_data.get("web") or json_data.get("installed") or json_data
|
creds = json_data.get("web") or json_data.get("installed") or json_data
|
||||||
existing.client_id = creds["client_id"]
|
existing.client_id = creds["client_id"]
|
||||||
existing.client_secret = creds["client_secret"]
|
existing.client_secret = creds["client_secret"]
|
||||||
existing.project_id = creds.get("project_id")
|
existing.project_id = creds.get("project_id")
|
||||||
existing.auth_uri = creds.get("auth_uri", "https://accounts.google.com/o/oauth2/auth")
|
existing.auth_uri = creds.get(
|
||||||
existing.token_uri = creds.get("token_uri", "https://oauth2.googleapis.com/token")
|
"auth_uri", "https://accounts.google.com/o/oauth2/auth"
|
||||||
|
)
|
||||||
|
existing.token_uri = creds.get(
|
||||||
|
"token_uri", "https://oauth2.googleapis.com/token"
|
||||||
|
)
|
||||||
existing.redirect_uris = creds.get("redirect_uris", [])
|
existing.redirect_uris = creds.get("redirect_uris", [])
|
||||||
existing.javascript_origins = creds.get("javascript_origins", [])
|
existing.javascript_origins = creds.get("javascript_origins", [])
|
||||||
db.commit()
|
db.commit()
|
||||||
@ -188,7 +203,9 @@ def get_config(
|
|||||||
db: Session = Depends(get_session),
|
db: Session = Depends(get_session),
|
||||||
) -> OAuthConfigResponse | None:
|
) -> OAuthConfigResponse | None:
|
||||||
"""Get current OAuth configuration (without secrets)."""
|
"""Get current OAuth configuration (without secrets)."""
|
||||||
config = db.query(GoogleOAuthConfig).filter(GoogleOAuthConfig.name == "default").first()
|
config = (
|
||||||
|
db.query(GoogleOAuthConfig).filter(GoogleOAuthConfig.name == "default").first()
|
||||||
|
)
|
||||||
if not config:
|
if not config:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -208,7 +225,9 @@ def delete_config(
|
|||||||
db: Session = Depends(get_session),
|
db: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Delete OAuth configuration."""
|
"""Delete OAuth configuration."""
|
||||||
config = db.query(GoogleOAuthConfig).filter(GoogleOAuthConfig.name == "default").first()
|
config = (
|
||||||
|
db.query(GoogleOAuthConfig).filter(GoogleOAuthConfig.name == "default").first()
|
||||||
|
)
|
||||||
if not config:
|
if not config:
|
||||||
raise HTTPException(status_code=404, detail="Config not found")
|
raise HTTPException(status_code=404, detail="Config not found")
|
||||||
|
|
||||||
@ -426,11 +445,15 @@ def browse_folder(
|
|||||||
else:
|
else:
|
||||||
# Get folder info for a specific folder
|
# Get folder info for a specific folder
|
||||||
try:
|
try:
|
||||||
folder_info = service.files().get(
|
folder_info = (
|
||||||
|
service.files()
|
||||||
|
.get(
|
||||||
fileId=folder_id,
|
fileId=folder_id,
|
||||||
fields="name, parents",
|
fields="name, parents",
|
||||||
supportsAllDrives=True,
|
supportsAllDrives=True,
|
||||||
).execute()
|
)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
folder_name = folder_info.get("name", folder_id)
|
folder_name = folder_info.get("name", folder_id)
|
||||||
parents = folder_info.get("parents", [])
|
parents = folder_info.get("parents", [])
|
||||||
parent_id = parents[0] if parents else None
|
parent_id = parents[0] if parents else None
|
||||||
@ -439,7 +462,9 @@ def browse_folder(
|
|||||||
query = f"'{folder_id}' in parents and trashed=false"
|
query = f"'{folder_id}' in parents and trashed=false"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = service.files().list(
|
response = (
|
||||||
|
service.files()
|
||||||
|
.list(
|
||||||
q=query,
|
q=query,
|
||||||
spaces="drive",
|
spaces="drive",
|
||||||
fields="nextPageToken, files(id, name, mimeType, size, modifiedTime)",
|
fields="nextPageToken, files(id, name, mimeType, size, modifiedTime)",
|
||||||
@ -448,7 +473,9 @@ def browse_folder(
|
|||||||
orderBy="folder,name", # Folders first, then by name
|
orderBy="folder,name", # Folders first, then by name
|
||||||
includeItemsFromAllDrives=True,
|
includeItemsFromAllDrives=True,
|
||||||
supportsAllDrives=True,
|
supportsAllDrives=True,
|
||||||
).execute()
|
)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to list folder: {e}")
|
raise HTTPException(status_code=500, detail=f"Failed to list folder: {e}")
|
||||||
|
|
||||||
@ -457,25 +484,29 @@ def browse_folder(
|
|||||||
|
|
||||||
# Add "Shared with me" as a virtual folder when at root
|
# Add "Shared with me" as a virtual folder when at root
|
||||||
if folder_id == "root":
|
if folder_id == "root":
|
||||||
items.append(DriveItem(
|
items.append(
|
||||||
|
DriveItem(
|
||||||
id="shared",
|
id="shared",
|
||||||
name="Shared with me",
|
name="Shared with me",
|
||||||
mime_type="application/vnd.google-apps.folder",
|
mime_type="application/vnd.google-apps.folder",
|
||||||
is_folder=True,
|
is_folder=True,
|
||||||
size=None,
|
size=None,
|
||||||
modified_at=None,
|
modified_at=None,
|
||||||
))
|
)
|
||||||
|
)
|
||||||
|
|
||||||
for file in response.get("files", []):
|
for file in response.get("files", []):
|
||||||
is_folder = file["mimeType"] == "application/vnd.google-apps.folder"
|
is_folder = file["mimeType"] == "application/vnd.google-apps.folder"
|
||||||
items.append(DriveItem(
|
items.append(
|
||||||
|
DriveItem(
|
||||||
id=file["id"],
|
id=file["id"],
|
||||||
name=file["name"],
|
name=file["name"],
|
||||||
mime_type=file["mimeType"],
|
mime_type=file["mimeType"],
|
||||||
is_folder=is_folder,
|
is_folder=is_folder,
|
||||||
size=file.get("size"),
|
size=file.get("size"),
|
||||||
modified_at=file.get("modifiedTime"),
|
modified_at=file.get("modifiedTime"),
|
||||||
))
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return BrowseResponse(
|
return BrowseResponse(
|
||||||
folder_id=folder_id,
|
folder_id=folder_id,
|
||||||
@ -586,9 +617,7 @@ def update_folder(
|
|||||||
include_shared=cast(bool, folder.include_shared),
|
include_shared=cast(bool, folder.include_shared),
|
||||||
tags=cast(list[str], folder.tags) or [],
|
tags=cast(list[str], folder.tags) or [],
|
||||||
check_interval=cast(int, folder.check_interval),
|
check_interval=cast(int, folder.check_interval),
|
||||||
last_sync_at=(
|
last_sync_at=(folder.last_sync_at.isoformat() if folder.last_sync_at else None),
|
||||||
folder.last_sync_at.isoformat() if folder.last_sync_at else None
|
|
||||||
),
|
|
||||||
active=cast(bool, folder.active),
|
active=cast(bool, folder.active),
|
||||||
exclude_folder_ids=cast(list[str], folder.exclude_folder_ids) or [],
|
exclude_folder_ids=cast(list[str], folder.exclude_folder_ids) or [],
|
||||||
)
|
)
|
||||||
@ -629,7 +658,7 @@ def trigger_sync(
|
|||||||
db: Session = Depends(get_session),
|
db: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Manually trigger a sync for a folder."""
|
"""Manually trigger a sync for a folder."""
|
||||||
from memory.workers.tasks.google_drive import sync_google_folder
|
from memory.common.celery_app import app, SYNC_GOOGLE_FOLDER
|
||||||
|
|
||||||
folder = (
|
folder = (
|
||||||
db.query(GoogleFolder)
|
db.query(GoogleFolder)
|
||||||
@ -643,7 +672,11 @@ def trigger_sync(
|
|||||||
if not folder:
|
if not folder:
|
||||||
raise HTTPException(status_code=404, detail="Folder not found")
|
raise HTTPException(status_code=404, detail="Folder not found")
|
||||||
|
|
||||||
task = sync_google_folder.delay(folder.id, force_full=force_full)
|
task = app.send_task(
|
||||||
|
SYNC_GOOGLE_FOLDER,
|
||||||
|
args=[folder.id],
|
||||||
|
kwargs={"force_full": force_full},
|
||||||
|
)
|
||||||
|
|
||||||
return {"task_id": task.id, "status": "scheduled"}
|
return {"task_id": task.id, "status": "scheduled"}
|
||||||
|
|
||||||
|
|||||||
168
src/memory/common/calendar.py
Normal file
168
src/memory/common/calendar.py
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
"""
|
||||||
|
Common calendar utilities for event expansion and querying.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
from dateutil.rrule import rrulestr
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from memory.common.db.models import CalendarEvent
|
||||||
|
|
||||||
|
|
||||||
|
class EventDict(TypedDict):
|
||||||
|
id: int
|
||||||
|
event_title: str
|
||||||
|
start_time: str
|
||||||
|
end_time: str | None
|
||||||
|
all_day: bool
|
||||||
|
location: str | None
|
||||||
|
calendar_name: str | None
|
||||||
|
recurrence_rule: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def expand_recurring_event(
|
||||||
|
event: CalendarEvent,
|
||||||
|
start_range: datetime,
|
||||||
|
end_range: datetime,
|
||||||
|
) -> list[tuple[datetime, datetime | None]]:
|
||||||
|
"""Expand a recurring event into occurrences within the given range.
|
||||||
|
|
||||||
|
Returns list of (start_time, end_time) tuples for each occurrence.
|
||||||
|
"""
|
||||||
|
if not event.recurrence_rule or not event.start_time:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
rule = rrulestr(
|
||||||
|
f"RRULE:{event.recurrence_rule}",
|
||||||
|
dtstart=event.start_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
duration = None
|
||||||
|
if event.end_time and event.start_time:
|
||||||
|
duration = event.end_time - event.start_time
|
||||||
|
|
||||||
|
occurrences = []
|
||||||
|
for occ_start in rule.between(start_range, end_range, inc=True):
|
||||||
|
occ_end = occ_start + duration if duration else None
|
||||||
|
occurrences.append((occ_start, occ_end))
|
||||||
|
|
||||||
|
return occurrences
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def event_to_dict(
|
||||||
|
event: CalendarEvent,
|
||||||
|
start_time: datetime | None = None,
|
||||||
|
end_time: datetime | None = None,
|
||||||
|
) -> EventDict:
|
||||||
|
"""Convert a CalendarEvent to a dictionary.
|
||||||
|
|
||||||
|
If start_time/end_time are provided, they override the event's times
|
||||||
|
(used for recurring event occurrences).
|
||||||
|
"""
|
||||||
|
st = start_time or event.start_time
|
||||||
|
et = end_time or event.end_time
|
||||||
|
|
||||||
|
return EventDict(
|
||||||
|
id=event.id, # type: ignore
|
||||||
|
event_title=event.event_title or "", # type: ignore
|
||||||
|
start_time=st.isoformat() if st else "",
|
||||||
|
end_time=et.isoformat() if et else None,
|
||||||
|
all_day=event.all_day or False, # type: ignore
|
||||||
|
location=event.location, # type: ignore
|
||||||
|
calendar_name=event.calendar_name, # type: ignore
|
||||||
|
recurrence_rule=event.recurrence_rule, # type: ignore
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_events_in_range(
|
||||||
|
session: Session,
|
||||||
|
start_date: datetime,
|
||||||
|
end_date: datetime,
|
||||||
|
limit: int = 200,
|
||||||
|
) -> list[EventDict]:
|
||||||
|
"""Get all calendar events (including expanded recurring) in a date range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
start_date: Start of the date range (inclusive)
|
||||||
|
end_date: End of the date range (inclusive)
|
||||||
|
limit: Maximum number of events to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of event dictionaries, sorted by start_time
|
||||||
|
"""
|
||||||
|
# Get non-recurring events in range
|
||||||
|
non_recurring = (
|
||||||
|
session.query(CalendarEvent)
|
||||||
|
.filter(
|
||||||
|
CalendarEvent.start_time >= start_date,
|
||||||
|
CalendarEvent.start_time <= end_date,
|
||||||
|
CalendarEvent.recurrence_rule.is_(None),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get all recurring events (they might have occurrences in range)
|
||||||
|
recurring = (
|
||||||
|
session.query(CalendarEvent)
|
||||||
|
.filter(CalendarEvent.recurrence_rule.isnot(None))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
results: list[tuple[datetime, EventDict]] = []
|
||||||
|
|
||||||
|
# Add non-recurring events
|
||||||
|
for e in non_recurring:
|
||||||
|
if e.start_time:
|
||||||
|
results.append((e.start_time, event_to_dict(e)))
|
||||||
|
|
||||||
|
# Expand recurring events
|
||||||
|
for e in recurring:
|
||||||
|
for occ_start, occ_end in expand_recurring_event(e, start_date, end_date):
|
||||||
|
results.append((occ_start, event_to_dict(e, occ_start, occ_end)))
|
||||||
|
|
||||||
|
# Sort by start time and apply limit
|
||||||
|
results.sort(key=lambda x: x[0])
|
||||||
|
return [r[1] for r in results[:limit]]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date_range(
|
||||||
|
start_date: str | None = None,
|
||||||
|
end_date: str | None = None,
|
||||||
|
days: int = 7,
|
||||||
|
) -> tuple[datetime, datetime]:
|
||||||
|
"""Parse date range from string inputs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: ISO format start date (defaults to now)
|
||||||
|
end_date: ISO format end date (defaults to start + days)
|
||||||
|
days: Number of days if end_date not specified
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (start_datetime, end_datetime)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If date format is invalid
|
||||||
|
"""
|
||||||
|
if start_date:
|
||||||
|
try:
|
||||||
|
range_start = datetime.fromisoformat(start_date.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Invalid start_date format: {start_date}")
|
||||||
|
else:
|
||||||
|
range_start = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
try:
|
||||||
|
range_end = datetime.fromisoformat(end_date.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Invalid end_date format: {end_date}")
|
||||||
|
else:
|
||||||
|
range_end = range_start + timedelta(days=days)
|
||||||
|
|
||||||
|
return range_start, range_end
|
||||||
@ -19,6 +19,7 @@ GITHUB_ROOT = "memory.workers.tasks.github"
|
|||||||
PEOPLE_ROOT = "memory.workers.tasks.people"
|
PEOPLE_ROOT = "memory.workers.tasks.people"
|
||||||
PROACTIVE_ROOT = "memory.workers.tasks.proactive"
|
PROACTIVE_ROOT = "memory.workers.tasks.proactive"
|
||||||
GOOGLE_ROOT = "memory.workers.tasks.google_drive"
|
GOOGLE_ROOT = "memory.workers.tasks.google_drive"
|
||||||
|
CALENDAR_ROOT = "memory.workers.tasks.calendar"
|
||||||
ADD_DISCORD_MESSAGE = f"{DISCORD_ROOT}.add_discord_message"
|
ADD_DISCORD_MESSAGE = f"{DISCORD_ROOT}.add_discord_message"
|
||||||
EDIT_DISCORD_MESSAGE = f"{DISCORD_ROOT}.edit_discord_message"
|
EDIT_DISCORD_MESSAGE = f"{DISCORD_ROOT}.edit_discord_message"
|
||||||
PROCESS_DISCORD_MESSAGE = f"{DISCORD_ROOT}.process_discord_message"
|
PROCESS_DISCORD_MESSAGE = f"{DISCORD_ROOT}.process_discord_message"
|
||||||
@ -83,6 +84,11 @@ SYNC_GOOGLE_FOLDER = f"{GOOGLE_ROOT}.sync_google_folder"
|
|||||||
SYNC_GOOGLE_DOC = f"{GOOGLE_ROOT}.sync_google_doc"
|
SYNC_GOOGLE_DOC = f"{GOOGLE_ROOT}.sync_google_doc"
|
||||||
SYNC_ALL_GOOGLE_ACCOUNTS = f"{GOOGLE_ROOT}.sync_all_google_accounts"
|
SYNC_ALL_GOOGLE_ACCOUNTS = f"{GOOGLE_ROOT}.sync_all_google_accounts"
|
||||||
|
|
||||||
|
# Calendar tasks
|
||||||
|
SYNC_CALENDAR_ACCOUNT = f"{CALENDAR_ROOT}.sync_calendar_account"
|
||||||
|
SYNC_CALENDAR_EVENT = f"{CALENDAR_ROOT}.sync_calendar_event"
|
||||||
|
SYNC_ALL_CALENDARS = f"{CALENDAR_ROOT}.sync_all_calendars"
|
||||||
|
|
||||||
|
|
||||||
def get_broker_url() -> str:
|
def get_broker_url() -> str:
|
||||||
protocol = settings.CELERY_BROKER_TYPE
|
protocol = settings.CELERY_BROKER_TYPE
|
||||||
@ -142,6 +148,7 @@ app.conf.update(
|
|||||||
f"{PEOPLE_ROOT}.*": {"queue": f"{settings.CELERY_QUEUE_PREFIX}-people"},
|
f"{PEOPLE_ROOT}.*": {"queue": f"{settings.CELERY_QUEUE_PREFIX}-people"},
|
||||||
f"{PROACTIVE_ROOT}.*": {"queue": f"{settings.CELERY_QUEUE_PREFIX}-discord"},
|
f"{PROACTIVE_ROOT}.*": {"queue": f"{settings.CELERY_QUEUE_PREFIX}-discord"},
|
||||||
f"{GOOGLE_ROOT}.*": {"queue": f"{settings.CELERY_QUEUE_PREFIX}-google"},
|
f"{GOOGLE_ROOT}.*": {"queue": f"{settings.CELERY_QUEUE_PREFIX}-google"},
|
||||||
|
f"{CALENDAR_ROOT}.*": {"queue": f"{settings.CELERY_QUEUE_PREFIX}-calendar"},
|
||||||
},
|
},
|
||||||
beat_schedule={
|
beat_schedule={
|
||||||
"sync-github-repos-hourly": {
|
"sync-github-repos-hourly": {
|
||||||
@ -156,6 +163,10 @@ app.conf.update(
|
|||||||
"task": SYNC_ALL_GOOGLE_ACCOUNTS,
|
"task": SYNC_ALL_GOOGLE_ACCOUNTS,
|
||||||
"schedule": crontab(minute=30), # Every hour at :30
|
"schedule": crontab(minute=30), # Every hour at :30
|
||||||
},
|
},
|
||||||
|
"sync-calendars-hourly": {
|
||||||
|
"task": SYNC_ALL_CALENDARS,
|
||||||
|
"schedule": crontab(minute=45), # Every hour at :45
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -23,6 +23,8 @@ from memory.common.db.models.source_items import (
|
|||||||
MiscDoc,
|
MiscDoc,
|
||||||
Note,
|
Note,
|
||||||
GoogleDoc,
|
GoogleDoc,
|
||||||
|
Task,
|
||||||
|
CalendarEvent,
|
||||||
MailMessagePayload,
|
MailMessagePayload,
|
||||||
EmailAttachmentPayload,
|
EmailAttachmentPayload,
|
||||||
AgentObservationPayload,
|
AgentObservationPayload,
|
||||||
@ -32,6 +34,8 @@ from memory.common.db.models.source_items import (
|
|||||||
NotePayload,
|
NotePayload,
|
||||||
ForumPostPayload,
|
ForumPostPayload,
|
||||||
GoogleDocPayload,
|
GoogleDocPayload,
|
||||||
|
TaskPayload,
|
||||||
|
CalendarEventPayload,
|
||||||
)
|
)
|
||||||
from memory.common.db.models.discord import (
|
from memory.common.db.models.discord import (
|
||||||
DiscordServer,
|
DiscordServer,
|
||||||
@ -62,6 +66,7 @@ from memory.common.db.models.sources import (
|
|||||||
GoogleOAuthConfig,
|
GoogleOAuthConfig,
|
||||||
GoogleAccount,
|
GoogleAccount,
|
||||||
GoogleFolder,
|
GoogleFolder,
|
||||||
|
CalendarAccount,
|
||||||
)
|
)
|
||||||
from memory.common.db.models.users import (
|
from memory.common.db.models.users import (
|
||||||
User,
|
User,
|
||||||
@ -89,6 +94,8 @@ Payload = (
|
|||||||
| MailMessagePayload
|
| MailMessagePayload
|
||||||
| PersonPayload
|
| PersonPayload
|
||||||
| GoogleDocPayload
|
| GoogleDocPayload
|
||||||
|
| TaskPayload
|
||||||
|
| CalendarEventPayload
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@ -114,6 +121,10 @@ __all__ = [
|
|||||||
"Note",
|
"Note",
|
||||||
"GoogleDoc",
|
"GoogleDoc",
|
||||||
"GoogleDocPayload",
|
"GoogleDocPayload",
|
||||||
|
"Task",
|
||||||
|
"TaskPayload",
|
||||||
|
"CalendarEvent",
|
||||||
|
"CalendarEventPayload",
|
||||||
# Observations
|
# Observations
|
||||||
"ObservationContradiction",
|
"ObservationContradiction",
|
||||||
"ReactionPattern",
|
"ReactionPattern",
|
||||||
@ -123,6 +134,10 @@ __all__ = [
|
|||||||
# People
|
# People
|
||||||
"Person",
|
"Person",
|
||||||
"PersonPayload",
|
"PersonPayload",
|
||||||
|
# Calendar
|
||||||
|
"CalendarAccount",
|
||||||
|
"CalendarEvent",
|
||||||
|
"CalendarEventPayload",
|
||||||
# Sources
|
# Sources
|
||||||
"Book",
|
"Book",
|
||||||
"ArticleFeed",
|
"ArticleFeed",
|
||||||
@ -132,6 +147,9 @@ __all__ = [
|
|||||||
"GoogleOAuthConfig",
|
"GoogleOAuthConfig",
|
||||||
"GoogleAccount",
|
"GoogleAccount",
|
||||||
"GoogleFolder",
|
"GoogleFolder",
|
||||||
|
"CalendarAccount",
|
||||||
|
"CalendarEvent",
|
||||||
|
"CalendarEventPayload",
|
||||||
"DiscordServer",
|
"DiscordServer",
|
||||||
"DiscordChannel",
|
"DiscordChannel",
|
||||||
"DiscordUser",
|
"DiscordUser",
|
||||||
|
|||||||
@ -1302,3 +1302,226 @@ class GoogleDoc(SourceItem):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def get_collections(cls) -> list[str]:
|
def get_collections(cls) -> list[str]:
|
||||||
return ["doc"]
|
return ["doc"]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskPayload(SourceItemPayload):
|
||||||
|
title: Annotated[str, "Title of the task"]
|
||||||
|
due_date: Annotated[str | None, "Due date in ISO format"]
|
||||||
|
priority: Annotated[str | None, "Priority level: low, medium, high, urgent"]
|
||||||
|
status: Annotated[str, "Status: pending, in_progress, done, cancelled"]
|
||||||
|
recurrence: Annotated[str | None, "Recurrence rule (RRULE format)"]
|
||||||
|
source_item_id: Annotated[int | None, "Source item that spawned this task"]
|
||||||
|
|
||||||
|
|
||||||
|
class Task(SourceItem):
|
||||||
|
"""Explicit task/todo item."""
|
||||||
|
|
||||||
|
__tablename__ = "task"
|
||||||
|
|
||||||
|
id = Column(
|
||||||
|
BigInteger, ForeignKey("source_item.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
|
||||||
|
task_title = Column(Text, nullable=False)
|
||||||
|
due_date = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
priority = Column(Text, nullable=True) # low, medium, high, urgent
|
||||||
|
status = Column(Text, nullable=False, server_default="pending")
|
||||||
|
recurrence = Column(Text, nullable=True) # RRULE format for habits
|
||||||
|
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
# Link to source that spawned this task (email, note, etc.)
|
||||||
|
source_item_id = Column(
|
||||||
|
BigInteger, ForeignKey("source_item.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
source_item = relationship(
|
||||||
|
"SourceItem", foreign_keys=[source_item_id], backref="spawned_tasks"
|
||||||
|
)
|
||||||
|
|
||||||
|
__mapper_args__ = {
|
||||||
|
"polymorphic_identity": "task",
|
||||||
|
"inherit_condition": id == SourceItem.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"status IN ('pending', 'in_progress', 'done', 'cancelled')",
|
||||||
|
name="task_status_check",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"priority IS NULL OR priority IN ('low', 'medium', 'high', 'urgent')",
|
||||||
|
name="task_priority_check",
|
||||||
|
),
|
||||||
|
Index("task_due_date_idx", "due_date"),
|
||||||
|
Index("task_status_idx", "status"),
|
||||||
|
Index("task_priority_idx", "priority"),
|
||||||
|
Index("task_source_item_idx", "source_item_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
if not kwargs.get("modality"):
|
||||||
|
kwargs["modality"] = "task"
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
def as_payload(self) -> TaskPayload:
|
||||||
|
return TaskPayload(
|
||||||
|
**super().as_payload(),
|
||||||
|
title=cast(str, self.task_title),
|
||||||
|
due_date=(self.due_date and self.due_date.isoformat() or None),
|
||||||
|
priority=cast(str | None, self.priority),
|
||||||
|
status=cast(str, self.status),
|
||||||
|
recurrence=cast(str | None, self.recurrence),
|
||||||
|
source_item_id=cast(int | None, self.source_item_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_contents(self) -> dict:
|
||||||
|
return {
|
||||||
|
"title": self.task_title,
|
||||||
|
"description": self.content,
|
||||||
|
"due_date": self.due_date and self.due_date.isoformat(),
|
||||||
|
"priority": self.priority,
|
||||||
|
"status": self.status,
|
||||||
|
"recurrence": self.recurrence,
|
||||||
|
"tags": self.tags,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _chunk_contents(self) -> Sequence[extract.DataChunk]:
|
||||||
|
parts = [cast(str, self.task_title)]
|
||||||
|
if self.content:
|
||||||
|
parts.append(cast(str, self.content))
|
||||||
|
if self.due_date:
|
||||||
|
parts.append(f"Due: {self.due_date.isoformat()}")
|
||||||
|
text = "\n\n".join(parts)
|
||||||
|
return extract.extract_text(text, modality="task")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_collections(cls) -> list[str]:
|
||||||
|
return ["task"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def title(self) -> str | None:
|
||||||
|
return cast(str | None, self.task_title)
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarEventPayload(SourceItemPayload):
|
||||||
|
event_title: Annotated[str, "Title of the event"]
|
||||||
|
start_time: Annotated[str, "Start time in ISO format"]
|
||||||
|
end_time: Annotated[str | None, "End time in ISO format"]
|
||||||
|
all_day: Annotated[bool, "Whether this is an all-day event"]
|
||||||
|
location: Annotated[str | None, "Event location"]
|
||||||
|
recurrence_rule: Annotated[str | None, "Recurrence rule (RRULE format)"]
|
||||||
|
calendar_account_id: Annotated[int | None, "Calendar account this event belongs to"]
|
||||||
|
calendar_name: Annotated[str | None, "Name of the calendar"]
|
||||||
|
external_id: Annotated[str | None, "External calendar ID for sync"]
|
||||||
|
event_metadata: Annotated[dict | None, "Additional metadata (attendees, links, etc.)"]
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarEvent(SourceItem):
|
||||||
|
"""Calendar event from external calendar sources (CalDAV, Google, etc.)."""
|
||||||
|
|
||||||
|
__tablename__ = "calendar_event"
|
||||||
|
|
||||||
|
id = Column(
|
||||||
|
BigInteger, ForeignKey("source_item.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Core event fields
|
||||||
|
event_title = Column(Text, nullable=False)
|
||||||
|
start_time = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
end_time = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
all_day = Column(Boolean, default=False, nullable=False)
|
||||||
|
location = Column(Text, nullable=True)
|
||||||
|
recurrence_rule = Column(Text, nullable=True) # RRULE format
|
||||||
|
|
||||||
|
# Sync metadata
|
||||||
|
calendar_account_id = Column(
|
||||||
|
BigInteger, ForeignKey("calendar_accounts.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
calendar_name = Column(Text, nullable=True)
|
||||||
|
external_id = Column(Text, nullable=True) # For dedup/sync
|
||||||
|
|
||||||
|
# Relationship
|
||||||
|
calendar_account = relationship("CalendarAccount", foreign_keys=[calendar_account_id])
|
||||||
|
|
||||||
|
# Flexible metadata (attendees, meeting links, conference info, etc.)
|
||||||
|
event_metadata = Column(JSONB, default=dict)
|
||||||
|
|
||||||
|
__mapper_args__ = {
|
||||||
|
"polymorphic_identity": "calendar_event",
|
||||||
|
}
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("calendar_event_start_idx", "start_time"),
|
||||||
|
Index("calendar_event_end_idx", "end_time"),
|
||||||
|
Index("calendar_event_account_idx", "calendar_account_id"),
|
||||||
|
Index("calendar_event_calendar_idx", "calendar_name"),
|
||||||
|
Index(
|
||||||
|
"calendar_event_external_idx",
|
||||||
|
"calendar_account_id",
|
||||||
|
"external_id",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where="external_id IS NOT NULL",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
if not kwargs.get("modality"):
|
||||||
|
kwargs["modality"] = "calendar"
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
def as_payload(self) -> CalendarEventPayload:
|
||||||
|
return CalendarEventPayload(
|
||||||
|
**super().as_payload(),
|
||||||
|
event_title=cast(str, self.event_title),
|
||||||
|
start_time=cast(datetime, self.start_time).isoformat(),
|
||||||
|
end_time=(self.end_time and self.end_time.isoformat() or None),
|
||||||
|
all_day=cast(bool, self.all_day),
|
||||||
|
location=cast(str | None, self.location),
|
||||||
|
recurrence_rule=cast(str | None, self.recurrence_rule),
|
||||||
|
calendar_account_id=cast(int | None, self.calendar_account_id),
|
||||||
|
calendar_name=cast(str | None, self.calendar_name),
|
||||||
|
external_id=cast(str | None, self.external_id),
|
||||||
|
event_metadata=cast(dict | None, self.event_metadata),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_contents(self) -> dict:
|
||||||
|
return {
|
||||||
|
"title": self.event_title,
|
||||||
|
"description": self.content,
|
||||||
|
"start_time": cast(datetime, self.start_time).isoformat(),
|
||||||
|
"end_time": self.end_time and self.end_time.isoformat(),
|
||||||
|
"all_day": self.all_day,
|
||||||
|
"location": self.location,
|
||||||
|
"calendar": self.calendar_name,
|
||||||
|
"attendees": (self.event_metadata or {}).get("attendees"),
|
||||||
|
"tags": self.tags,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _chunk_contents(self) -> Sequence[extract.DataChunk]:
|
||||||
|
parts = [cast(str, self.event_title)]
|
||||||
|
|
||||||
|
if self.content:
|
||||||
|
parts.append(cast(str, self.content))
|
||||||
|
|
||||||
|
if self.location:
|
||||||
|
parts.append(f"Location: {self.location}")
|
||||||
|
|
||||||
|
metadata = cast(dict | None, self.event_metadata) or {}
|
||||||
|
if attendees := metadata.get("attendees"):
|
||||||
|
if isinstance(attendees, list):
|
||||||
|
parts.append(f"Attendees: {', '.join(str(a) for a in attendees)}")
|
||||||
|
|
||||||
|
if meeting_link := metadata.get("meeting_link"):
|
||||||
|
parts.append(f"Meeting link: {meeting_link}")
|
||||||
|
|
||||||
|
text = "\n\n".join(parts)
|
||||||
|
return extract.extract_text(text, modality="calendar")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_collections(cls) -> list[str]:
|
||||||
|
return ["calendar"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def title(self) -> str | None:
|
||||||
|
return cast(str | None, self.event_title)
|
||||||
|
|||||||
@ -376,3 +376,56 @@ class GoogleFolder(Base):
|
|||||||
UniqueConstraint("account_id", "folder_id", name="unique_folder_per_account"),
|
UniqueConstraint("account_id", "folder_id", name="unique_folder_per_account"),
|
||||||
Index("google_folders_active_idx", "active", "last_sync_at"),
|
Index("google_folders_active_idx", "active", "last_sync_at"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarAccount(Base):
|
||||||
|
"""Calendar source for syncing events (CalDAV, Google Calendar, etc.)."""
|
||||||
|
|
||||||
|
__tablename__ = "calendar_accounts"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True)
|
||||||
|
name = Column(Text, nullable=False) # Display name
|
||||||
|
|
||||||
|
# Calendar type
|
||||||
|
calendar_type = Column(Text, nullable=False) # 'caldav', 'google'
|
||||||
|
|
||||||
|
# For CalDAV (Radicale, etc.)
|
||||||
|
caldav_url = Column(Text, nullable=True) # CalDAV server URL
|
||||||
|
caldav_username = Column(Text, nullable=True)
|
||||||
|
caldav_password = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# For Google Calendar - link to existing GoogleAccount
|
||||||
|
google_account_id = Column(
|
||||||
|
BigInteger, ForeignKey("google_accounts.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Which calendars to sync (empty = all)
|
||||||
|
calendar_ids = Column(ARRAY(Text), nullable=False, server_default="{}")
|
||||||
|
|
||||||
|
# Tags to apply to all events from this account
|
||||||
|
tags = Column(ARRAY(Text), nullable=False, server_default="{}")
|
||||||
|
|
||||||
|
# Sync configuration
|
||||||
|
check_interval = Column(Integer, nullable=False, server_default="15") # Minutes
|
||||||
|
sync_past_days = Column(Integer, nullable=False, server_default="30") # How far back
|
||||||
|
sync_future_days = Column(Integer, nullable=False, server_default="90") # How far ahead
|
||||||
|
last_sync_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
sync_error = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
active = Column(Boolean, nullable=False, server_default="true")
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
google_account = relationship("GoogleAccount", foreign_keys=[google_account_id])
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("calendar_type IN ('caldav', 'google')"),
|
||||||
|
Index("calendar_accounts_active_idx", "active", "last_sync_at"),
|
||||||
|
Index("calendar_accounts_type_idx", "calendar_type"),
|
||||||
|
)
|
||||||
|
|||||||
@ -38,7 +38,7 @@ DB_URL = os.getenv("DATABASE_URL", make_db_url())
|
|||||||
REDIS_HOST = os.getenv("REDIS_HOST", "redis")
|
REDIS_HOST = os.getenv("REDIS_HOST", "redis")
|
||||||
REDIS_PORT = os.getenv("REDIS_PORT", "6379")
|
REDIS_PORT = os.getenv("REDIS_PORT", "6379")
|
||||||
REDIS_DB = os.getenv("REDIS_DB", "0")
|
REDIS_DB = os.getenv("REDIS_DB", "0")
|
||||||
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", None)
|
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD") or None # Treat empty string as None
|
||||||
if REDIS_PASSWORD:
|
if REDIS_PASSWORD:
|
||||||
REDIS_URL = f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}"
|
REDIS_URL = f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}"
|
||||||
else:
|
else:
|
||||||
@ -180,7 +180,9 @@ ENABLE_BM25_SEARCH = boolean_env("ENABLE_BM25_SEARCH", True)
|
|||||||
ENABLE_SEARCH_SCORING = boolean_env("ENABLE_SEARCH_SCORING", True)
|
ENABLE_SEARCH_SCORING = boolean_env("ENABLE_SEARCH_SCORING", True)
|
||||||
ENABLE_HYDE_EXPANSION = boolean_env("ENABLE_HYDE_EXPANSION", True)
|
ENABLE_HYDE_EXPANSION = boolean_env("ENABLE_HYDE_EXPANSION", True)
|
||||||
HYDE_TIMEOUT = float(os.getenv("HYDE_TIMEOUT", "3.0"))
|
HYDE_TIMEOUT = float(os.getenv("HYDE_TIMEOUT", "3.0"))
|
||||||
ENABLE_QUERY_ANALYSIS = boolean_env("ENABLE_QUERY_ANALYSIS", True) # Runs in parallel with HyDE
|
ENABLE_QUERY_ANALYSIS = boolean_env(
|
||||||
|
"ENABLE_QUERY_ANALYSIS", True
|
||||||
|
) # Runs in parallel with HyDE
|
||||||
ENABLE_RERANKING = boolean_env("ENABLE_RERANKING", True)
|
ENABLE_RERANKING = boolean_env("ENABLE_RERANKING", True)
|
||||||
RERANK_MODEL = os.getenv("RERANK_MODEL", "rerank-2-lite")
|
RERANK_MODEL = os.getenv("RERANK_MODEL", "rerank-2-lite")
|
||||||
MAX_PREVIEW_LENGTH = int(os.getenv("MAX_PREVIEW_LENGTH", DEFAULT_CHUNK_TOKENS * 16))
|
MAX_PREVIEW_LENGTH = int(os.getenv("MAX_PREVIEW_LENGTH", DEFAULT_CHUNK_TOKENS * 16))
|
||||||
@ -248,7 +250,9 @@ S3_BACKUP_INTERVAL = int(
|
|||||||
# Google OAuth settings
|
# Google OAuth settings
|
||||||
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "")
|
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "")
|
||||||
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "")
|
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "")
|
||||||
GOOGLE_REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI", f"{SERVER_URL}/auth/callback/google")
|
GOOGLE_REDIRECT_URI = os.getenv(
|
||||||
|
"GOOGLE_REDIRECT_URI", f"{SERVER_URL}/auth/callback/google"
|
||||||
|
)
|
||||||
GOOGLE_SCOPES = [
|
GOOGLE_SCOPES = [
|
||||||
"https://www.googleapis.com/auth/drive.readonly",
|
"https://www.googleapis.com/auth/drive.readonly",
|
||||||
"https://www.googleapis.com/auth/userinfo.email",
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
|
|||||||
@ -5,6 +5,7 @@ Import sub-modules so Celery can register their @app.task decorators.
|
|||||||
from memory.workers.tasks import (
|
from memory.workers.tasks import (
|
||||||
backup,
|
backup,
|
||||||
blogs,
|
blogs,
|
||||||
|
calendar,
|
||||||
comic,
|
comic,
|
||||||
discord,
|
discord,
|
||||||
ebook,
|
ebook,
|
||||||
@ -23,6 +24,7 @@ from memory.workers.tasks import (
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"backup",
|
"backup",
|
||||||
"blogs",
|
"blogs",
|
||||||
|
"calendar",
|
||||||
"comic",
|
"comic",
|
||||||
"discord",
|
"discord",
|
||||||
"ebook",
|
"ebook",
|
||||||
|
|||||||
520
src/memory/workers/tasks/calendar.py
Normal file
520
src/memory/workers/tasks/calendar.py
Normal file
@ -0,0 +1,520 @@
|
|||||||
|
"""Celery tasks for calendar syncing (CalDAV, Google Calendar)."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, TypedDict, cast
|
||||||
|
|
||||||
|
import caldav
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from memory.common.celery_app import (
|
||||||
|
SYNC_ALL_CALENDARS,
|
||||||
|
SYNC_CALENDAR_ACCOUNT,
|
||||||
|
SYNC_CALENDAR_EVENT,
|
||||||
|
app,
|
||||||
|
)
|
||||||
|
from memory.common.db.connection import make_session
|
||||||
|
from memory.common.db.models import CalendarEvent
|
||||||
|
from memory.common.db.models.sources import CalendarAccount
|
||||||
|
from memory.parsers.google_drive import refresh_credentials
|
||||||
|
from memory.workers.tasks.content_processing import (
|
||||||
|
create_task_result,
|
||||||
|
process_content_item,
|
||||||
|
safe_task_execution,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EventData(TypedDict, total=False):
|
||||||
|
"""Structured event data for calendar sync.
|
||||||
|
|
||||||
|
Required fields: title, start_time
|
||||||
|
"""
|
||||||
|
|
||||||
|
title: str # Required
|
||||||
|
start_time: datetime # Required
|
||||||
|
end_time: datetime | None
|
||||||
|
all_day: bool
|
||||||
|
description: str
|
||||||
|
location: str | None
|
||||||
|
external_id: str | None
|
||||||
|
calendar_name: str
|
||||||
|
recurrence_rule: str | None
|
||||||
|
attendees: list[str]
|
||||||
|
meeting_link: str | None
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Helper functions
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _get_ical_component(event: Any) -> Any:
|
||||||
|
"""Get the VEVENT component from a caldav event using icalendar."""
|
||||||
|
ical = event.icalendar_instance
|
||||||
|
for component in ical.walk():
|
||||||
|
if component.name == "VEVENT":
|
||||||
|
return component
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_vevent_attr(vevent: Any, attr: str, default: Any = None) -> Any:
|
||||||
|
"""Safely get an attribute from an icalendar VEVENT component.
|
||||||
|
|
||||||
|
For rrule attributes, converts the rrule object to its string representation.
|
||||||
|
"""
|
||||||
|
component = _get_ical_component(vevent)
|
||||||
|
if component is None:
|
||||||
|
return default
|
||||||
|
|
||||||
|
value = component.get(attr)
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
|
||||||
|
# For date/datetime properties, extract the actual value
|
||||||
|
if hasattr(value, "dt"):
|
||||||
|
return value.dt
|
||||||
|
|
||||||
|
# rrule is a special case - it's an object that needs string conversion
|
||||||
|
if attr == "rrule" and value is not None:
|
||||||
|
return str(value.to_ical().decode("utf-8"))
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _create_event_hash(event_data: EventData) -> bytes:
|
||||||
|
"""Create a hash for deduplication based on event content."""
|
||||||
|
content = (
|
||||||
|
f"{event_data.get('title', '')}"
|
||||||
|
f"{event_data.get('start_time', '')}"
|
||||||
|
f"{event_data.get('description', '')}"
|
||||||
|
)
|
||||||
|
return hashlib.sha256(content.encode()).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_event_data(event_data: EventData) -> dict[str, Any]:
|
||||||
|
"""Serialize event data for Celery task passing (datetime -> ISO string)."""
|
||||||
|
serialized: dict[str, Any] = dict(event_data)
|
||||||
|
if isinstance(serialized.get("start_time"), datetime):
|
||||||
|
serialized["start_time"] = serialized["start_time"].isoformat()
|
||||||
|
if isinstance(serialized.get("end_time"), datetime):
|
||||||
|
serialized["end_time"] = serialized["end_time"].isoformat()
|
||||||
|
return serialized
|
||||||
|
|
||||||
|
|
||||||
|
def _deserialize_event_data(data: dict[str, Any]) -> EventData:
|
||||||
|
"""Deserialize event data from Celery task (ISO string -> datetime)."""
|
||||||
|
result = dict(data)
|
||||||
|
if isinstance(result.get("start_time"), str):
|
||||||
|
result["start_time"] = datetime.fromisoformat(result["start_time"])
|
||||||
|
if isinstance(result.get("end_time"), str):
|
||||||
|
result["end_time"] = datetime.fromisoformat(result["end_time"])
|
||||||
|
return cast(EventData, result)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_timezone(dt: datetime | None) -> datetime | None:
|
||||||
|
"""Ensure datetime has timezone info, defaulting to UTC."""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_calendar_event(account: CalendarAccount, event_data: EventData) -> CalendarEvent:
|
||||||
|
"""Create a CalendarEvent model from parsed event data."""
|
||||||
|
account_tags = cast(list[str], account.tags) or []
|
||||||
|
|
||||||
|
metadata: dict[str, Any] = {}
|
||||||
|
if event_data.get("attendees"):
|
||||||
|
metadata["attendees"] = event_data["attendees"]
|
||||||
|
if event_data.get("meeting_link"):
|
||||||
|
metadata["meeting_link"] = event_data["meeting_link"]
|
||||||
|
|
||||||
|
return CalendarEvent(
|
||||||
|
modality="calendar",
|
||||||
|
sha256=_create_event_hash(event_data),
|
||||||
|
content=event_data.get("description", ""),
|
||||||
|
event_title=event_data["title"],
|
||||||
|
start_time=event_data["start_time"],
|
||||||
|
end_time=event_data.get("end_time"),
|
||||||
|
all_day=event_data.get("all_day", False),
|
||||||
|
location=event_data.get("location"),
|
||||||
|
recurrence_rule=event_data.get("recurrence_rule"),
|
||||||
|
calendar_account_id=account.id,
|
||||||
|
calendar_name=event_data.get("calendar_name"),
|
||||||
|
external_id=event_data.get("external_id"),
|
||||||
|
event_metadata=metadata,
|
||||||
|
tags=account_tags,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_existing_event(existing: CalendarEvent, event_data: EventData) -> None:
|
||||||
|
"""Update an existing CalendarEvent with new data."""
|
||||||
|
existing.event_title = event_data["title"]
|
||||||
|
existing.start_time = event_data["start_time"]
|
||||||
|
existing.end_time = event_data.get("end_time")
|
||||||
|
existing.all_day = event_data.get("all_day", False)
|
||||||
|
existing.location = event_data.get("location")
|
||||||
|
existing.content = event_data.get("description", "")
|
||||||
|
existing.recurrence_rule = event_data.get("recurrence_rule")
|
||||||
|
|
||||||
|
metadata = existing.event_metadata or {}
|
||||||
|
if event_data.get("attendees"):
|
||||||
|
metadata["attendees"] = event_data["attendees"]
|
||||||
|
if event_data.get("meeting_link"):
|
||||||
|
metadata["meeting_link"] = event_data["meeting_link"]
|
||||||
|
existing.event_metadata = metadata
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# CalDAV parsing
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_caldav_event(vevent: Any, calendar_name: str) -> EventData:
|
||||||
|
"""Parse a CalDAV VEVENT into EventData format."""
|
||||||
|
summary = _get_vevent_attr(vevent, "summary", "Untitled Event")
|
||||||
|
dtstart = _get_vevent_attr(vevent, "dtstart")
|
||||||
|
dtend = _get_vevent_attr(vevent, "dtend")
|
||||||
|
|
||||||
|
if dtstart is None:
|
||||||
|
raise ValueError("Calendar event missing required start time (dtstart)")
|
||||||
|
|
||||||
|
# All-day events use date objects, timed events use datetime
|
||||||
|
all_day = not hasattr(dtstart, "hour")
|
||||||
|
|
||||||
|
if all_day:
|
||||||
|
start_time = datetime.combine(dtstart, datetime.min.time()).replace(tzinfo=timezone.utc)
|
||||||
|
end_time = (
|
||||||
|
datetime.combine(dtend, datetime.min.time()).replace(tzinfo=timezone.utc)
|
||||||
|
if dtend
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
start_time = dtstart if dtstart.tzinfo else dtstart.replace(tzinfo=timezone.utc)
|
||||||
|
end_time = _ensure_timezone(dtend)
|
||||||
|
|
||||||
|
# Parse attendees
|
||||||
|
attendees: list[str] = []
|
||||||
|
raw_attendees = _get_vevent_attr(vevent, "attendee")
|
||||||
|
if raw_attendees:
|
||||||
|
attendee_list = raw_attendees if isinstance(raw_attendees, list) else [raw_attendees]
|
||||||
|
attendees = [str(a).replace("mailto:", "") for a in attendee_list]
|
||||||
|
|
||||||
|
return EventData(
|
||||||
|
title=str(summary),
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
all_day=all_day,
|
||||||
|
description=str(_get_vevent_attr(vevent, "description", "")),
|
||||||
|
location=_get_vevent_attr(vevent, "location"),
|
||||||
|
external_id=_get_vevent_attr(vevent, "uid"),
|
||||||
|
calendar_name=calendar_name,
|
||||||
|
recurrence_rule=_get_vevent_attr(vevent, "rrule"),
|
||||||
|
attendees=attendees,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_caldav_events(
|
||||||
|
url: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
calendar_ids: list[str],
|
||||||
|
since: datetime,
|
||||||
|
until: datetime,
|
||||||
|
) -> list[EventData]:
|
||||||
|
"""Fetch events from a CalDAV server.
|
||||||
|
|
||||||
|
Fetches ALL events (not date-filtered) to preserve recurring events with RRULE.
|
||||||
|
Recurring events are expanded at query time, not sync time.
|
||||||
|
"""
|
||||||
|
client = caldav.DAVClient(url=url, username=username, password=password)
|
||||||
|
principal = client.principal()
|
||||||
|
events: list[EventData] = []
|
||||||
|
|
||||||
|
for calendar in principal.calendars():
|
||||||
|
calendar_name = calendar.name or "Unknown"
|
||||||
|
|
||||||
|
if calendar_ids and calendar.id not in calendar_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Fetch ALL events to get recurring events with RRULE intact
|
||||||
|
# We expand recurring events at query time, not sync time
|
||||||
|
vevents = calendar.events()
|
||||||
|
for vevent in vevents:
|
||||||
|
try:
|
||||||
|
events.append(_parse_caldav_event(vevent, calendar_name))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error parsing CalDAV event from {calendar_name}: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching events from calendar {calendar_name}: {e}")
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Google Calendar parsing
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_google_event(event: dict[str, Any], calendar_name: str) -> EventData:
|
||||||
|
"""Parse a Google Calendar event into EventData format."""
|
||||||
|
start = event.get("start", {})
|
||||||
|
end = event.get("end", {})
|
||||||
|
all_day = "date" in start
|
||||||
|
|
||||||
|
if all_day:
|
||||||
|
start_time = datetime.fromisoformat(start["date"]).replace(tzinfo=timezone.utc)
|
||||||
|
end_time = (
|
||||||
|
datetime.fromisoformat(end["date"]).replace(tzinfo=timezone.utc)
|
||||||
|
if end.get("date")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
start_time = datetime.fromisoformat(start["dateTime"].replace("Z", "+00:00"))
|
||||||
|
end_time = (
|
||||||
|
datetime.fromisoformat(end["dateTime"].replace("Z", "+00:00"))
|
||||||
|
if end.get("dateTime")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract attendee emails
|
||||||
|
attendees = [a["email"] for a in event.get("attendees", []) if a.get("email")]
|
||||||
|
|
||||||
|
# Extract meeting link from hangoutLink or conferenceData
|
||||||
|
meeting_link = event.get("hangoutLink")
|
||||||
|
if not meeting_link and "conferenceData" in event:
|
||||||
|
for ep in event["conferenceData"].get("entryPoints", []):
|
||||||
|
if ep.get("entryPointType") == "video":
|
||||||
|
meeting_link = ep.get("uri")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Extract recurrence rule (first one if multiple)
|
||||||
|
recurrence = event.get("recurrence", [])
|
||||||
|
recurrence_rule = recurrence[0] if recurrence else None
|
||||||
|
|
||||||
|
return EventData(
|
||||||
|
title=event.get("summary", "Untitled Event"),
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
all_day=all_day,
|
||||||
|
description=event.get("description", ""),
|
||||||
|
location=event.get("location"),
|
||||||
|
external_id=event.get("id"),
|
||||||
|
calendar_name=calendar_name,
|
||||||
|
recurrence_rule=recurrence_rule,
|
||||||
|
attendees=attendees,
|
||||||
|
meeting_link=meeting_link,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_google_calendar_events(
|
||||||
|
account: CalendarAccount,
|
||||||
|
calendar_ids: list[str],
|
||||||
|
since: datetime,
|
||||||
|
until: datetime,
|
||||||
|
session: Session,
|
||||||
|
) -> list[EventData]:
|
||||||
|
"""Fetch events from Google Calendar using existing GoogleAccount."""
|
||||||
|
google_account = account.google_account
|
||||||
|
if not google_account:
|
||||||
|
raise ValueError("Google Calendar account requires linked GoogleAccount")
|
||||||
|
|
||||||
|
credentials = refresh_credentials(google_account, session)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from googleapiclient.discovery import build
|
||||||
|
except ImportError as e:
|
||||||
|
raise ImportError("google-api-python-client not installed") from e
|
||||||
|
|
||||||
|
service = build("calendar", "v3", credentials=credentials)
|
||||||
|
events: list[EventData] = []
|
||||||
|
|
||||||
|
time_min = since.isoformat()
|
||||||
|
time_max = until.isoformat()
|
||||||
|
|
||||||
|
# Determine which calendars to sync
|
||||||
|
calendars_to_sync = calendar_ids
|
||||||
|
if not calendars_to_sync:
|
||||||
|
try:
|
||||||
|
calendar_list = service.calendarList().list().execute()
|
||||||
|
calendars_to_sync = [cal["id"] for cal in calendar_list.get("items", [])]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching calendar list, falling back to primary: {e}")
|
||||||
|
calendars_to_sync = ["primary"]
|
||||||
|
|
||||||
|
for calendar_id in calendars_to_sync:
|
||||||
|
try:
|
||||||
|
# Get calendar display name
|
||||||
|
try:
|
||||||
|
cal_info = service.calendars().get(calendarId=calendar_id).execute()
|
||||||
|
calendar_name = cal_info.get("summary", calendar_id)
|
||||||
|
except Exception:
|
||||||
|
calendar_name = calendar_id
|
||||||
|
|
||||||
|
events_result = (
|
||||||
|
service.events()
|
||||||
|
.list(
|
||||||
|
calendarId=calendar_id,
|
||||||
|
timeMin=time_min,
|
||||||
|
timeMax=time_max,
|
||||||
|
singleEvents=True,
|
||||||
|
orderBy="startTime",
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
|
||||||
|
for event in events_result.get("items", []):
|
||||||
|
try:
|
||||||
|
events.append(_parse_google_event(event, calendar_name))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error parsing Google event: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching events from calendar {calendar_id}: {e}")
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Celery tasks
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@app.task(name=SYNC_CALENDAR_EVENT)
|
||||||
|
@safe_task_execution
|
||||||
|
def sync_calendar_event(account_id: int, event_data_raw: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Sync a single calendar event."""
|
||||||
|
event_data = _deserialize_event_data(event_data_raw)
|
||||||
|
logger.info(f"Syncing calendar event: {event_data.get('title')}")
|
||||||
|
|
||||||
|
with make_session() as session:
|
||||||
|
account = session.get(CalendarAccount, account_id)
|
||||||
|
if not account:
|
||||||
|
return {"status": "error", "error": "Account not found"}
|
||||||
|
|
||||||
|
# Check for existing event by external_id
|
||||||
|
external_id = event_data.get("external_id")
|
||||||
|
existing = None
|
||||||
|
if external_id:
|
||||||
|
existing = (
|
||||||
|
session.query(CalendarEvent)
|
||||||
|
.filter(
|
||||||
|
CalendarEvent.calendar_account_id == account_id,
|
||||||
|
CalendarEvent.external_id == external_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
_update_existing_event(existing, event_data)
|
||||||
|
session.commit()
|
||||||
|
return create_task_result(existing, "updated")
|
||||||
|
|
||||||
|
calendar_event = _create_calendar_event(account, event_data)
|
||||||
|
return process_content_item(calendar_event, session)
|
||||||
|
|
||||||
|
|
||||||
|
@app.task(name=SYNC_CALENDAR_ACCOUNT)
|
||||||
|
@safe_task_execution
|
||||||
|
def sync_calendar_account(account_id: int, force_full: bool = False) -> dict[str, Any]:
|
||||||
|
"""Sync all events from a calendar account."""
|
||||||
|
logger.info(f"Syncing calendar account {account_id}")
|
||||||
|
|
||||||
|
with make_session() as session:
|
||||||
|
account = session.get(CalendarAccount, account_id)
|
||||||
|
if not account or not cast(bool, account.active):
|
||||||
|
return {"status": "error", "error": "Account not found or inactive"}
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
last_sync = cast(datetime | None, account.last_sync_at)
|
||||||
|
|
||||||
|
# Skip if recently synced (unless force_full)
|
||||||
|
if last_sync and not force_full:
|
||||||
|
check_interval = cast(int, account.check_interval)
|
||||||
|
if now - last_sync < timedelta(minutes=check_interval):
|
||||||
|
return {"status": "skipped_recent_check", "account_id": account_id}
|
||||||
|
|
||||||
|
# Calculate sync window
|
||||||
|
sync_past = cast(int, account.sync_past_days)
|
||||||
|
sync_future = cast(int, account.sync_future_days)
|
||||||
|
since = now - timedelta(days=sync_past)
|
||||||
|
until = now + timedelta(days=sync_future)
|
||||||
|
|
||||||
|
calendar_type = cast(str, account.calendar_type)
|
||||||
|
calendar_ids = cast(list[str], account.calendar_ids) or []
|
||||||
|
|
||||||
|
try:
|
||||||
|
if calendar_type == "caldav":
|
||||||
|
caldav_url = cast(str, account.caldav_url)
|
||||||
|
caldav_username = cast(str, account.caldav_username)
|
||||||
|
caldav_password = cast(str, account.caldav_password)
|
||||||
|
|
||||||
|
if not all([caldav_url, caldav_username, caldav_password]):
|
||||||
|
return {"status": "error", "error": "CalDAV credentials incomplete"}
|
||||||
|
|
||||||
|
events = _fetch_caldav_events(
|
||||||
|
caldav_url, caldav_username, caldav_password, calendar_ids, since, until
|
||||||
|
)
|
||||||
|
elif calendar_type == "google":
|
||||||
|
events = _fetch_google_calendar_events(
|
||||||
|
account, calendar_ids, since, until, session
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return {"status": "error", "error": f"Unknown calendar type: {calendar_type}"}
|
||||||
|
|
||||||
|
# Queue sync tasks for each event
|
||||||
|
task_ids = []
|
||||||
|
for event_data in events:
|
||||||
|
try:
|
||||||
|
serialized = _serialize_event_data(event_data)
|
||||||
|
task = sync_calendar_event.delay(account.id, serialized)
|
||||||
|
task_ids.append(task.id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error queuing event {event_data.get('title')}: {e}")
|
||||||
|
|
||||||
|
account.last_sync_at = now
|
||||||
|
account.sync_error = None
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
account.sync_error = str(e)
|
||||||
|
session.commit()
|
||||||
|
raise
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "completed",
|
||||||
|
"sync_type": "full" if force_full else "incremental",
|
||||||
|
"account_id": account_id,
|
||||||
|
"account_name": account.name,
|
||||||
|
"calendar_type": calendar_type,
|
||||||
|
"events_synced": len(task_ids),
|
||||||
|
"task_ids": task_ids,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.task(name=SYNC_ALL_CALENDARS)
|
||||||
|
def sync_all_calendars(force_full: bool = False) -> list[dict[str, Any]]:
|
||||||
|
"""Trigger sync for all active calendar accounts."""
|
||||||
|
with make_session() as session:
|
||||||
|
active_accounts = (
|
||||||
|
session.query(CalendarAccount).filter(CalendarAccount.active).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
results = [
|
||||||
|
{
|
||||||
|
"account_id": account.id,
|
||||||
|
"account_name": account.name,
|
||||||
|
"calendar_type": account.calendar_type,
|
||||||
|
"task_id": sync_calendar_account.delay(account.id, force_full=force_full).id,
|
||||||
|
}
|
||||||
|
for account in active_accounts
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Scheduled {'full' if force_full else 'incremental'} sync "
|
||||||
|
f"for {len(results)} active calendar accounts"
|
||||||
|
)
|
||||||
|
return results
|
||||||
442
tests/memory/common/test_calendar.py
Normal file
442
tests/memory/common/test_calendar.py
Normal file
@ -0,0 +1,442 @@
|
|||||||
|
"""Tests for common calendar utilities."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from memory.common.calendar import (
|
||||||
|
expand_recurring_event,
|
||||||
|
event_to_dict,
|
||||||
|
get_events_in_range,
|
||||||
|
parse_date_range,
|
||||||
|
EventDict,
|
||||||
|
)
|
||||||
|
from memory.common.db.models import CalendarEvent
|
||||||
|
from memory.common.db.models.sources import CalendarAccount
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def calendar_account(db_session) -> CalendarAccount:
|
||||||
|
"""Create a calendar account for testing."""
|
||||||
|
account = CalendarAccount(
|
||||||
|
name="Test Calendar",
|
||||||
|
calendar_type="caldav",
|
||||||
|
caldav_url="https://caldav.example.com",
|
||||||
|
caldav_username="testuser",
|
||||||
|
caldav_password="testpass",
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
db_session.add(account)
|
||||||
|
db_session.commit()
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def simple_event(db_session, calendar_account) -> CalendarEvent:
|
||||||
|
"""Create a simple non-recurring event."""
|
||||||
|
event = CalendarEvent(
|
||||||
|
modality="calendar",
|
||||||
|
sha256=b"0" * 32,
|
||||||
|
event_title="Team Meeting",
|
||||||
|
start_time=datetime(2024, 1, 15, 10, 0, 0, tzinfo=timezone.utc),
|
||||||
|
end_time=datetime(2024, 1, 15, 11, 0, 0, tzinfo=timezone.utc),
|
||||||
|
all_day=False,
|
||||||
|
location="Conference Room A",
|
||||||
|
calendar_name="Work",
|
||||||
|
calendar_account_id=calendar_account.id,
|
||||||
|
recurrence_rule=None,
|
||||||
|
)
|
||||||
|
db_session.add(event)
|
||||||
|
db_session.commit()
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def all_day_event(db_session, calendar_account) -> CalendarEvent:
|
||||||
|
"""Create an all-day event."""
|
||||||
|
event = CalendarEvent(
|
||||||
|
modality="calendar",
|
||||||
|
sha256=b"1" * 32,
|
||||||
|
event_title="Holiday",
|
||||||
|
start_time=datetime(2024, 1, 20, 0, 0, 0, tzinfo=timezone.utc),
|
||||||
|
end_time=datetime(2024, 1, 21, 0, 0, 0, tzinfo=timezone.utc),
|
||||||
|
all_day=True,
|
||||||
|
location=None,
|
||||||
|
calendar_name="Holidays",
|
||||||
|
calendar_account_id=calendar_account.id,
|
||||||
|
recurrence_rule=None,
|
||||||
|
)
|
||||||
|
db_session.add(event)
|
||||||
|
db_session.commit()
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def recurring_event(db_session, calendar_account) -> CalendarEvent:
|
||||||
|
"""Create a recurring event (daily on weekdays)."""
|
||||||
|
event = CalendarEvent(
|
||||||
|
modality="calendar",
|
||||||
|
sha256=b"2" * 32,
|
||||||
|
event_title="Daily Standup",
|
||||||
|
start_time=datetime(2024, 1, 1, 9, 0, 0, tzinfo=timezone.utc),
|
||||||
|
end_time=datetime(2024, 1, 1, 9, 15, 0, tzinfo=timezone.utc),
|
||||||
|
all_day=False,
|
||||||
|
location="Zoom",
|
||||||
|
calendar_name="Work",
|
||||||
|
calendar_account_id=calendar_account.id,
|
||||||
|
recurrence_rule="FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR",
|
||||||
|
)
|
||||||
|
db_session.add(event)
|
||||||
|
db_session.commit()
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def weekly_recurring_event(db_session, calendar_account) -> CalendarEvent:
|
||||||
|
"""Create a weekly recurring event."""
|
||||||
|
event = CalendarEvent(
|
||||||
|
modality="calendar",
|
||||||
|
sha256=b"3" * 32,
|
||||||
|
event_title="Weekly Review",
|
||||||
|
start_time=datetime(2024, 1, 5, 14, 0, 0, tzinfo=timezone.utc), # Friday
|
||||||
|
end_time=datetime(2024, 1, 5, 15, 0, 0, tzinfo=timezone.utc),
|
||||||
|
all_day=False,
|
||||||
|
location=None,
|
||||||
|
calendar_name="Work",
|
||||||
|
calendar_account_id=calendar_account.id,
|
||||||
|
recurrence_rule="FREQ=WEEKLY;BYDAY=FR",
|
||||||
|
)
|
||||||
|
db_session.add(event)
|
||||||
|
db_session.commit()
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tests for parse_date_range
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_date_range_with_both_dates():
|
||||||
|
"""Test parsing with both start and end date provided."""
|
||||||
|
start, end = parse_date_range("2024-01-15", "2024-01-20")
|
||||||
|
|
||||||
|
assert start.year == 2024
|
||||||
|
assert start.month == 1
|
||||||
|
assert start.day == 15
|
||||||
|
assert end.day == 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_date_range_with_iso_format():
|
||||||
|
"""Test parsing with full ISO format."""
|
||||||
|
start, end = parse_date_range(
|
||||||
|
"2024-01-15T10:00:00Z",
|
||||||
|
"2024-01-20T18:00:00Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert start.hour == 10
|
||||||
|
assert end.hour == 18
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_date_range_with_timezone():
|
||||||
|
"""Test parsing with timezone offset."""
|
||||||
|
start, end = parse_date_range(
|
||||||
|
"2024-01-15T10:00:00+00:00",
|
||||||
|
"2024-01-20T18:00:00+00:00"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert start.tzinfo is not None
|
||||||
|
assert end.tzinfo is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_date_range_defaults_to_now():
|
||||||
|
"""Test that start defaults to now when not provided."""
|
||||||
|
before = datetime.now(timezone.utc)
|
||||||
|
start, end = parse_date_range(None, None, days=7)
|
||||||
|
after = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
assert before <= start <= after
|
||||||
|
assert end > start
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_date_range_uses_days():
|
||||||
|
"""Test that days parameter is used for end date."""
|
||||||
|
start, end = parse_date_range("2024-01-15", None, days=10)
|
||||||
|
|
||||||
|
assert start.day == 15
|
||||||
|
expected_end = start + timedelta(days=10)
|
||||||
|
assert end.day == expected_end.day
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_date_range_invalid_start_date():
|
||||||
|
"""Test error on invalid start date."""
|
||||||
|
with pytest.raises(ValueError, match="Invalid start_date"):
|
||||||
|
parse_date_range("not-a-date", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_date_range_invalid_end_date():
|
||||||
|
"""Test error on invalid end date."""
|
||||||
|
with pytest.raises(ValueError, match="Invalid end_date"):
|
||||||
|
parse_date_range("2024-01-15", "not-a-date")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tests for expand_recurring_event
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_recurring_event_daily(recurring_event):
|
||||||
|
"""Test expanding a daily recurring event."""
|
||||||
|
start = datetime(2024, 1, 15, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
|
end = datetime(2024, 1, 19, 23, 59, 59, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
occurrences = expand_recurring_event(recurring_event, start, end)
|
||||||
|
|
||||||
|
# Mon-Fri should give us 5 occurrences
|
||||||
|
assert len(occurrences) == 5
|
||||||
|
|
||||||
|
# Check first occurrence
|
||||||
|
first_start, first_end = occurrences[0]
|
||||||
|
assert first_start.day == 15
|
||||||
|
assert first_start.hour == 9
|
||||||
|
assert first_end.hour == 9
|
||||||
|
assert first_end.minute == 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_recurring_event_weekly(weekly_recurring_event):
|
||||||
|
"""Test expanding a weekly recurring event."""
|
||||||
|
start = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
|
end = datetime(2024, 1, 31, 23, 59, 59, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
occurrences = expand_recurring_event(weekly_recurring_event, start, end)
|
||||||
|
|
||||||
|
# January has 4-5 Fridays: 5th, 12th, 19th, 26th = 4 occurrences
|
||||||
|
assert len(occurrences) >= 4
|
||||||
|
|
||||||
|
# All should be Fridays
|
||||||
|
for occ_start, _ in occurrences:
|
||||||
|
assert occ_start.weekday() == 4 # Friday
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_recurring_event_preserves_duration(recurring_event):
|
||||||
|
"""Test that expansion preserves event duration."""
|
||||||
|
start = datetime(2024, 1, 15, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
|
end = datetime(2024, 1, 16, 23, 59, 59, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
occurrences = expand_recurring_event(recurring_event, start, end)
|
||||||
|
|
||||||
|
for occ_start, occ_end in occurrences:
|
||||||
|
duration = occ_end - occ_start
|
||||||
|
assert duration == timedelta(minutes=15)
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_recurring_event_non_recurring_returns_empty(simple_event):
|
||||||
|
"""Test that non-recurring events return empty list."""
|
||||||
|
start = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
|
end = datetime(2024, 1, 31, 23, 59, 59, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
occurrences = expand_recurring_event(simple_event, start, end)
|
||||||
|
|
||||||
|
assert occurrences == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_recurring_event_no_start_time():
|
||||||
|
"""Test handling event without start time."""
|
||||||
|
event = CalendarEvent(
|
||||||
|
modality="calendar",
|
||||||
|
sha256=b"x" * 32,
|
||||||
|
event_title="No Start",
|
||||||
|
start_time=None,
|
||||||
|
recurrence_rule="FREQ=DAILY",
|
||||||
|
)
|
||||||
|
|
||||||
|
start = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
|
end = datetime(2024, 1, 31, 23, 59, 59, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
occurrences = expand_recurring_event(event, start, end)
|
||||||
|
|
||||||
|
assert occurrences == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_recurring_event_invalid_rule():
|
||||||
|
"""Test handling invalid recurrence rule."""
|
||||||
|
event = CalendarEvent(
|
||||||
|
modality="calendar",
|
||||||
|
sha256=b"y" * 32,
|
||||||
|
event_title="Bad Rule",
|
||||||
|
start_time=datetime(2024, 1, 1, 9, 0, 0, tzinfo=timezone.utc),
|
||||||
|
recurrence_rule="INVALID_RULE",
|
||||||
|
)
|
||||||
|
|
||||||
|
start = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
|
end = datetime(2024, 1, 31, 23, 59, 59, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Should return empty list, not raise
|
||||||
|
occurrences = expand_recurring_event(event, start, end)
|
||||||
|
assert occurrences == []
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tests for event_to_dict
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_to_dict_basic(simple_event):
|
||||||
|
"""Test converting event to dict."""
|
||||||
|
result = event_to_dict(simple_event)
|
||||||
|
|
||||||
|
assert result["id"] == simple_event.id
|
||||||
|
assert result["event_title"] == "Team Meeting"
|
||||||
|
assert result["location"] == "Conference Room A"
|
||||||
|
assert result["calendar_name"] == "Work"
|
||||||
|
assert result["all_day"] is False
|
||||||
|
assert result["recurrence_rule"] is None
|
||||||
|
assert "2024-01-15" in result["start_time"]
|
||||||
|
assert "2024-01-15" in result["end_time"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_to_dict_all_day(all_day_event):
|
||||||
|
"""Test converting all-day event."""
|
||||||
|
result = event_to_dict(all_day_event)
|
||||||
|
|
||||||
|
assert result["all_day"] is True
|
||||||
|
assert result["event_title"] == "Holiday"
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_to_dict_with_override_times(simple_event):
|
||||||
|
"""Test overriding times for recurring occurrences."""
|
||||||
|
override_start = datetime(2024, 2, 15, 10, 0, 0, tzinfo=timezone.utc)
|
||||||
|
override_end = datetime(2024, 2, 15, 11, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
result = event_to_dict(simple_event, override_start, override_end)
|
||||||
|
|
||||||
|
assert "2024-02-15" in result["start_time"]
|
||||||
|
assert "2024-02-15" in result["end_time"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_to_dict_no_end_time():
|
||||||
|
"""Test event without end time."""
|
||||||
|
event = CalendarEvent(
|
||||||
|
modality="calendar",
|
||||||
|
sha256=b"z" * 32,
|
||||||
|
event_title="Open-ended",
|
||||||
|
start_time=datetime(2024, 1, 15, 10, 0, 0, tzinfo=timezone.utc),
|
||||||
|
end_time=None,
|
||||||
|
all_day=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = event_to_dict(event)
|
||||||
|
|
||||||
|
assert result["end_time"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tests for get_events_in_range
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_events_in_range_simple(db_session, simple_event):
|
||||||
|
"""Test fetching non-recurring events in range."""
|
||||||
|
start = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
|
end = datetime(2024, 1, 31, 23, 59, 59, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
events = get_events_in_range(db_session, start, end)
|
||||||
|
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0]["event_title"] == "Team Meeting"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_events_in_range_excludes_out_of_range(db_session, simple_event):
|
||||||
|
"""Test that events outside range are excluded."""
|
||||||
|
start = datetime(2024, 2, 1, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
|
end = datetime(2024, 2, 28, 23, 59, 59, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
events = get_events_in_range(db_session, start, end)
|
||||||
|
|
||||||
|
assert len(events) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_events_in_range_expands_recurring(db_session, recurring_event):
|
||||||
|
"""Test that recurring events are expanded."""
|
||||||
|
start = datetime(2024, 1, 15, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
|
end = datetime(2024, 1, 19, 23, 59, 59, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
events = get_events_in_range(db_session, start, end)
|
||||||
|
|
||||||
|
# 5 weekdays
|
||||||
|
assert len(events) == 5
|
||||||
|
for event in events:
|
||||||
|
assert event["event_title"] == "Daily Standup"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_events_in_range_mixed_events(
|
||||||
|
db_session, simple_event, all_day_event, recurring_event
|
||||||
|
):
|
||||||
|
"""Test fetching mix of recurring and non-recurring."""
|
||||||
|
start = datetime(2024, 1, 15, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
|
end = datetime(2024, 1, 21, 23, 59, 59, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
events = get_events_in_range(db_session, start, end)
|
||||||
|
|
||||||
|
titles = [e["event_title"] for e in events]
|
||||||
|
assert "Team Meeting" in titles
|
||||||
|
assert "Holiday" in titles
|
||||||
|
assert "Daily Standup" in titles
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_events_in_range_sorted_by_start_time(
|
||||||
|
db_session, simple_event, recurring_event
|
||||||
|
):
|
||||||
|
"""Test that events are sorted by start time."""
|
||||||
|
start = datetime(2024, 1, 15, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
|
end = datetime(2024, 1, 16, 23, 59, 59, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
events = get_events_in_range(db_session, start, end)
|
||||||
|
|
||||||
|
# Verify sorted order
|
||||||
|
times = [e["start_time"] for e in events]
|
||||||
|
assert times == sorted(times)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_events_in_range_respects_limit(db_session, recurring_event):
|
||||||
|
"""Test that limit parameter is respected."""
|
||||||
|
start = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
|
end = datetime(2024, 1, 31, 23, 59, 59, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
events = get_events_in_range(db_session, start, end, limit=3)
|
||||||
|
|
||||||
|
assert len(events) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_events_in_range_empty_database(db_session, calendar_account):
|
||||||
|
"""Test with no events in database."""
|
||||||
|
start = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
|
end = datetime(2024, 1, 31, 23, 59, 59, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
events = get_events_in_range(db_session, start, end)
|
||||||
|
|
||||||
|
assert events == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_events_in_range_recurring_no_end_time(db_session, calendar_account):
|
||||||
|
"""Test recurring event without end time."""
|
||||||
|
event = CalendarEvent(
|
||||||
|
modality="calendar",
|
||||||
|
sha256=b"4" * 32,
|
||||||
|
event_title="All Day Recurring",
|
||||||
|
start_time=datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
|
||||||
|
end_time=None,
|
||||||
|
all_day=True,
|
||||||
|
calendar_account_id=calendar_account.id,
|
||||||
|
recurrence_rule="FREQ=WEEKLY;BYDAY=MO",
|
||||||
|
)
|
||||||
|
db_session.add(event)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
start = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
|
end = datetime(2024, 1, 15, 23, 59, 59, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
events = get_events_in_range(db_session, start, end)
|
||||||
|
|
||||||
|
# Should have occurrences on Mondays: 1st, 8th, 15th = 3
|
||||||
|
assert len(events) >= 2
|
||||||
|
for event in events:
|
||||||
|
assert event["end_time"] is None
|
||||||
680
tests/memory/workers/tasks/test_calendar_tasks.py
Normal file
680
tests/memory/workers/tasks/test_calendar_tasks.py
Normal file
@ -0,0 +1,680 @@
|
|||||||
|
"""Tests for calendar syncing tasks."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import Mock, patch, MagicMock
|
||||||
|
|
||||||
|
from memory.common.db.models import CalendarEvent
|
||||||
|
from memory.common.db.models.sources import CalendarAccount, GoogleAccount
|
||||||
|
from memory.workers.tasks import calendar
|
||||||
|
from memory.workers.tasks.calendar import (
|
||||||
|
_create_event_hash,
|
||||||
|
_parse_google_event,
|
||||||
|
_create_calendar_event,
|
||||||
|
_serialize_event_data,
|
||||||
|
)
|
||||||
|
from memory.common.db import connection as db_connection
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_db_cache():
|
||||||
|
"""Reset the cached database engine between tests."""
|
||||||
|
db_connection._engine = None
|
||||||
|
db_connection._session_factory = None
|
||||||
|
db_connection._scoped_session = None
|
||||||
|
yield
|
||||||
|
db_connection._engine = None
|
||||||
|
db_connection._session_factory = None
|
||||||
|
db_connection._scoped_session = None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_event_data() -> dict:
|
||||||
|
"""Mock event data for testing."""
|
||||||
|
return {
|
||||||
|
"title": "Team Meeting",
|
||||||
|
"start_time": datetime(2024, 1, 15, 10, 0, 0, tzinfo=timezone.utc),
|
||||||
|
"end_time": datetime(2024, 1, 15, 11, 0, 0, tzinfo=timezone.utc),
|
||||||
|
"all_day": False,
|
||||||
|
"description": "Weekly sync meeting with the team",
|
||||||
|
"location": "Conference Room A",
|
||||||
|
"external_id": "event-123",
|
||||||
|
"calendar_name": "Work",
|
||||||
|
"recurrence_rule": None,
|
||||||
|
"attendees": ["alice@example.com", "bob@example.com"],
|
||||||
|
"meeting_link": "https://meet.example.com/abc123",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_all_day_event() -> dict:
|
||||||
|
"""Mock all-day event data."""
|
||||||
|
return {
|
||||||
|
"title": "Company Holiday",
|
||||||
|
"start_time": datetime(2024, 12, 25, 0, 0, 0, tzinfo=timezone.utc),
|
||||||
|
"end_time": datetime(2024, 12, 26, 0, 0, 0, tzinfo=timezone.utc),
|
||||||
|
"all_day": True,
|
||||||
|
"description": "Christmas Day",
|
||||||
|
"location": None,
|
||||||
|
"external_id": "holiday-123",
|
||||||
|
"calendar_name": "Holidays",
|
||||||
|
"recurrence_rule": None,
|
||||||
|
"attendees": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_recurring_event() -> dict:
|
||||||
|
"""Mock recurring event data."""
|
||||||
|
return {
|
||||||
|
"title": "Daily Standup",
|
||||||
|
"start_time": datetime(2024, 1, 15, 9, 0, 0, tzinfo=timezone.utc),
|
||||||
|
"end_time": datetime(2024, 1, 15, 9, 15, 0, tzinfo=timezone.utc),
|
||||||
|
"all_day": False,
|
||||||
|
"description": "Quick daily sync",
|
||||||
|
"location": None,
|
||||||
|
"external_id": "standup-123",
|
||||||
|
"calendar_name": "Work",
|
||||||
|
"recurrence_rule": "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR",
|
||||||
|
"attendees": ["team@example.com"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def caldav_account(db_session) -> CalendarAccount:
|
||||||
|
"""Create a CalDAV calendar account for testing."""
|
||||||
|
account = CalendarAccount(
|
||||||
|
name="Test CalDAV",
|
||||||
|
calendar_type="caldav",
|
||||||
|
caldav_url="https://caldav.example.com",
|
||||||
|
caldav_username="testuser",
|
||||||
|
caldav_password="testpass",
|
||||||
|
calendar_ids=[],
|
||||||
|
tags=["calendar", "test"],
|
||||||
|
check_interval=15,
|
||||||
|
sync_past_days=30,
|
||||||
|
sync_future_days=90,
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
db_session.add(account)
|
||||||
|
db_session.commit()
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def google_account(db_session) -> GoogleAccount:
|
||||||
|
"""Create a Google account for testing."""
|
||||||
|
account = GoogleAccount(
|
||||||
|
name="Test Google",
|
||||||
|
email="test@gmail.com",
|
||||||
|
access_token="test_access_token",
|
||||||
|
refresh_token="test_refresh_token",
|
||||||
|
token_expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
|
||||||
|
scopes=["calendar"],
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
db_session.add(account)
|
||||||
|
db_session.commit()
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def google_calendar_account(db_session, google_account) -> CalendarAccount:
|
||||||
|
"""Create a Google Calendar account for testing."""
|
||||||
|
account = CalendarAccount(
|
||||||
|
name="Test Google Calendar",
|
||||||
|
calendar_type="google",
|
||||||
|
google_account_id=google_account.id,
|
||||||
|
calendar_ids=[],
|
||||||
|
tags=["calendar", "google"],
|
||||||
|
check_interval=15,
|
||||||
|
sync_past_days=30,
|
||||||
|
sync_future_days=90,
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
db_session.add(account)
|
||||||
|
db_session.commit()
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def inactive_account(db_session) -> CalendarAccount:
|
||||||
|
"""Create an inactive calendar account."""
|
||||||
|
account = CalendarAccount(
|
||||||
|
name="Inactive CalDAV",
|
||||||
|
calendar_type="caldav",
|
||||||
|
caldav_url="https://caldav.example.com",
|
||||||
|
caldav_username="testuser",
|
||||||
|
caldav_password="testpass",
|
||||||
|
active=False,
|
||||||
|
)
|
||||||
|
db_session.add(account)
|
||||||
|
db_session.commit()
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tests for helper functions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_event_hash_basic(mock_event_data):
|
||||||
|
"""Test event hash creation."""
|
||||||
|
hash1 = _create_event_hash(mock_event_data)
|
||||||
|
hash2 = _create_event_hash(mock_event_data)
|
||||||
|
assert hash1 == hash2
|
||||||
|
assert len(hash1) == 32 # SHA256 = 32 bytes
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_event_hash_different_events():
|
||||||
|
"""Test that different events have different hashes."""
|
||||||
|
event1 = {"title": "Event 1", "start_time": "2024-01-15T10:00:00Z", "description": ""}
|
||||||
|
event2 = {"title": "Event 2", "start_time": "2024-01-15T10:00:00Z", "description": ""}
|
||||||
|
|
||||||
|
hash1 = _create_event_hash(event1)
|
||||||
|
hash2 = _create_event_hash(event2)
|
||||||
|
assert hash1 != hash2
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_event_data(mock_event_data):
|
||||||
|
"""Test event data serialization for Celery."""
|
||||||
|
serialized = _serialize_event_data(mock_event_data)
|
||||||
|
|
||||||
|
# Datetimes should be converted to ISO strings
|
||||||
|
assert isinstance(serialized["start_time"], str)
|
||||||
|
assert isinstance(serialized["end_time"], str)
|
||||||
|
assert serialized["title"] == "Team Meeting"
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_event_data_none_end_time():
|
||||||
|
"""Test serialization with None end_time."""
|
||||||
|
event = {
|
||||||
|
"title": "Open Event",
|
||||||
|
"start_time": datetime(2024, 1, 15, 10, 0, 0, tzinfo=timezone.utc),
|
||||||
|
"end_time": None,
|
||||||
|
}
|
||||||
|
serialized = _serialize_event_data(event)
|
||||||
|
assert serialized["end_time"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_google_event_regular():
|
||||||
|
"""Test parsing a regular Google Calendar event."""
|
||||||
|
google_event = {
|
||||||
|
"id": "google-event-123",
|
||||||
|
"summary": "Team Sync",
|
||||||
|
"description": "Weekly team meeting",
|
||||||
|
"location": "Zoom",
|
||||||
|
"start": {"dateTime": "2024-01-15T14:00:00Z"},
|
||||||
|
"end": {"dateTime": "2024-01-15T15:00:00Z"},
|
||||||
|
"attendees": [
|
||||||
|
{"email": "alice@example.com"},
|
||||||
|
{"email": "bob@example.com"},
|
||||||
|
],
|
||||||
|
"hangoutLink": "https://meet.google.com/abc-123",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _parse_google_event(google_event, "Work Calendar")
|
||||||
|
|
||||||
|
assert result["title"] == "Team Sync"
|
||||||
|
assert result["external_id"] == "google-event-123"
|
||||||
|
assert result["calendar_name"] == "Work Calendar"
|
||||||
|
assert result["all_day"] is False
|
||||||
|
assert result["location"] == "Zoom"
|
||||||
|
assert result["meeting_link"] == "https://meet.google.com/abc-123"
|
||||||
|
assert "alice@example.com" in result["attendees"]
|
||||||
|
assert "bob@example.com" in result["attendees"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_google_event_all_day():
|
||||||
|
"""Test parsing an all-day Google Calendar event."""
|
||||||
|
google_event = {
|
||||||
|
"id": "holiday-event",
|
||||||
|
"summary": "Company Holiday",
|
||||||
|
"start": {"date": "2024-12-25"},
|
||||||
|
"end": {"date": "2024-12-26"},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _parse_google_event(google_event, "Holidays")
|
||||||
|
|
||||||
|
assert result["title"] == "Company Holiday"
|
||||||
|
assert result["all_day"] is True
|
||||||
|
assert result["start_time"].date().isoformat() == "2024-12-25"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_google_event_with_conference_data():
|
||||||
|
"""Test parsing Google event with conference data instead of hangoutLink."""
|
||||||
|
google_event = {
|
||||||
|
"id": "meet-event",
|
||||||
|
"summary": "Video Call",
|
||||||
|
"start": {"dateTime": "2024-01-15T14:00:00Z"},
|
||||||
|
"end": {"dateTime": "2024-01-15T15:00:00Z"},
|
||||||
|
"conferenceData": {
|
||||||
|
"entryPoints": [
|
||||||
|
{"entryPointType": "phone", "uri": "tel:+1234567890"},
|
||||||
|
{"entryPointType": "video", "uri": "https://zoom.us/j/123456"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _parse_google_event(google_event, "Work")
|
||||||
|
|
||||||
|
assert result["meeting_link"] == "https://zoom.us/j/123456"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_google_event_no_description():
|
||||||
|
"""Test parsing event without description."""
|
||||||
|
google_event = {
|
||||||
|
"id": "simple-event",
|
||||||
|
"summary": "Quick Meeting",
|
||||||
|
"start": {"dateTime": "2024-01-15T14:00:00Z"},
|
||||||
|
"end": {"dateTime": "2024-01-15T15:00:00Z"},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _parse_google_event(google_event, "Work")
|
||||||
|
|
||||||
|
assert result["description"] == ""
|
||||||
|
assert result["attendees"] == []
|
||||||
|
assert result["meeting_link"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_google_event_with_recurrence():
|
||||||
|
"""Test parsing event with recurrence rule."""
|
||||||
|
google_event = {
|
||||||
|
"id": "recurring-event",
|
||||||
|
"summary": "Daily Standup",
|
||||||
|
"start": {"dateTime": "2024-01-15T09:00:00Z"},
|
||||||
|
"end": {"dateTime": "2024-01-15T09:15:00Z"},
|
||||||
|
"recurrence": ["RRULE:FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR"],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _parse_google_event(google_event, "Work")
|
||||||
|
|
||||||
|
assert result["recurrence_rule"] == "RRULE:FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tests for _create_calendar_event
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_calendar_event(caldav_account, mock_event_data):
|
||||||
|
"""Test creating a CalendarEvent from event data."""
|
||||||
|
event = _create_calendar_event(caldav_account, mock_event_data)
|
||||||
|
|
||||||
|
assert event.event_title == "Team Meeting"
|
||||||
|
assert event.start_time == mock_event_data["start_time"]
|
||||||
|
assert event.end_time == mock_event_data["end_time"]
|
||||||
|
assert event.all_day is False
|
||||||
|
assert event.location == "Conference Room A"
|
||||||
|
assert event.external_id == "event-123"
|
||||||
|
assert event.calendar_account_id == caldav_account.id
|
||||||
|
assert event.modality == "calendar"
|
||||||
|
assert "calendar" in event.tags
|
||||||
|
assert "test" in event.tags # From account tags
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_calendar_event_with_metadata(caldav_account, mock_event_data):
|
||||||
|
"""Test that attendees and meeting link are stored in metadata."""
|
||||||
|
event = _create_calendar_event(caldav_account, mock_event_data)
|
||||||
|
|
||||||
|
assert event.event_metadata is not None
|
||||||
|
assert event.event_metadata["attendees"] == ["alice@example.com", "bob@example.com"]
|
||||||
|
assert event.event_metadata["meeting_link"] == "https://meet.example.com/abc123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_calendar_event_no_attendees(caldav_account, mock_all_day_event):
|
||||||
|
"""Test creating event without attendees."""
|
||||||
|
event = _create_calendar_event(caldav_account, mock_all_day_event)
|
||||||
|
|
||||||
|
# Should not have attendees in metadata
|
||||||
|
assert "attendees" not in event.event_metadata or event.event_metadata.get("attendees") == []
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tests for sync_calendar_event
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_calendar_event_new(mock_event_data, caldav_account, db_session, qdrant):
|
||||||
|
"""Test syncing a new calendar event."""
|
||||||
|
serialized = _serialize_event_data(mock_event_data)
|
||||||
|
|
||||||
|
result = calendar.sync_calendar_event(caldav_account.id, serialized)
|
||||||
|
|
||||||
|
assert result["status"] == "processed"
|
||||||
|
|
||||||
|
# Verify event was created
|
||||||
|
event = (
|
||||||
|
db_session.query(CalendarEvent)
|
||||||
|
.filter_by(external_id="event-123")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
assert event is not None
|
||||||
|
assert event.event_title == "Team Meeting"
|
||||||
|
assert event.calendar_account_id == caldav_account.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_calendar_event_account_not_found(mock_event_data, db_session):
|
||||||
|
"""Test syncing with non-existent account."""
|
||||||
|
serialized = _serialize_event_data(mock_event_data)
|
||||||
|
|
||||||
|
result = calendar.sync_calendar_event(99999, serialized)
|
||||||
|
|
||||||
|
assert result["status"] == "error"
|
||||||
|
assert "Account not found" in result["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_calendar_event_update_existing(
|
||||||
|
mock_event_data, caldav_account, db_session, qdrant
|
||||||
|
):
|
||||||
|
"""Test updating an existing calendar event."""
|
||||||
|
# First sync
|
||||||
|
serialized = _serialize_event_data(mock_event_data)
|
||||||
|
calendar.sync_calendar_event(caldav_account.id, serialized)
|
||||||
|
|
||||||
|
# Update the event
|
||||||
|
mock_event_data["title"] = "Updated Team Meeting"
|
||||||
|
mock_event_data["location"] = "Conference Room B"
|
||||||
|
serialized = _serialize_event_data(mock_event_data)
|
||||||
|
|
||||||
|
result = calendar.sync_calendar_event(caldav_account.id, serialized)
|
||||||
|
|
||||||
|
assert result["status"] == "updated"
|
||||||
|
|
||||||
|
# Verify event was updated
|
||||||
|
db_session.expire_all()
|
||||||
|
event = (
|
||||||
|
db_session.query(CalendarEvent)
|
||||||
|
.filter_by(external_id="event-123")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
assert event.event_title == "Updated Team Meeting"
|
||||||
|
assert event.location == "Conference Room B"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_calendar_event_without_external_id(caldav_account, db_session, qdrant):
|
||||||
|
"""Test syncing event without external_id creates new each time."""
|
||||||
|
event_data = {
|
||||||
|
"title": "Ad-hoc Meeting",
|
||||||
|
"start_time": datetime(2024, 1, 15, 10, 0, 0, tzinfo=timezone.utc),
|
||||||
|
"end_time": datetime(2024, 1, 15, 11, 0, 0, tzinfo=timezone.utc),
|
||||||
|
"all_day": False,
|
||||||
|
"description": "",
|
||||||
|
"location": None,
|
||||||
|
"external_id": None, # No external ID
|
||||||
|
"calendar_name": "Work",
|
||||||
|
}
|
||||||
|
serialized = _serialize_event_data(event_data)
|
||||||
|
|
||||||
|
result = calendar.sync_calendar_event(caldav_account.id, serialized)
|
||||||
|
assert result["status"] == "processed"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tests for sync_calendar_account
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_calendar_account_not_found(db_session):
|
||||||
|
"""Test sync with non-existent account."""
|
||||||
|
result = calendar.sync_calendar_account(99999)
|
||||||
|
|
||||||
|
assert result["status"] == "error"
|
||||||
|
assert "Account not found or inactive" in result["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_calendar_account_inactive(inactive_account, db_session):
|
||||||
|
"""Test sync with inactive account."""
|
||||||
|
result = calendar.sync_calendar_account(inactive_account.id)
|
||||||
|
|
||||||
|
assert result["status"] == "error"
|
||||||
|
assert "Account not found or inactive" in result["error"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"check_interval_minutes,seconds_since_check,should_skip",
|
||||||
|
[
|
||||||
|
(15, 60, True), # 15min interval, checked 1min ago -> skip
|
||||||
|
(15, 800, True), # 15min interval, checked 13min ago -> skip
|
||||||
|
(15, 1000, False), # 15min interval, checked 16min ago -> don't skip
|
||||||
|
(30, 1000, True), # 30min interval, checked 16min ago -> skip
|
||||||
|
(30, 2000, False), # 30min interval, checked 33min ago -> don't skip
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_sync_calendar_account_check_interval(
|
||||||
|
check_interval_minutes,
|
||||||
|
seconds_since_check,
|
||||||
|
should_skip,
|
||||||
|
db_session,
|
||||||
|
):
|
||||||
|
"""Test sync respects check interval."""
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
account = CalendarAccount(
|
||||||
|
name="Interval Test",
|
||||||
|
calendar_type="caldav",
|
||||||
|
caldav_url="https://caldav.example.com",
|
||||||
|
caldav_username="user",
|
||||||
|
caldav_password="pass",
|
||||||
|
check_interval=check_interval_minutes,
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
db_session.add(account)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
# Set last_sync_at
|
||||||
|
last_sync_time = datetime.now(timezone.utc) - timedelta(seconds=seconds_since_check)
|
||||||
|
db_session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE calendar_accounts SET last_sync_at = :timestamp WHERE id = :account_id"
|
||||||
|
),
|
||||||
|
{"timestamp": last_sync_time, "account_id": account.id},
|
||||||
|
)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
result = calendar.sync_calendar_account(account.id)
|
||||||
|
|
||||||
|
if should_skip:
|
||||||
|
assert result["status"] == "skipped_recent_check"
|
||||||
|
else:
|
||||||
|
# Would fail with incomplete caldav credentials error, but that's expected
|
||||||
|
assert "status" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_calendar_account_force_full_bypasses_interval(db_session):
|
||||||
|
"""Test force_full bypasses check interval."""
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
account = CalendarAccount(
|
||||||
|
name="Force Test",
|
||||||
|
calendar_type="caldav",
|
||||||
|
caldav_url="https://caldav.example.com",
|
||||||
|
caldav_username="user",
|
||||||
|
caldav_password="pass",
|
||||||
|
check_interval=60,
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
db_session.add(account)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
# Set recent last_sync_at
|
||||||
|
last_sync_time = datetime.now(timezone.utc) - timedelta(seconds=30)
|
||||||
|
db_session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE calendar_accounts SET last_sync_at = :timestamp WHERE id = :account_id"
|
||||||
|
),
|
||||||
|
{"timestamp": last_sync_time, "account_id": account.id},
|
||||||
|
)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# Even with recent sync, force_full should proceed
|
||||||
|
# (It will fail due to fake caldav URL, but won't be skipped)
|
||||||
|
result = calendar.sync_calendar_account(account.id, force_full=True)
|
||||||
|
|
||||||
|
assert result["status"] != "skipped_recent_check"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_calendar_account_incomplete_caldav_credentials(db_session):
|
||||||
|
"""Test sync fails gracefully with incomplete CalDAV credentials."""
|
||||||
|
account = CalendarAccount(
|
||||||
|
name="Incomplete CalDAV",
|
||||||
|
calendar_type="caldav",
|
||||||
|
caldav_url="https://caldav.example.com",
|
||||||
|
caldav_username=None, # Missing username
|
||||||
|
caldav_password=None, # Missing password
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
db_session.add(account)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
result = calendar.sync_calendar_account(account.id)
|
||||||
|
|
||||||
|
assert result["status"] == "error"
|
||||||
|
assert "incomplete" in result["error"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("memory.workers.tasks.calendar._fetch_caldav_events")
|
||||||
|
@patch("memory.workers.tasks.calendar.sync_calendar_event")
|
||||||
|
def test_sync_calendar_account_caldav_success(
|
||||||
|
mock_sync_event, mock_fetch, caldav_account, db_session
|
||||||
|
):
|
||||||
|
"""Test successful CalDAV sync."""
|
||||||
|
mock_fetch.return_value = [
|
||||||
|
{
|
||||||
|
"title": "Test Event",
|
||||||
|
"start_time": datetime(2024, 1, 15, 10, 0, 0, tzinfo=timezone.utc),
|
||||||
|
"end_time": datetime(2024, 1, 15, 11, 0, 0, tzinfo=timezone.utc),
|
||||||
|
"all_day": False,
|
||||||
|
"description": "",
|
||||||
|
"location": None,
|
||||||
|
"external_id": "caldav-1",
|
||||||
|
"calendar_name": "Default",
|
||||||
|
"recurrence_rule": None,
|
||||||
|
"attendees": [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
mock_sync_event.delay.return_value = Mock(id="task-123")
|
||||||
|
|
||||||
|
result = calendar.sync_calendar_account(caldav_account.id)
|
||||||
|
|
||||||
|
assert result["status"] == "completed"
|
||||||
|
assert result["events_synced"] == 1
|
||||||
|
assert result["calendar_type"] == "caldav"
|
||||||
|
mock_sync_event.delay.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("memory.workers.tasks.calendar._fetch_google_calendar_events")
|
||||||
|
@patch("memory.workers.tasks.calendar.sync_calendar_event")
|
||||||
|
def test_sync_calendar_account_google_success(
|
||||||
|
mock_sync_event, mock_fetch, google_calendar_account, db_session
|
||||||
|
):
|
||||||
|
"""Test successful Google Calendar sync."""
|
||||||
|
mock_fetch.return_value = [
|
||||||
|
{
|
||||||
|
"title": "Google Event",
|
||||||
|
"start_time": datetime(2024, 1, 15, 10, 0, 0, tzinfo=timezone.utc),
|
||||||
|
"end_time": datetime(2024, 1, 15, 11, 0, 0, tzinfo=timezone.utc),
|
||||||
|
"all_day": False,
|
||||||
|
"description": "",
|
||||||
|
"location": None,
|
||||||
|
"external_id": "google-1",
|
||||||
|
"calendar_name": "Primary",
|
||||||
|
"recurrence_rule": None,
|
||||||
|
"attendees": [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
mock_sync_event.delay.return_value = Mock(id="task-456")
|
||||||
|
|
||||||
|
result = calendar.sync_calendar_account(google_calendar_account.id)
|
||||||
|
|
||||||
|
assert result["status"] == "completed"
|
||||||
|
assert result["events_synced"] == 1
|
||||||
|
assert result["calendar_type"] == "google"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_calendar_account_updates_timestamp(caldav_account, db_session):
|
||||||
|
"""Test that sync updates last_sync_at timestamp."""
|
||||||
|
with patch("memory.workers.tasks.calendar._fetch_caldav_events") as mock_fetch:
|
||||||
|
mock_fetch.return_value = []
|
||||||
|
|
||||||
|
assert caldav_account.last_sync_at is None
|
||||||
|
|
||||||
|
calendar.sync_calendar_account(caldav_account.id)
|
||||||
|
|
||||||
|
db_session.refresh(caldav_account)
|
||||||
|
assert caldav_account.last_sync_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tests for sync_all_calendars
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@patch("memory.workers.tasks.calendar.sync_calendar_account")
|
||||||
|
def test_sync_all_calendars(mock_sync_account, db_session):
|
||||||
|
"""Test syncing all active calendar accounts."""
|
||||||
|
account1 = CalendarAccount(
|
||||||
|
name="Account 1",
|
||||||
|
calendar_type="caldav",
|
||||||
|
caldav_url="https://caldav1.example.com",
|
||||||
|
caldav_username="user1",
|
||||||
|
caldav_password="pass1",
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
account2 = CalendarAccount(
|
||||||
|
name="Account 2",
|
||||||
|
calendar_type="caldav",
|
||||||
|
caldav_url="https://caldav2.example.com",
|
||||||
|
caldav_username="user2",
|
||||||
|
caldav_password="pass2",
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
inactive = CalendarAccount(
|
||||||
|
name="Inactive",
|
||||||
|
calendar_type="caldav",
|
||||||
|
caldav_url="https://caldav3.example.com",
|
||||||
|
caldav_username="user3",
|
||||||
|
caldav_password="pass3",
|
||||||
|
active=False,
|
||||||
|
)
|
||||||
|
db_session.add_all([account1, account2, inactive])
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
mock_sync_account.delay.side_effect = [Mock(id="task-1"), Mock(id="task-2")]
|
||||||
|
|
||||||
|
result = calendar.sync_all_calendars()
|
||||||
|
|
||||||
|
# Should only sync active accounts
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0]["task_id"] == "task-1"
|
||||||
|
assert result[1]["task_id"] == "task-2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_all_calendars_no_active(db_session):
|
||||||
|
"""Test sync_all when no active accounts exist."""
|
||||||
|
inactive = CalendarAccount(
|
||||||
|
name="Inactive",
|
||||||
|
calendar_type="caldav",
|
||||||
|
caldav_url="https://caldav.example.com",
|
||||||
|
caldav_username="user",
|
||||||
|
caldav_password="pass",
|
||||||
|
active=False,
|
||||||
|
)
|
||||||
|
db_session.add(inactive)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
result = calendar.sync_all_calendars()
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
@patch("memory.workers.tasks.calendar.sync_calendar_account")
|
||||||
|
def test_sync_all_calendars_force_full(mock_sync_account, caldav_account, db_session):
|
||||||
|
"""Test force_full is passed through to individual syncs."""
|
||||||
|
mock_sync_account.delay.return_value = Mock(id="task-123")
|
||||||
|
|
||||||
|
calendar.sync_all_calendars(force_full=True)
|
||||||
|
|
||||||
|
mock_sync_account.delay.assert_called_once_with(
|
||||||
|
caldav_account.id, force_full=True
|
||||||
|
)
|
||||||
Loading…
x
Reference in New Issue
Block a user