feat: swipe complete, list archive, today habits and calendar subscriptions
ci / docker (push) Successful in 3m20s
ci / docker (push) Successful in 3m20s
This commit is contained in:
+179
@@ -1,6 +1,12 @@
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import ipaddress
|
||||
import re
|
||||
import socket
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
@@ -17,6 +23,7 @@ from .db import get_db
|
||||
from .models import (
|
||||
Attachment,
|
||||
AuditLog,
|
||||
CalendarSubscription,
|
||||
Folder,
|
||||
Habit,
|
||||
HabitLog,
|
||||
@@ -251,6 +258,178 @@ async def delete_recurrence(recurrence_id: UUID, scope: str = Query("all", patte
|
||||
await db.commit(); return Response(status_code=204)
|
||||
|
||||
|
||||
class CalendarSubscriptionCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
url: str = Field(min_length=1, max_length=2000)
|
||||
color: str = Field(default="#f15a29", min_length=1, max_length=32)
|
||||
|
||||
|
||||
def calendar_subscription_dict(row: CalendarSubscription):
|
||||
return {"id": row.id, "name": row.name, "url": row.url, "color": row.color, "enabled": row.enabled}
|
||||
|
||||
|
||||
def parse_ics_datetime(value: str):
|
||||
raw = value.strip()
|
||||
if len(raw) == 8 and raw.isdigit():
|
||||
return datetime.strptime(raw, "%Y%m%d").replace(tzinfo=UTC), True
|
||||
cleaned = raw.removesuffix("Z")
|
||||
parsed = datetime.fromisoformat(cleaned)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
return parsed.astimezone(UTC), False
|
||||
|
||||
|
||||
def parse_ics_events(content: str, source_name: str, color: str):
|
||||
events = []
|
||||
current = None
|
||||
for raw_line in content.splitlines():
|
||||
line = raw_line.strip()
|
||||
if line == "BEGIN:VEVENT":
|
||||
current = {}
|
||||
continue
|
||||
if line == "END:VEVENT":
|
||||
if current and current.get("title") and current.get("starts_at"):
|
||||
events.append({
|
||||
"id": current.get("uid") or f"{source_name}-{current['starts_at'].isoformat()}-{current['title']}",
|
||||
"title": current["title"],
|
||||
"starts_at": current["starts_at"],
|
||||
"ends_at": current.get("ends_at"),
|
||||
"all_day": current.get("all_day", False),
|
||||
"source_name": source_name,
|
||||
"color": color,
|
||||
})
|
||||
current = None
|
||||
continue
|
||||
if current is None or ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
field = key.split(";", 1)[0].upper()
|
||||
if field == "SUMMARY":
|
||||
current["title"] = value.strip()
|
||||
elif field == "UID":
|
||||
current["uid"] = value.strip()
|
||||
elif field in {"DTSTART", "DTEND"}:
|
||||
try:
|
||||
dt, all_day = parse_ics_datetime(value)
|
||||
except ValueError:
|
||||
continue
|
||||
current["all_day"] = current.get("all_day", False) or all_day
|
||||
current["starts_at" if field == "DTSTART" else "ends_at"] = dt
|
||||
return events
|
||||
|
||||
|
||||
def _validate_public_calendar_url(url: str):
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||
raise HTTPException(422, "日历订阅只支持 http/https 链接")
|
||||
try:
|
||||
addresses = socket.getaddrinfo(parsed.hostname, None)
|
||||
except socket.gaierror as exc:
|
||||
raise HTTPException(422, "日历订阅域名无法解析") from exc
|
||||
for item in addresses:
|
||||
ip = ipaddress.ip_address(item[4][0])
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved:
|
||||
raise HTTPException(422, "日历订阅不能指向内网地址")
|
||||
|
||||
|
||||
def fetch_calendar_events(url: str, source_name: str, color: str):
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
current_url = url
|
||||
opener = urllib.request.build_opener(NoRedirect)
|
||||
for _ in range(4):
|
||||
_validate_public_calendar_url(current_url)
|
||||
try:
|
||||
with opener.open(current_url, timeout=15) as response:
|
||||
content_type = response.headers.get_content_type()
|
||||
if content_type not in {"text/calendar", "text/plain", "application/octet-stream"}:
|
||||
raise HTTPException(422, "订阅链接没有返回 ICS 日历内容")
|
||||
body = response.read(2_000_001)
|
||||
if len(body) > 2_000_000:
|
||||
raise HTTPException(413, "日历订阅内容超过 2MB")
|
||||
return parse_ics_events(body.decode("utf-8", errors="ignore"), source_name, color)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code not in {301, 302, 303, 307, 308}:
|
||||
raise HTTPException(502, f"订阅拉取失败: HTTP {exc.code}") from exc
|
||||
location = exc.headers.get("Location")
|
||||
if not location:
|
||||
raise HTTPException(502, "订阅重定向缺少目标地址") from exc
|
||||
current_url = urllib.parse.urljoin(current_url, location)
|
||||
except urllib.error.URLError as exc:
|
||||
raise HTTPException(502, f"订阅拉取失败: {exc.reason}") from exc
|
||||
raise HTTPException(502, "日历订阅重定向次数过多")
|
||||
|
||||
|
||||
@router.get("/calendar-subscriptions")
|
||||
async def list_calendar_subscriptions(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
rows = (await db.scalars(select(CalendarSubscription).where(CalendarSubscription.user_id == user.id).order_by(CalendarSubscription.created_at))).all()
|
||||
return [calendar_subscription_dict(row) for row in rows]
|
||||
|
||||
|
||||
@router.post("/calendar-subscriptions", status_code=201)
|
||||
async def create_calendar_subscription(payload: CalendarSubscriptionCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
_validate_public_calendar_url(payload.url)
|
||||
row = CalendarSubscription(user_id=user.id, **payload.model_dump())
|
||||
db.add(row)
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return calendar_subscription_dict(row)
|
||||
|
||||
|
||||
@router.get("/calendar-subscriptions/today")
|
||||
async def today_calendar_events(
|
||||
day: date,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
rows = list((await db.scalars(select(CalendarSubscription).where(CalendarSubscription.user_id == user.id, CalendarSubscription.enabled.is_(True)).order_by(CalendarSubscription.created_at))).all())
|
||||
if not rows:
|
||||
return []
|
||||
tomorrow = day + timedelta(days=1)
|
||||
day_start = datetime.combine(day, time.min, tzinfo=UTC)
|
||||
day_end = datetime.combine(tomorrow, time.min, tzinfo=UTC)
|
||||
|
||||
async def pull(row: CalendarSubscription):
|
||||
return await asyncio.to_thread(fetch_calendar_events, row.url, row.name, row.color)
|
||||
|
||||
results = await asyncio.gather(*(pull(row) for row in rows), return_exceptions=True)
|
||||
events = []
|
||||
for row, result in zip(rows, results, strict=True):
|
||||
if isinstance(result, Exception):
|
||||
continue
|
||||
for item in result:
|
||||
starts_at = item["starts_at"]
|
||||
ends_at = item.get("ends_at")
|
||||
if item["all_day"]:
|
||||
if not (day_start <= starts_at < day_end):
|
||||
continue
|
||||
elif not (day_start <= starts_at < day_end):
|
||||
continue
|
||||
events.append({
|
||||
"id": item["id"],
|
||||
"title": item["title"],
|
||||
"starts_at": starts_at.isoformat(),
|
||||
"ends_at": ends_at.isoformat() if ends_at else None,
|
||||
"all_day": item["all_day"],
|
||||
"source_name": row.name,
|
||||
"color": row.color,
|
||||
})
|
||||
events.sort(key=lambda item: (item["all_day"] is False, item["starts_at"], item["title"]))
|
||||
return events
|
||||
|
||||
|
||||
@router.delete("/calendar-subscriptions/{subscription_id}", status_code=204)
|
||||
async def delete_calendar_subscription(subscription_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = await db.scalar(select(CalendarSubscription).where(CalendarSubscription.id == subscription_id, CalendarSubscription.user_id == user.id))
|
||||
if not row:
|
||||
raise HTTPException(404, "日历订阅不存在")
|
||||
await db.delete(row)
|
||||
await db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
class HabitCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
kind: str = Field("boolean", pattern="^(boolean|numeric)$")
|
||||
|
||||
Reference in New Issue
Block a user