feat: swipe complete, list archive, today habits and calendar subscriptions
ci / docker (push) Successful in 3m20s

This commit is contained in:
2026-09-06 09:33:37 +08:00
parent 13715945d5
commit b937c8ca72
15 changed files with 474 additions and 57 deletions
+35 -10
View File
@@ -329,20 +329,28 @@ async def create_list(
@app.get("/api/v1/lists", response_model=list[ListOut])
async def list_lists(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
query = select(TaskList).where(TaskList.user_id == user.id, TaskList.deleted_at.is_(None))
async def list_lists(
archived: bool = False,
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
deleted_condition = TaskList.deleted_at.is_not(None) if archived else TaskList.deleted_at.is_(None)
query = select(TaskList).where(TaskList.user_id == user.id, deleted_condition)
ordering = (TaskList.is_inbox.desc(), TaskList.position, TaskList.created_at)
return list((await db.scalars(query.order_by(*ordering))).all())
async def _owned_list(db: AsyncSession, user_id: UUID, list_id: UUID) -> TaskList:
item = await db.scalar(
select(TaskList).where(
TaskList.id == list_id,
TaskList.user_id == user_id,
TaskList.deleted_at.is_(None),
)
)
async def _owned_list(
db: AsyncSession,
user_id: UUID,
list_id: UUID,
*,
include_archived: bool = False,
) -> TaskList:
conditions = [TaskList.id == list_id, TaskList.user_id == user_id]
if not include_archived:
conditions.append(TaskList.deleted_at.is_(None))
item = await db.scalar(select(TaskList).where(*conditions))
if item is None:
raise HTTPException(status_code=404, detail="清单不存在")
return item
@@ -394,6 +402,23 @@ async def delete_list(
return Response(status_code=204)
@app.post("/api/v1/lists/{list_id}/restore", response_model=ListOut)
async def restore_list(
list_id: UUID,
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
item = await _owned_list(db, user.id, list_id, include_archived=True)
if item.is_inbox or item.deleted_at is None:
raise HTTPException(status_code=409, detail="清单未归档")
item.deleted_at = None
await db.flush()
audit(db, user.id, "restore", "list", item.id)
await db.commit()
await db.refresh(item)
return item
@app.post("/api/v1/tags", response_model=TagOut, status_code=201)
async def create_tag(
payload: TagCreate,
+11
View File
@@ -188,6 +188,17 @@ class Attachment(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class CalendarSubscription(Base):
__tablename__ = "calendar_subscriptions"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(120))
url: Mapped[str] = mapped_column(Text)
color: Mapped[str] = mapped_column(String(32), default="#f15a29")
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
+179
View File
@@ -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)$")
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,3 +1,3 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-VdV4G6jw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CPHlHtC0.css">
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-BizE1tvq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BR_rdQ3j.css">
</head><body><div id="app"></div></body></html>