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
|
||||
environment:
|
||||
<<: *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:
|
||||
<<: *worker-base
|
||||
|
||||
@ -2029,4 +2029,191 @@ a.folder-item-name:hover {
|
||||
background: #fffbeb;
|
||||
color: #b45309;
|
||||
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 { 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
|
||||
const AuthWrapper = () => {
|
||||
@ -102,6 +102,14 @@ const AuthWrapper = () => {
|
||||
)
|
||||
} />
|
||||
|
||||
<Route path="/ui/calendar" element={
|
||||
isAuthenticated ? (
|
||||
<Calendar />
|
||||
) : (
|
||||
<Navigate to="/ui/login" replace />
|
||||
)
|
||||
} />
|
||||
|
||||
{/* Default redirect */}
|
||||
<Route path="/" element={
|
||||
<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>
|
||||
</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())}>
|
||||
<h3>📝 Notes</h3>
|
||||
<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 Search } from './search'
|
||||
export { default as Sources } from './sources'
|
||||
export { default as Calendar } from './calendar'
|
||||
export { default as LoginPrompt } from './auth/LoginPrompt'
|
||||
export { default as AuthError } from './auth/AuthError'
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
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 {
|
||||
SourceCard,
|
||||
Modal,
|
||||
@ -15,7 +15,7 @@ import {
|
||||
ConfirmDialog,
|
||||
} from './shared'
|
||||
|
||||
type TabType = 'email' | 'feeds' | 'github' | 'google'
|
||||
type TabType = 'email' | 'feeds' | 'github' | 'google' | 'calendar'
|
||||
|
||||
const Sources = () => {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('email')
|
||||
@ -52,6 +52,12 @@ const Sources = () => {
|
||||
>
|
||||
Google Drive
|
||||
</button>
|
||||
<button
|
||||
className={`tab ${activeTab === 'calendar' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('calendar')}
|
||||
>
|
||||
Calendar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sources-content">
|
||||
@ -59,6 +65,7 @@ const Sources = () => {
|
||||
{activeTab === 'feeds' && <FeedsPanel />}
|
||||
{activeTab === 'github' && <GitHubPanel />}
|
||||
{activeTab === 'google' && <GoogleDrivePanel />}
|
||||
{activeTab === 'calendar' && <CalendarPanel />}
|
||||
</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 {
|
||||
accountId: number
|
||||
onSubmit: (data: any) => Promise<void>
|
||||
|
||||
@ -227,12 +227,79 @@ export interface BrowseResponse {
|
||||
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
|
||||
export interface TaskResponse {
|
||||
task_id: 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 = () => {
|
||||
const { apiCall } = useAuth()
|
||||
|
||||
@ -525,6 +592,64 @@ export const useSources = () => {
|
||||
if (!response.ok) throw new Error('Failed to delete Google OAuth config')
|
||||
}, [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 {
|
||||
// Email
|
||||
listEmailAccounts,
|
||||
@ -564,5 +689,13 @@ export const useSources = () => {
|
||||
getGoogleOAuthConfig,
|
||||
uploadGoogleOAuthConfig,
|
||||
deleteGoogleOAuthConfig,
|
||||
// Calendar Accounts
|
||||
listCalendarAccounts,
|
||||
createCalendarAccount,
|
||||
updateCalendarAccount,
|
||||
deleteCalendarAccount,
|
||||
syncCalendarAccount,
|
||||
// Calendar Events
|
||||
getUpcomingEvents,
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ export default defineConfig({
|
||||
'/auth': 'http://localhost:8000',
|
||||
'/health': 'http://localhost:8000',
|
||||
'/email-accounts': 'http://localhost:8000',
|
||||
'/calendar-accounts': 'http://localhost:8000',
|
||||
'/article-feeds': 'http://localhost:8000',
|
||||
'/github': 'http://localhost:8000',
|
||||
'/google-drive': 'http://localhost:8000',
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
discord.py==2.3.2
|
||||
uvicorn==0.29.0
|
||||
uvicorn==0.29.0
|
||||
caldav
|
||||
@ -1,2 +1,3 @@
|
||||
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.meta import meta_mcp
|
||||
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.schedule import schedule_mcp
|
||||
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)
|
||||
mcp.mount(core_mcp, prefix="core")
|
||||
mcp.mount(github_mcp, prefix="github")
|
||||
mcp.mount(organizer_mcp, prefix="organizer")
|
||||
mcp.mount(people_mcp, prefix="people")
|
||||
mcp.mount(schedule_mcp, prefix="schedule")
|
||||
mcp.mount(books_mcp, prefix="books")
|
||||
|
||||
@ -172,7 +172,7 @@ class SimpleOAuthProvider(OAuthProvider):
|
||||
|
||||
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."""
|
||||
with make_session() as session:
|
||||
client = session.get(OAuthClientInformation, client_id)
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
from memory.api.MCP.servers.core import core_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.schedule import schedule_mcp
|
||||
from memory.api.MCP.servers.books import books_mcp
|
||||
@ -10,6 +11,7 @@ from memory.api.MCP.servers.meta import meta_mcp
|
||||
__all__ = [
|
||||
"core_mcp",
|
||||
"github_mcp",
|
||||
"organizer_mcp",
|
||||
"people_mcp",
|
||||
"schedule_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.article_feeds import router as article_feeds_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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -157,6 +158,7 @@ app.include_router(google_drive_router)
|
||||
app.include_router(email_accounts_router)
|
||||
app.include_router(article_feeds_router)
|
||||
app.include_router(github_sources_router)
|
||||
app.include_router(calendar_accounts_router)
|
||||
|
||||
|
||||
# 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),
|
||||
tags=list(feed.tags or []),
|
||||
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),
|
||||
created_at=feed.created_at.isoformat() if feed.created_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),
|
||||
):
|
||||
"""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)
|
||||
if not feed:
|
||||
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"}
|
||||
|
||||
|
||||
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),
|
||||
):
|
||||
"""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)
|
||||
if not account:
|
||||
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"}
|
||||
|
||||
|
||||
@ -124,7 +124,9 @@ def repo_to_response(repo: GithubRepo) -> GithubRepoResponse:
|
||||
check_interval=cast(int, repo.check_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_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),
|
||||
created_at=repo.created_at.isoformat() if repo.created_at else "",
|
||||
)
|
||||
@ -432,7 +434,7 @@ def trigger_repo_sync(
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
"""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 = (
|
||||
db.query(GithubRepo)
|
||||
@ -442,6 +444,10 @@ def trigger_repo_sync(
|
||||
if not repo:
|
||||
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"}
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import cast
|
||||
|
||||
# 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.db.connection import get_session, make_session
|
||||
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
|
||||
|
||||
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:
|
||||
"""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:
|
||||
return config
|
||||
|
||||
@ -116,6 +123,7 @@ class OAuthConfigResponse(BaseModel):
|
||||
# Browse endpoint models
|
||||
class DriveItem(BaseModel):
|
||||
"""A file or folder in Google Drive."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
mime_type: str
|
||||
@ -126,6 +134,7 @@ class DriveItem(BaseModel):
|
||||
|
||||
class BrowseResponse(BaseModel):
|
||||
"""Response from browsing a Google Drive folder."""
|
||||
|
||||
folder_id: str
|
||||
folder_name: str
|
||||
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}")
|
||||
|
||||
# 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:
|
||||
# Update existing config
|
||||
creds = json_data.get("web") or json_data.get("installed") or json_data
|
||||
existing.client_id = creds["client_id"]
|
||||
existing.client_secret = creds["client_secret"]
|
||||
existing.project_id = creds.get("project_id")
|
||||
existing.auth_uri = creds.get("auth_uri", "https://accounts.google.com/o/oauth2/auth")
|
||||
existing.token_uri = creds.get("token_uri", "https://oauth2.googleapis.com/token")
|
||||
existing.auth_uri = creds.get(
|
||||
"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.javascript_origins = creds.get("javascript_origins", [])
|
||||
db.commit()
|
||||
@ -188,7 +203,9 @@ def get_config(
|
||||
db: Session = Depends(get_session),
|
||||
) -> OAuthConfigResponse | None:
|
||||
"""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:
|
||||
return None
|
||||
|
||||
@ -208,7 +225,9 @@ def delete_config(
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
"""Delete OAuth configuration."""
|
||||
config = db.query(GoogleOAuthConfig).filter(GoogleOAuthConfig.name == "default").first()
|
||||
config = (
|
||||
db.query(GoogleOAuthConfig).filter(GoogleOAuthConfig.name == "default").first()
|
||||
)
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="Config not found")
|
||||
|
||||
@ -426,11 +445,15 @@ def browse_folder(
|
||||
else:
|
||||
# Get folder info for a specific folder
|
||||
try:
|
||||
folder_info = service.files().get(
|
||||
fileId=folder_id,
|
||||
fields="name, parents",
|
||||
supportsAllDrives=True,
|
||||
).execute()
|
||||
folder_info = (
|
||||
service.files()
|
||||
.get(
|
||||
fileId=folder_id,
|
||||
fields="name, parents",
|
||||
supportsAllDrives=True,
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
folder_name = folder_info.get("name", folder_id)
|
||||
parents = folder_info.get("parents", [])
|
||||
parent_id = parents[0] if parents else None
|
||||
@ -439,16 +462,20 @@ def browse_folder(
|
||||
query = f"'{folder_id}' in parents and trashed=false"
|
||||
|
||||
try:
|
||||
response = service.files().list(
|
||||
q=query,
|
||||
spaces="drive",
|
||||
fields="nextPageToken, files(id, name, mimeType, size, modifiedTime)",
|
||||
pageToken=page_token,
|
||||
pageSize=page_size,
|
||||
orderBy="folder,name", # Folders first, then by name
|
||||
includeItemsFromAllDrives=True,
|
||||
supportsAllDrives=True,
|
||||
).execute()
|
||||
response = (
|
||||
service.files()
|
||||
.list(
|
||||
q=query,
|
||||
spaces="drive",
|
||||
fields="nextPageToken, files(id, name, mimeType, size, modifiedTime)",
|
||||
pageToken=page_token,
|
||||
pageSize=page_size,
|
||||
orderBy="folder,name", # Folders first, then by name
|
||||
includeItemsFromAllDrives=True,
|
||||
supportsAllDrives=True,
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
except Exception as 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
|
||||
if folder_id == "root":
|
||||
items.append(DriveItem(
|
||||
id="shared",
|
||||
name="Shared with me",
|
||||
mime_type="application/vnd.google-apps.folder",
|
||||
is_folder=True,
|
||||
size=None,
|
||||
modified_at=None,
|
||||
))
|
||||
items.append(
|
||||
DriveItem(
|
||||
id="shared",
|
||||
name="Shared with me",
|
||||
mime_type="application/vnd.google-apps.folder",
|
||||
is_folder=True,
|
||||
size=None,
|
||||
modified_at=None,
|
||||
)
|
||||
)
|
||||
|
||||
for file in response.get("files", []):
|
||||
is_folder = file["mimeType"] == "application/vnd.google-apps.folder"
|
||||
items.append(DriveItem(
|
||||
id=file["id"],
|
||||
name=file["name"],
|
||||
mime_type=file["mimeType"],
|
||||
is_folder=is_folder,
|
||||
size=file.get("size"),
|
||||
modified_at=file.get("modifiedTime"),
|
||||
))
|
||||
items.append(
|
||||
DriveItem(
|
||||
id=file["id"],
|
||||
name=file["name"],
|
||||
mime_type=file["mimeType"],
|
||||
is_folder=is_folder,
|
||||
size=file.get("size"),
|
||||
modified_at=file.get("modifiedTime"),
|
||||
)
|
||||
)
|
||||
|
||||
return BrowseResponse(
|
||||
folder_id=folder_id,
|
||||
@ -586,9 +617,7 @@ def update_folder(
|
||||
include_shared=cast(bool, folder.include_shared),
|
||||
tags=cast(list[str], folder.tags) or [],
|
||||
check_interval=cast(int, folder.check_interval),
|
||||
last_sync_at=(
|
||||
folder.last_sync_at.isoformat() if folder.last_sync_at else None
|
||||
),
|
||||
last_sync_at=(folder.last_sync_at.isoformat() if folder.last_sync_at else None),
|
||||
active=cast(bool, folder.active),
|
||||
exclude_folder_ids=cast(list[str], folder.exclude_folder_ids) or [],
|
||||
)
|
||||
@ -629,7 +658,7 @@ def trigger_sync(
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
"""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 = (
|
||||
db.query(GoogleFolder)
|
||||
@ -643,7 +672,11 @@ def trigger_sync(
|
||||
if not folder:
|
||||
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"}
|
||||
|
||||
|
||||
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"
|
||||
PROACTIVE_ROOT = "memory.workers.tasks.proactive"
|
||||
GOOGLE_ROOT = "memory.workers.tasks.google_drive"
|
||||
CALENDAR_ROOT = "memory.workers.tasks.calendar"
|
||||
ADD_DISCORD_MESSAGE = f"{DISCORD_ROOT}.add_discord_message"
|
||||
EDIT_DISCORD_MESSAGE = f"{DISCORD_ROOT}.edit_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_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:
|
||||
protocol = settings.CELERY_BROKER_TYPE
|
||||
@ -142,6 +148,7 @@ app.conf.update(
|
||||
f"{PEOPLE_ROOT}.*": {"queue": f"{settings.CELERY_QUEUE_PREFIX}-people"},
|
||||
f"{PROACTIVE_ROOT}.*": {"queue": f"{settings.CELERY_QUEUE_PREFIX}-discord"},
|
||||
f"{GOOGLE_ROOT}.*": {"queue": f"{settings.CELERY_QUEUE_PREFIX}-google"},
|
||||
f"{CALENDAR_ROOT}.*": {"queue": f"{settings.CELERY_QUEUE_PREFIX}-calendar"},
|
||||
},
|
||||
beat_schedule={
|
||||
"sync-github-repos-hourly": {
|
||||
@ -156,6 +163,10 @@ app.conf.update(
|
||||
"task": SYNC_ALL_GOOGLE_ACCOUNTS,
|
||||
"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,
|
||||
Note,
|
||||
GoogleDoc,
|
||||
Task,
|
||||
CalendarEvent,
|
||||
MailMessagePayload,
|
||||
EmailAttachmentPayload,
|
||||
AgentObservationPayload,
|
||||
@ -32,6 +34,8 @@ from memory.common.db.models.source_items import (
|
||||
NotePayload,
|
||||
ForumPostPayload,
|
||||
GoogleDocPayload,
|
||||
TaskPayload,
|
||||
CalendarEventPayload,
|
||||
)
|
||||
from memory.common.db.models.discord import (
|
||||
DiscordServer,
|
||||
@ -62,6 +66,7 @@ from memory.common.db.models.sources import (
|
||||
GoogleOAuthConfig,
|
||||
GoogleAccount,
|
||||
GoogleFolder,
|
||||
CalendarAccount,
|
||||
)
|
||||
from memory.common.db.models.users import (
|
||||
User,
|
||||
@ -89,6 +94,8 @@ Payload = (
|
||||
| MailMessagePayload
|
||||
| PersonPayload
|
||||
| GoogleDocPayload
|
||||
| TaskPayload
|
||||
| CalendarEventPayload
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@ -114,6 +121,10 @@ __all__ = [
|
||||
"Note",
|
||||
"GoogleDoc",
|
||||
"GoogleDocPayload",
|
||||
"Task",
|
||||
"TaskPayload",
|
||||
"CalendarEvent",
|
||||
"CalendarEventPayload",
|
||||
# Observations
|
||||
"ObservationContradiction",
|
||||
"ReactionPattern",
|
||||
@ -123,6 +134,10 @@ __all__ = [
|
||||
# People
|
||||
"Person",
|
||||
"PersonPayload",
|
||||
# Calendar
|
||||
"CalendarAccount",
|
||||
"CalendarEvent",
|
||||
"CalendarEventPayload",
|
||||
# Sources
|
||||
"Book",
|
||||
"ArticleFeed",
|
||||
@ -132,6 +147,9 @@ __all__ = [
|
||||
"GoogleOAuthConfig",
|
||||
"GoogleAccount",
|
||||
"GoogleFolder",
|
||||
"CalendarAccount",
|
||||
"CalendarEvent",
|
||||
"CalendarEventPayload",
|
||||
"DiscordServer",
|
||||
"DiscordChannel",
|
||||
"DiscordUser",
|
||||
|
||||
@ -1302,3 +1302,226 @@ class GoogleDoc(SourceItem):
|
||||
@classmethod
|
||||
def get_collections(cls) -> list[str]:
|
||||
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"),
|
||||
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_PORT = os.getenv("REDIS_PORT", "6379")
|
||||
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:
|
||||
REDIS_URL = f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}"
|
||||
else:
|
||||
@ -180,7 +180,9 @@ ENABLE_BM25_SEARCH = boolean_env("ENABLE_BM25_SEARCH", True)
|
||||
ENABLE_SEARCH_SCORING = boolean_env("ENABLE_SEARCH_SCORING", True)
|
||||
ENABLE_HYDE_EXPANSION = boolean_env("ENABLE_HYDE_EXPANSION", True)
|
||||
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)
|
||||
RERANK_MODEL = os.getenv("RERANK_MODEL", "rerank-2-lite")
|
||||
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_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "")
|
||||
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 = [
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
"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 (
|
||||
backup,
|
||||
blogs,
|
||||
calendar,
|
||||
comic,
|
||||
discord,
|
||||
ebook,
|
||||
@ -23,6 +24,7 @@ from memory.workers.tasks import (
|
||||
__all__ = [
|
||||
"backup",
|
||||
"blogs",
|
||||
"calendar",
|
||||
"comic",
|
||||
"discord",
|
||||
"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