From f3ad1eec03f47516b94a869f12cd7776ce097700 Mon Sep 17 00:00:00 2001 From: bboysoul Date: Sun, 20 Sep 2026 16:37:43 +0800 Subject: [PATCH] feat: add iCal calendar subscriptions --- backend/backup/archive.py | 18 +- backend/backup/service.py | 16 +- backend/calendar.py | 248 ++++++++++++++++++ backend/calendar_router.py | 224 ++++++++++++++++ backend/main.py | 2 + backend/models.py | 17 ++ frontend/e2e/backup-roundtrip.spec.ts | 7 +- frontend/e2e/mobile-ui.spec.ts | 27 +- frontend/e2e/ui-reduction-acceptance.spec.ts | 2 +- frontend/src/App.vue | 10 +- frontend/src/CalendarIntegration.test.ts | 8 + frontend/src/CalendarPanel.test.ts | 113 ++++++++ frontend/src/CalendarPanel.vue | 64 +++++ frontend/src/MemoIntegration.test.ts | 6 +- frontend/src/calendar.css | 1 + frontend/src/lib/mvp-utils.test.ts | 2 + frontend/src/lib/mvp-utils.ts | 4 +- frontend/src/main.ts | 1 + frontend/src/style.css | 2 +- frontend/src/style.test.ts | 10 +- .../versions/0020_calendar_subscriptions.py | 46 +++- pyproject.toml | 2 + tests/test_calendar_backup_compat.py | 60 +++++ tests/test_calendar_subscriptions.py | 212 +++++++++++++++ uv.lock | 47 ++++ 25 files changed, 1108 insertions(+), 41 deletions(-) create mode 100644 backend/calendar.py create mode 100644 backend/calendar_router.py create mode 100644 frontend/src/CalendarIntegration.test.ts create mode 100644 frontend/src/CalendarPanel.test.ts create mode 100644 frontend/src/CalendarPanel.vue create mode 100644 frontend/src/calendar.css create mode 100644 tests/test_calendar_backup_compat.py create mode 100644 tests/test_calendar_subscriptions.py diff --git a/backend/backup/archive.py b/backend/backup/archive.py index ca928d7..5b82bd8 100644 --- a/backend/backup/archive.py +++ b/backend/backup/archive.py @@ -152,6 +152,18 @@ def parse_archive_path(path: Path, *, max_archive_bytes: int = MAX_ARCHIVE_BYTES content_names = set(names) - {"manifest.json"} if set(checksums) != content_names: raise backup_error("backup_manifest_mismatch", "manifest 与 ZIP 条目不一致") + # Calendar subscriptions are an additive backup-v2 entity. Accept archives + # produced by older helpers that checksum the new file but omit its count. + optional_entities = {"calendar_subscriptions"} + undeclared_optional = { + f"data/{name}.json" for name in optional_entities - set(declared_entities) + } + if set(declared_entities) != { + name[5:-5] + for name in content_names - undeclared_optional + if name.startswith("data/") and name.endswith(".json") + }: + raise backup_error("backup_manifest_mismatch", "manifest 实体清单不一致") entities: dict[str, list[dict]] = {} files: dict[str, StagedBlob] = {} for index, name in enumerate(sorted(content_names)): @@ -170,7 +182,11 @@ def parse_archive_path(path: Path, *, max_archive_bytes: int = MAX_ARCHIVE_BYTES if not isinstance(checksums[name], str) or actual_digest != checksums[name]: raise backup_error("backup_checksum_mismatch", "备份校验和不匹配") if set(declared_entities) != set(entities): - raise backup_error("backup_manifest_mismatch", "manifest 实体清单不一致") + required_declared = set(entities) - optional_entities + if set(declared_entities) != required_declared or any( + entities.get(name) for name in optional_entities - set(declared_entities) + ): + raise backup_error("backup_manifest_mismatch", "manifest 实体清单不一致") if any(type(count) is not int or count < 0 or count != len(entities[name]) for name, count in declared_entities.items()): raise backup_error("backup_manifest_mismatch", "manifest 实体数量不一致") except HTTPException: diff --git a/backend/backup/service.py b/backend/backup/service.py index 42ad0cc..2dd0718 100644 --- a/backend/backup/service.py +++ b/backend/backup/service.py @@ -19,6 +19,7 @@ from backend.models import ( BackupImport, BackupImportEntity, BackupPreflight, + CalendarSubscription, Countdown, Folder, Habit, @@ -65,6 +66,7 @@ ENTITY_MODELS = { "habit_pauses": HabitPause, "countdowns": Countdown, "memos": Memo, + "calendar_subscriptions": CalendarSubscription, "attachments": Attachment, } RELATIONS = { @@ -392,6 +394,9 @@ def _validate_recurrence_graph(parsed: ParsedArchive) -> None: def validate_archive(parsed: ParsedArchive) -> None: unknown = set(parsed.entities) - set(ENTITY_MODELS) + # Calendar subscriptions were introduced after backup v2. Treat their + # absence as an empty collection so archives from older Dodo releases remain restorable. + parsed.entities.setdefault("calendar_subscriptions", []) missing = set(ENTITY_MODELS) - set(parsed.entities) if unknown: raise backup_error("backup_entity_unknown", "备份包含未知实体") @@ -611,7 +616,16 @@ async def restore_v2( await db.execute(delete(HabitPause).where( HabitPause.habit_id.in_(select(Habit.id).where(Habit.user_id == user.id)) )) - for model in (Attachment, Memo, Countdown, Task, Habit, TaskList, Folder): + for model in ( + Attachment, + CalendarSubscription, + Memo, + Countdown, + Task, + Habit, + TaskList, + Folder, + ): await db.execute(delete(model).where(model.user_id == user.id)) await db.execute(delete(BackupImportEntity).where(BackupImportEntity.user_id == user.id)) await db.execute(delete(BackupImport).where(BackupImport.user_id == user.id)) diff --git a/backend/calendar.py b/backend/calendar.py new file mode 100644 index 0000000..fe623f2 --- /dev/null +++ b/backend/calendar.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +import http.client +import ipaddress +import socket +import ssl +import urllib.parse +from dataclasses import dataclass +from datetime import UTC, date, datetime, time, timedelta +from itertools import islice +from typing import Any +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from dateutil.rrule import rrulestr +from fastapi import HTTPException +from icalendar import Calendar + +MAX_ICS_BYTES = 2_000_000 +MAX_REDIRECTS = 3 +DEFAULT_RECURRENCE_LIMIT = 10_000 +TIMEOUT_SECONDS = 10 +_ALLOWED_CONTENT_TYPES = {"text/calendar", "text/plain", "application/octet-stream"} + + +@dataclass(frozen=True) +class FetchResult: + content: bytes | None + etag: str | None + last_modified: str | None + not_modified: bool + + +def _is_global(value: str) -> bool: + return ipaddress.ip_address(value.split("%", 1)[0]).is_global + + +def validate_calendar_url(url: str) -> tuple[str, str, int]: + try: + parsed = urllib.parse.urlsplit(url) + port = parsed.port or (443 if parsed.scheme == "https" else 80) + except ValueError as exc: + raise HTTPException(422, "invalid calendar URL") from exc + if ( + parsed.scheme not in {"http", "https"} + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.fragment + ): + raise HTTPException(422, "calendar URL must be HTTP(S) without userinfo or fragment") + try: + infos = socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_STREAM) + except socket.gaierror as exc: + raise HTTPException(422, "calendar host cannot be resolved") from exc + addresses = {item[4][0] for item in infos} + if not addresses or not all(_is_global(address) for address in addresses): + raise HTTPException(422, "calendar host must resolve only to public addresses") + return url, min(addresses), port + + +def _request(url: str, headers: dict[str, str]) -> tuple[int, list[tuple[str, str]], str, bytes]: + _, ip, port = validate_calendar_url(url) + parsed = urllib.parse.urlsplit(url) + target = f"[{ip}]" if ":" in ip else ip + host = parsed.hostname or "" + if parsed.port: + host = f"{host}:{parsed.port}" + request_headers = {"Host": host, "User-Agent": "dodo-calendar-fetch/1.0", **headers} + path = urllib.parse.urlunsplit(("", "", parsed.path or "/", parsed.query, "")) + connection: http.client.HTTPConnection + if parsed.scheme == "https": + connection = http.client.HTTPSConnection(target, port=port, timeout=TIMEOUT_SECONDS) + else: + connection = http.client.HTTPConnection(target, port=port, timeout=TIMEOUT_SECONDS) + try: + if parsed.scheme == "https": + raw = socket.create_connection((ip, port), timeout=TIMEOUT_SECONDS) + connection.sock = ssl.create_default_context().wrap_socket( + raw, server_hostname=parsed.hostname + ) + connection.request("GET", path, headers=request_headers) + response = connection.getresponse() + length = response.getheader("Content-Length") + if length and int(length) > MAX_ICS_BYTES: + raise HTTPException(413, "calendar exceeds 2MB") + body = response.read(MAX_ICS_BYTES + 1) + return response.status, response.getheaders(), response.getheader("Content-Type") or "", body + except HTTPException: + raise + except (OSError, http.client.HTTPException, ValueError) as exc: + raise HTTPException(502, "calendar upstream unavailable") from exc + finally: + connection.close() + + +def fetch_calendar(url: str, *, etag: str | None = None, last_modified: str | None = None) -> FetchResult: + headers = {} + if etag: + headers["If-None-Match"] = etag + if last_modified: + headers["If-Modified-Since"] = last_modified + current = url + for redirect_count in range(MAX_REDIRECTS + 1): + status, response_headers, content_type, body = _request(current, headers) + header_map = {key.lower(): value for key, value in response_headers} + if status in {301, 302, 303, 307, 308}: + if redirect_count == MAX_REDIRECTS or not header_map.get("location"): + raise HTTPException(502, "calendar redirect limit exceeded") + current = urllib.parse.urljoin(current, header_map["location"]) + validate_calendar_url(current) + continue + if status == 304: + return FetchResult(None, etag, last_modified, True) + if status >= 400: + raise HTTPException(502, f"calendar upstream returned HTTP {status}") + if len(body) > MAX_ICS_BYTES: + raise HTTPException(413, "calendar exceeds 2MB") + if content_type.split(";", 1)[0].lower() not in _ALLOWED_CONTENT_TYPES: + raise HTTPException(422, "URL did not return an iCalendar document") + return FetchResult(body, header_map.get("etag"), header_map.get("last-modified"), False) + raise HTTPException(502, "calendar redirect limit exceeded") + + +def _localize(value: date | datetime, timezone: ZoneInfo) -> tuple[datetime, bool]: + if isinstance(value, datetime): + return (value if value.tzinfo else value.replace(tzinfo=timezone)), False + return datetime.combine(value, time.min, tzinfo=timezone), True + + +def _utc(value: date | datetime, timezone: ZoneInfo) -> tuple[datetime, bool]: + localized, all_day = _localize(value, timezone) + return localized.astimezone(UTC), all_day + + +def _duration(event: Any, starts_at: datetime, all_day: bool, timezone: ZoneInfo) -> timedelta: + if event.get("dtend"): + ends_at, _ = _utc(event.decoded("dtend"), timezone) + return max(ends_at - starts_at, timedelta()) + if event.get("duration"): + return event.decoded("duration") + return timedelta(days=1) if all_day else timedelta(hours=1) + + +def _exdates(event: Any, timezone: ZoneInfo) -> set[datetime]: + values = event.get("exdate") + if not values: + return set() + excluded = set() + for item in values if isinstance(values, list) else [values]: + for value in getattr(item, "dts", []): + excluded.add(_utc(value.dt, timezone)[0]) + return excluded + + +def _overlaps(start: datetime, end: datetime, window_start: datetime, window_end: datetime) -> bool: + return start < window_end and end > window_start + + +def _event_dict(event: Any, source_id: str, source: str, color: str, start: datetime, end: datetime, all_day: bool) -> dict: + uid = str(event.get("uid") or "") + title = str(event.get("summary") or "Untitled event").strip() or "Untitled event" + return { + "id": f"{uid or title}:{start.isoformat()}", + "title": title, + "starts_at": start, + "ends_at": end, + "all_day": all_day, + "source_id": source_id, + "source_name": source, + "color": color, + } + + +def parse_ics_events( + content: bytes | str, + source_name: str, + color: str, + window_start: datetime, + window_end: datetime, + timezone_name: str, + *, + source_id: str = "", + recurrence_limit: int = DEFAULT_RECURRENCE_LIMIT, +) -> list[dict]: + try: + timezone = ZoneInfo(timezone_name) + except ZoneInfoNotFoundError as exc: + raise ValueError("invalid timezone") from exc + try: + calendar = Calendar.from_ical(content) + except Exception as exc: + raise ValueError("invalid iCalendar document") from exc + components = list(calendar.walk("VEVENT")) + master_durations = {} + for event in components: + if event.get("dtstart") and not event.get("recurrence-id"): + master_start, master_all_day = _utc(event.decoded("dtstart"), timezone) + master_durations[str(event.get("uid") or "")] = _duration( + event, master_start, master_all_day, timezone + ) + overrides = {} + for event in components: + recurrence_id = event.get("recurrence-id") + if recurrence_id: + overrides[(str(event.get("uid") or ""), _utc(event.decoded("recurrence-id"), timezone)[0])] = event + events = [] + for event in components: + if not event.get("dtstart") or event.get("recurrence-id") or str(event.get("status") or "").upper() == "CANCELLED": + continue + local_start, all_day = _localize(event.decoded("dtstart"), timezone) + start = local_start.astimezone(UTC) + duration = _duration(event, start, all_day, timezone) + uid = str(event.get("uid") or "") + if event.get("rrule"): + try: + rule = rrulestr(event.get("rrule").to_ical().decode(), dtstart=local_start) + bounded = rule.xafter(window_start - duration, count=recurrence_limit + 1, inc=True) + occurrences = [item for item in islice(bounded, recurrence_limit + 1) if item < window_end] + except Exception as exc: + raise ValueError("invalid recurrence rule") from exc + if len(occurrences) > recurrence_limit: + raise ValueError("recurrence limit exceeded") + excluded = _exdates(event, timezone) + for occurrence in occurrences: + occurrence = (occurrence if occurrence.tzinfo else occurrence.replace(tzinfo=timezone)).astimezone(UTC) + if occurrence in excluded or (uid, occurrence) in overrides: + continue + end = occurrence + duration + if _overlaps(occurrence, end, window_start, window_end): + events.append(_event_dict(event, source_id, source_name, color, occurrence, end, all_day)) + else: + end = start + duration + if _overlaps(start, end, window_start, window_end): + events.append(_event_dict(event, source_id, source_name, color, start, end, all_day)) + for event in overrides.values(): + if not event.get("dtstart") or str(event.get("status") or "").upper() == "CANCELLED": + continue + start, all_day = _utc(event.decoded("dtstart"), timezone) + uid = str(event.get("uid") or "") + duration = ( + _duration(event, start, all_day, timezone) + if event.get("dtend") or event.get("duration") + else master_durations.get(uid, timedelta(days=1) if all_day else timedelta(hours=1)) + ) + end = start + duration + if _overlaps(start, end, window_start, window_end): + events.append(_event_dict(event, source_id, source_name, color, start, end, all_day)) + return sorted(events, key=lambda item: (item["starts_at"], item["title"], item["id"])) diff --git a/backend/calendar_router.py b/backend/calendar_router.py new file mode 100644 index 0000000..49e17e5 --- /dev/null +++ b/backend/calendar_router.py @@ -0,0 +1,224 @@ +from datetime import UTC, datetime, timedelta +from uuid import UUID +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from fastapi import APIRouter, Depends, HTTPException, Query, Response +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from . import calendar as calendar_service +from .auth import current_user +from .db import get_db +from .models import CalendarSubscription, User, utcnow + +router = APIRouter(prefix="/api/v1", tags=["calendar"]) +MAX_WINDOW = timedelta(days=366) + + +class SubscriptionCreate(BaseModel): + name: str = Field(min_length=1, max_length=120) + url: str = Field(min_length=1, max_length=2000) + color: str = Field(default="#f15a29", pattern=r"^#[0-9A-Fa-f]{6}$") + enabled: bool = True + + @field_validator("name") + @classmethod + def clean_name(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("name cannot be blank") + return value + + +class SubscriptionUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=120) + url: str | None = Field(default=None, min_length=1, max_length=2000) + color: str | None = Field(default=None, pattern=r"^#[0-9A-Fa-f]{6}$") + enabled: bool | None = None + + @field_validator("name") + @classmethod + def clean_name(cls, value: str | None) -> str | None: + if value is None: + return None + value = value.strip() + if not value: + raise ValueError("name cannot be blank") + return value + + @model_validator(mode="after") + def reject_nulls(self): + for field in self.model_fields_set: + if getattr(self, field) is None: + raise ValueError(f"{field} cannot be null") + return self + + +class SubscriptionOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: UUID + name: str + url: str + color: str + enabled: bool + refreshed_at: datetime | None + last_error: str | None + stale: bool + + +def _out(row: CalendarSubscription) -> dict: + return { + "id": row.id, + "name": row.name, + "url": row.url, + "color": row.color, + "enabled": row.enabled, + "refreshed_at": row.refreshed_at, + "last_error": row.last_error, + "stale": bool(row.last_error and row.ics_cache), + } + + +async def _owned(db: AsyncSession, user_id: UUID, subscription_id: UUID) -> CalendarSubscription: + row = await db.scalar(select(CalendarSubscription).where( + CalendarSubscription.id == subscription_id, + CalendarSubscription.user_id == user_id, + )) + if row is None: + raise HTTPException(404, "calendar subscription not found") + return row + + +async def _refresh(db: AsyncSession, row: CalendarSubscription) -> None: + try: + result = await __import__("asyncio").to_thread( + calendar_service.fetch_calendar, row.url, etag=row.etag, last_modified=row.last_modified + ) + if result.not_modified: + if not row.ics_cache: + raise HTTPException(502, "calendar returned not modified without cache") + elif result.content is not None: + # Parse before replacing a known-good cache. + calendar_service.parse_ics_events( + result.content, + row.name, + row.color, + datetime.now(UTC) - timedelta(days=1), + datetime.now(UTC) + timedelta(days=1), + "UTC", + ) + row.ics_cache = result.content.decode("utf-8-sig") + row.etag = result.etag + row.last_modified = result.last_modified + row.refreshed_at = utcnow() + row.last_error = None + except Exception as exc: + row.last_error = exc.detail if isinstance(exc, HTTPException) else str(exc) + if not row.ics_cache: + await db.rollback() + raise HTTPException(502, row.last_error) from exc + await db.commit() + + +@router.get("/calendar-subscriptions", response_model=list[SubscriptionOut]) +async def list_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, CalendarSubscription.id))).all() + return [_out(row) for row in rows] + + +@router.post("/calendar-subscriptions", response_model=SubscriptionOut, status_code=201) +async def create_subscription( + payload: SubscriptionCreate, + user: User = Depends(current_user), + db: AsyncSession = Depends(get_db), +): + calendar_service.validate_calendar_url(payload.url) + row = CalendarSubscription(user_id=user.id, **payload.model_dump()) + db.add(row) + await db.flush() + await _refresh(db, row) + await db.refresh(row) + return _out(row) + + +@router.patch("/calendar-subscriptions/{subscription_id}", response_model=SubscriptionOut) +async def update_subscription( + subscription_id: UUID, + payload: SubscriptionUpdate, + user: User = Depends(current_user), + db: AsyncSession = Depends(get_db), +): + row = await _owned(db, user.id, subscription_id) + changes = payload.model_dump(exclude_unset=True) + if "url" in changes: + calendar_service.validate_calendar_url(changes["url"]) + if changes["url"] != row.url: + row.ics_cache = row.etag = row.last_modified = row.refreshed_at = row.last_error = None + for key, value in changes.items(): + setattr(row, key, value) + await db.commit() + await db.refresh(row) + return _out(row) + + +@router.delete("/calendar-subscriptions/{subscription_id}", status_code=204) +async def delete_subscription( + subscription_id: UUID, + user: User = Depends(current_user), + db: AsyncSession = Depends(get_db), +): + row = await _owned(db, user.id, subscription_id) + await db.delete(row) + await db.commit() + return Response(status_code=204) + + +@router.post("/calendar-subscriptions/{subscription_id}/refresh", response_model=SubscriptionOut) +async def refresh_subscription( + subscription_id: UUID, + user: User = Depends(current_user), + db: AsyncSession = Depends(get_db), +): + row = await _owned(db, user.id, subscription_id) + await _refresh(db, row) + await db.refresh(row) + return _out(row) + + +@router.get("/calendar-events") +async def calendar_events( + start: datetime = Query(), + end: datetime = Query(), + user: User = Depends(current_user), + db: AsyncSession = Depends(get_db), +): + if start.tzinfo is None or end.tzinfo is None or end <= start or end - start > MAX_WINDOW: + raise HTTPException(422, "start/end must be timezone-aware and span at most 366 days") + try: + ZoneInfo(user.timezone) + except ZoneInfoNotFoundError as exc: + raise HTTPException(422, "user timezone is invalid") from exc + rows = (await db.scalars(select(CalendarSubscription).where( + CalendarSubscription.user_id == user.id, + CalendarSubscription.enabled.is_(True), + ).order_by(CalendarSubscription.created_at, CalendarSubscription.id))).all() + events = [] + sources = [] + for row in rows: + if not row.ics_cache: + await _refresh(db, row) + try: + parsed = calendar_service.parse_ics_events( + row.ics_cache or "", row.name, row.color, start, end, user.timezone, + source_id=str(row.id), + ) + events.extend(parsed) + except ValueError as exc: + row.last_error = str(exc) + await db.commit() + sources.append({"id": row.id, "name": row.name, "stale": bool(row.last_error)}) + events.sort(key=lambda item: (item["starts_at"], item["title"], item["id"])) + return {"events": events, "sources": sources} diff --git a/backend/main.py b/backend/main.py index b538bcf..5da7cca 100644 --- a/backend/main.py +++ b/backend/main.py @@ -30,6 +30,7 @@ from .auth import ( verify_password, ) from .backup import router as backup_router +from .calendar_router import router as calendar_router from .db import create_schema, get_db from .models import ( AppState, @@ -120,6 +121,7 @@ async def openapi(_: User = Depends(current_user)): app.include_router(mvp_router) app.include_router(backup_router) +app.include_router(calendar_router) logger = logging.getLogger(__name__) _login_attempts: dict[tuple[str, str], deque[float]] = defaultdict(deque) diff --git a/backend/models.py b/backend/models.py index cd11619..67a4d33 100644 --- a/backend/models.py +++ b/backend/models.py @@ -301,6 +301,23 @@ class BackupImportEntity(Base): created_at: Mapped[datetime] = mapped_column(UTCDateTime(), 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) + ics_cache: Mapped[str | None] = mapped_column(Text, nullable=True) + etag: Mapped[str | None] = mapped_column(String(512), nullable=True) + last_modified: Mapped[str | None] = mapped_column(String(512), nullable=True) + refreshed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True) + last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow) + updated_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow, onupdate=utcnow) + + class AuditLog(Base): __tablename__ = "audit_logs" id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id) diff --git a/frontend/e2e/backup-roundtrip.spec.ts b/frontend/e2e/backup-roundtrip.spec.ts index 517ff42..b6c2509 100644 --- a/frontend/e2e/backup-roundtrip.spec.ts +++ b/frontend/e2e/backup-roundtrip.spec.ts @@ -8,6 +8,11 @@ function bottomTab(page: Page, name: string) { return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true }) } +async function openSidebarView(page: Page, name: string) { + await page.getByRole('button', { name: /展开菜单|收起菜单/ }).click() + await page.locator('.sidebar').getByRole('button', { name, exact: true }).click() +} + async function csrf(request: APIRequestContext, baseURL: string) { const state = await request.storageState() return state.cookies.find(cookie => cookie.name === 'dodo_csrf' && baseURL.includes(cookie.domain))?.value @@ -50,7 +55,7 @@ test('complete ZIP backup preflights and replace-restores task, habit history, c expect(countdownResponse.ok()).toBeTruthy() await page.goto('/') - await bottomTab(page, '设置').click() + await openSidebarView(page, '设置') const downloadPromise = page.waitForEvent('download') await page.getByRole('button', { name: '导出 ZIP' }).click() const download = await downloadPromise diff --git a/frontend/e2e/mobile-ui.spec.ts b/frontend/e2e/mobile-ui.spec.ts index 9501eab..0dff928 100644 --- a/frontend/e2e/mobile-ui.spec.ts +++ b/frontend/e2e/mobile-ui.spec.ts @@ -6,11 +6,6 @@ function bottomTab(page: Page, name: string) { } async function openSettings(page: Page) { - const mobileTab = bottomTab(page, '设置') - if (await mobileTab.isVisible()) { - await mobileTab.click() - return - } const desktopSettings = page.getByRole('navigation', { name: '管理' }).getByRole('button', { name: '设置', exact: true }) const box = await desktopSettings.boundingBox() if (box && box.x + box.width > 0 && box.y + box.height > 0 && box.x < (await page.viewportSize())!.width) await desktopSettings.click() @@ -159,15 +154,21 @@ test('bottom navigation keeps its safe-area gap after dragging', async ({ page } }) test('all bottom destinations expose one active page and desktop layout stays unchanged', async ({ page }) => { + await page.route('**/api/v1/calendar-subscriptions', route => route.fulfill({ json: [] })) + await page.route('**/api/v1/calendar-events?*', route => route.fulfill({ json: { events: [], sources: [] } })) await page.goto('/') const navigation = page.getByRole('navigation', { name: '主要导航' }) - for (const label of ['今天', '习惯', '倒数日', '设置']) { - await bottomTab(page, label).click() - await expect(navigation.locator('[aria-current="page"]')).toHaveCount(1) - await expect(bottomTab(page, label)).toHaveAttribute('aria-current', 'page') + const mobile = (await page.viewportSize())!.width <= 930 + if (mobile) { + for (const label of ['今天', '习惯', '倒数日', '备忘录', '日历订阅']) { + await bottomTab(page, label).click() + await expect(navigation.locator('[aria-current="page"]')).toHaveCount(1) + await expect(bottomTab(page, label)).toHaveAttribute('aria-current', 'page') + } + await bottomTab(page, '今天').click() + } else { + await expect(navigation).toBeHidden() } - - await bottomTab(page, '今天').click() await page.setViewportSize({ width: 1440, height: 900 }) const desktop = await page.locator('.shell').evaluate(element => { const shell = getComputedStyle(element) @@ -202,7 +203,9 @@ test('all bottom destinations expose one active page and desktop layout stays un test('settings match the approved paper-ledger geometry and action hierarchy', async ({ page }) => { await page.goto('/') await openSettings(page) - if ((await page.viewportSize())!.width <= 720) await expect(bottomTab(page, '设置')).toHaveAttribute('aria-current', 'page') + if ((await page.viewportSize())!.width <= 720) { + await expect(page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name: '设置', exact: true })).toHaveCount(0) + } const groups = page.locator('.settings-group') await expect(groups).toHaveCount(4) const layout = await page.locator('.settings-sections').evaluate(element => { diff --git a/frontend/e2e/ui-reduction-acceptance.spec.ts b/frontend/e2e/ui-reduction-acceptance.spec.ts index 6a928fd..8ab715a 100644 --- a/frontend/e2e/ui-reduction-acceptance.spec.ts +++ b/frontend/e2e/ui-reduction-acceptance.spec.ts @@ -130,7 +130,7 @@ test('task rows use the body for detail and Trash keeps distinct actions', async test('Settings removes intro/empty danger and places mode-specific restore risk copy correctly', async ({ page }, testInfo) => { await page.goto('/') - await bottomTab(page, '设置').click() + await openSidebarView(page, '设置') expect(await page.locator('.view-intro').count()).toBe(0) await expect(page.locator('.settings-group')).toHaveCount(4) expect(await page.locator('.settings-danger').count()).toBe(0) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4090fb6..b8babdb 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -16,6 +16,7 @@ import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/arch import MvpPanel from './MvpPanel.vue' import CountdownPanel from './CountdownPanel.vue' import MemoPanel from './MemoPanel.vue' +import CalendarPanel from './CalendarPanel.vue' import FloatingAddButton from './components/FloatingAddButton.vue' import CompletedFilterPill from './components/CompletedFilterPill.vue' import CalendarPicker from './components/CalendarPicker.vue' @@ -32,7 +33,7 @@ type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; completed_at: string | null; version: number; due_at: string | null; due_has_time: boolean; subtasks?: Task[] } type RepeatOption = TaskRepeatOption type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null } -type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'settings' +type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'calendar' | 'settings' const initialized = ref(null) const authReady = ref(false) @@ -389,6 +390,7 @@ const activeName = computed(() => { if (activeView.value === 'habits') return '习惯' if (activeView.value === 'countdowns') return '倒数日' if (activeView.value === 'memos') return '备忘录' + if (activeView.value === 'calendar') return '日历订阅' if (activeView.value === 'settings') return '设置与数据' return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱' }) @@ -409,7 +411,7 @@ const sourceTasks = computed(() => activeView.value === 'trash' ? trash.value : const visibleTasks = computed(() => { const now = new Date() let result = sourceTasks.value - if (['habits','settings','countdowns','memos'].includes(activeView.value)) return [] + if (['habits','settings','countdowns','memos','calendar'].includes(activeView.value)) return [] if (activeView.value === 'today') result = result.filter((task) => { const dueToday = task.due_at && new Date(task.due_at).toDateString() === now.toDateString() const completedToday = task.completed_at && new Date(task.completed_at).toDateString() === now.toDateString() @@ -1613,6 +1615,7 @@ onUnmounted(() => { +
我的清单
@@ -1684,6 +1687,7 @@ onUnmounted(() => { +