feat: add iCal calendar subscriptions
ci / gitleaks (push) Successful in 56s
ci / docker (push) Successful in 5m55s

This commit is contained in:
2026-09-20 16:37:43 +08:00
parent af52fe0cad
commit f3ad1eec03
25 changed files with 1108 additions and 41 deletions
+17 -1
View File
@@ -152,6 +152,18 @@ def parse_archive_path(path: Path, *, max_archive_bytes: int = MAX_ARCHIVE_BYTES
content_names = set(names) - {"manifest.json"} content_names = set(names) - {"manifest.json"}
if set(checksums) != content_names: if set(checksums) != content_names:
raise backup_error("backup_manifest_mismatch", "manifest 与 ZIP 条目不一致") 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]] = {} entities: dict[str, list[dict]] = {}
files: dict[str, StagedBlob] = {} files: dict[str, StagedBlob] = {}
for index, name in enumerate(sorted(content_names)): 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]: if not isinstance(checksums[name], str) or actual_digest != checksums[name]:
raise backup_error("backup_checksum_mismatch", "备份校验和不匹配") raise backup_error("backup_checksum_mismatch", "备份校验和不匹配")
if set(declared_entities) != set(entities): 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()): 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 实体数量不一致") raise backup_error("backup_manifest_mismatch", "manifest 实体数量不一致")
except HTTPException: except HTTPException:
+15 -1
View File
@@ -19,6 +19,7 @@ from backend.models import (
BackupImport, BackupImport,
BackupImportEntity, BackupImportEntity,
BackupPreflight, BackupPreflight,
CalendarSubscription,
Countdown, Countdown,
Folder, Folder,
Habit, Habit,
@@ -65,6 +66,7 @@ ENTITY_MODELS = {
"habit_pauses": HabitPause, "habit_pauses": HabitPause,
"countdowns": Countdown, "countdowns": Countdown,
"memos": Memo, "memos": Memo,
"calendar_subscriptions": CalendarSubscription,
"attachments": Attachment, "attachments": Attachment,
} }
RELATIONS = { RELATIONS = {
@@ -392,6 +394,9 @@ def _validate_recurrence_graph(parsed: ParsedArchive) -> None:
def validate_archive(parsed: ParsedArchive) -> None: def validate_archive(parsed: ParsedArchive) -> None:
unknown = set(parsed.entities) - set(ENTITY_MODELS) 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) missing = set(ENTITY_MODELS) - set(parsed.entities)
if unknown: if unknown:
raise backup_error("backup_entity_unknown", "备份包含未知实体") raise backup_error("backup_entity_unknown", "备份包含未知实体")
@@ -611,7 +616,16 @@ async def restore_v2(
await db.execute(delete(HabitPause).where( await db.execute(delete(HabitPause).where(
HabitPause.habit_id.in_(select(Habit.id).where(Habit.user_id == user.id)) 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(model).where(model.user_id == user.id))
await db.execute(delete(BackupImportEntity).where(BackupImportEntity.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)) await db.execute(delete(BackupImport).where(BackupImport.user_id == user.id))
+248
View File
@@ -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"]))
+224
View File
@@ -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}
+2
View File
@@ -30,6 +30,7 @@ from .auth import (
verify_password, verify_password,
) )
from .backup import router as backup_router from .backup import router as backup_router
from .calendar_router import router as calendar_router
from .db import create_schema, get_db from .db import create_schema, get_db
from .models import ( from .models import (
AppState, AppState,
@@ -120,6 +121,7 @@ async def openapi(_: User = Depends(current_user)):
app.include_router(mvp_router) app.include_router(mvp_router)
app.include_router(backup_router) app.include_router(backup_router)
app.include_router(calendar_router)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_login_attempts: dict[tuple[str, str], deque[float]] = defaultdict(deque) _login_attempts: dict[tuple[str, str], deque[float]] = defaultdict(deque)
+17
View File
@@ -301,6 +301,23 @@ class BackupImportEntity(Base):
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow) 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): class AuditLog(Base):
__tablename__ = "audit_logs" __tablename__ = "audit_logs"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id) id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
+6 -1
View File
@@ -8,6 +8,11 @@ function bottomTab(page: Page, name: string) {
return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true }) 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) { async function csrf(request: APIRequestContext, baseURL: string) {
const state = await request.storageState() const state = await request.storageState()
return state.cookies.find(cookie => cookie.name === 'dodo_csrf' && baseURL.includes(cookie.domain))?.value 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() expect(countdownResponse.ok()).toBeTruthy()
await page.goto('/') await page.goto('/')
await bottomTab(page, '设置').click() await openSidebarView(page, '设置')
const downloadPromise = page.waitForEvent('download') const downloadPromise = page.waitForEvent('download')
await page.getByRole('button', { name: '导出 ZIP' }).click() await page.getByRole('button', { name: '导出 ZIP' }).click()
const download = await downloadPromise const download = await downloadPromise
+15 -12
View File
@@ -6,11 +6,6 @@ function bottomTab(page: Page, name: string) {
} }
async function openSettings(page: Page) { 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 desktopSettings = page.getByRole('navigation', { name: '管理' }).getByRole('button', { name: '设置', exact: true })
const box = await desktopSettings.boundingBox() 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() 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 }) => { 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('/') await page.goto('/')
const navigation = page.getByRole('navigation', { name: '主要导航' }) const navigation = page.getByRole('navigation', { name: '主要导航' })
for (const label of ['今天', '习惯', '倒数日', '设置']) { const mobile = (await page.viewportSize())!.width <= 930
await bottomTab(page, label).click() if (mobile) {
await expect(navigation.locator('[aria-current="page"]')).toHaveCount(1) for (const label of ['今天', '习惯', '倒数日', '备忘录', '日历订阅']) {
await expect(bottomTab(page, label)).toHaveAttribute('aria-current', 'page') 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 }) await page.setViewportSize({ width: 1440, height: 900 })
const desktop = await page.locator('.shell').evaluate(element => { const desktop = await page.locator('.shell').evaluate(element => {
const shell = getComputedStyle(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 }) => { test('settings match the approved paper-ledger geometry and action hierarchy', async ({ page }) => {
await page.goto('/') await page.goto('/')
await openSettings(page) 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') const groups = page.locator('.settings-group')
await expect(groups).toHaveCount(4) await expect(groups).toHaveCount(4)
const layout = await page.locator('.settings-sections').evaluate(element => { const layout = await page.locator('.settings-sections').evaluate(element => {
+1 -1
View File
@@ -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) => { test('Settings removes intro/empty danger and places mode-specific restore risk copy correctly', async ({ page }, testInfo) => {
await page.goto('/') await page.goto('/')
await bottomTab(page, '设置').click() await openSidebarView(page, '设置')
expect(await page.locator('.view-intro').count()).toBe(0) expect(await page.locator('.view-intro').count()).toBe(0)
await expect(page.locator('.settings-group')).toHaveCount(4) await expect(page.locator('.settings-group')).toHaveCount(4)
expect(await page.locator('.settings-danger').count()).toBe(0) expect(await page.locator('.settings-danger').count()).toBe(0)
+7 -3
View File
@@ -16,6 +16,7 @@ import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/arch
import MvpPanel from './MvpPanel.vue' import MvpPanel from './MvpPanel.vue'
import CountdownPanel from './CountdownPanel.vue' import CountdownPanel from './CountdownPanel.vue'
import MemoPanel from './MemoPanel.vue' import MemoPanel from './MemoPanel.vue'
import CalendarPanel from './CalendarPanel.vue'
import FloatingAddButton from './components/FloatingAddButton.vue' import FloatingAddButton from './components/FloatingAddButton.vue'
import CompletedFilterPill from './components/CompletedFilterPill.vue' import CompletedFilterPill from './components/CompletedFilterPill.vue'
import CalendarPicker from './components/CalendarPicker.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 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 RepeatOption = TaskRepeatOption
type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null } 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<boolean | null>(null) const initialized = ref<boolean | null>(null)
const authReady = ref(false) const authReady = ref(false)
@@ -389,6 +390,7 @@ const activeName = computed(() => {
if (activeView.value === 'habits') return '习惯' if (activeView.value === 'habits') return '习惯'
if (activeView.value === 'countdowns') return '倒数日' if (activeView.value === 'countdowns') return '倒数日'
if (activeView.value === 'memos') return '备忘录' if (activeView.value === 'memos') return '备忘录'
if (activeView.value === 'calendar') return '日历订阅'
if (activeView.value === 'settings') return '设置与数据' if (activeView.value === 'settings') return '设置与数据'
return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱' 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 visibleTasks = computed(() => {
const now = new Date() const now = new Date()
let result = sourceTasks.value 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) => { if (activeView.value === 'today') result = result.filter((task) => {
const dueToday = task.due_at && new Date(task.due_at).toDateString() === now.toDateString() 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() const completedToday = task.completed_at && new Date(task.completed_at).toDateString() === now.toDateString()
@@ -1613,6 +1615,7 @@ onUnmounted(() => {
<button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button> <button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button>
<button :class="{ active: activeView==='countdowns' }" @click="switchView('countdowns')"><CalendarHeart />倒数日</button> <button :class="{ active: activeView==='countdowns' }" @click="switchView('countdowns')"><CalendarHeart />倒数日</button>
<button :class="{ active: activeView==='memos' }" @click="switchView('memos')"><StickyNote />备忘录</button> <button :class="{ active: activeView==='memos' }" @click="switchView('memos')"><StickyNote />备忘录</button>
<button :class="{ active: activeView==='calendar' }" @click="switchView('calendar')"><CalendarDays />日历订阅</button>
</nav> </nav>
<div class="section-title list-root-drop" :class="{'list-drop-target':listDrag&&listDropFolderId===null&&!listReorderTarget}" @pointermove="listDrag&&resolveListDrop($event)"><span>我的清单</span><span class="sidebar-create-wrap"><button class="mini-icon list-create-trigger" aria-label="新建清单或文件夹" :aria-expanded="sidebarCreateOpen" @click="toggleSidebarCreate"><Plus /></button><span v-if="sidebarCreateOpen" class="sidebar-popover sidebar-create-menu"><button @click="runSidebarCreate('list')"><ListTodo/>新建清单</button><button @click="runSidebarCreate('folder')"><Folder/>新建文件夹</button></span></span></div> <div class="section-title list-root-drop" :class="{'list-drop-target':listDrag&&listDropFolderId===null&&!listReorderTarget}" @pointermove="listDrag&&resolveListDrop($event)"><span>我的清单</span><span class="sidebar-create-wrap"><button class="mini-icon list-create-trigger" aria-label="新建清单或文件夹" :aria-expanded="sidebarCreateOpen" @click="toggleSidebarCreate"><Plus /></button><span v-if="sidebarCreateOpen" class="sidebar-popover sidebar-create-menu"><button @click="runSidebarCreate('list')"><ListTodo/>新建清单</button><button @click="runSidebarCreate('folder')"><Folder/>新建文件夹</button></span></span></div>
<div class="folders"> <div class="folders">
@@ -1684,6 +1687,7 @@ onUnmounted(() => {
<MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" :show-completed="showCompleted" :compact-layout="compactLayout" @update:show-completed="showCompleted=$event" @detail="handleHabitDetail" @changed="refreshAll" @notice="toast" @logout="completeLogout" /> <MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" :show-completed="showCompleted" :compact-layout="compactLayout" @update:show-completed="showCompleted=$event" @detail="handleHabitDetail" @changed="refreshAll" @notice="toast" @logout="completeLogout" />
</template> </template>
<MemoPanel v-else-if="activeView==='memos'" ref="memoPanel" :request="api" @scope="memoTrash=$event==='trash'" @detail="memoDetailOpen=$event" @notice="toast" /> <MemoPanel v-else-if="activeView==='memos'" ref="memoPanel" :request="api" @scope="memoTrash=$event==='trash'" @detail="memoDetailOpen=$event" @notice="toast" />
<CalendarPanel v-else-if="activeView==='calendar'" @notice="toast" />
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" /> <CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
<template v-else> <template v-else>
<section v-if="activeView==='today'" class="today-context" aria-label="今日概览"> <section v-if="activeView==='today'" class="today-context" aria-label="今日概览">
@@ -1765,7 +1769,7 @@ onUnmounted(() => {
<footer class="detail-actions"><button class="danger-text detail-trash" type="button" :disabled="taskDetailBusy" @click="removeTask(selectedTask)"><Trash2/>移到回收站</button><button class="primary detail-save" type="submit" :disabled="taskDetailBusy">{{savingSelectedTask?'正在保存…':recurrenceLoading?'正在读取…':'保存更改'}}</button></footer> <footer class="detail-actions"><button class="danger-text detail-trash" type="button" :disabled="taskDetailBusy" @click="removeTask(selectedTask)"><Trash2/>移到回收站</button><button class="primary detail-save" type="submit" :disabled="taskDetailBusy">{{savingSelectedTask?'正在保存…':recurrenceLoading?'正在读取…':'保存更改'}}</button></footer>
</AppSheet> </AppSheet>
<nav class="bottom" :inert="memoBackgroundInert ? true : undefined" aria-label="主要导航"><button :class="{active:activeView==='today'}" :aria-current="activeView==='today' ? 'page' : undefined" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='habits'}" :aria-current="activeView==='habits' ? 'page' : undefined" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='countdowns'}" :aria-current="activeView==='countdowns' ? 'page' : undefined" @click="switchView('countdowns')"><CalendarHeart/><span>倒数日</span></button><button :class="{active:activeView==='settings'}" :aria-current="activeView==='settings' ? 'page' : undefined" @click="switchView('settings')"><Settings/><span>设置</span></button></nav> <nav class="bottom" :inert="memoBackgroundInert ? true : undefined" aria-label="主要导航"><button :class="{active:activeView==='today'}" :aria-current="activeView==='today' ? 'page' : undefined" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='habits'}" :aria-current="activeView==='habits' ? 'page' : undefined" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='countdowns'}" :aria-current="activeView==='countdowns' ? 'page' : undefined" @click="switchView('countdowns')"><CalendarHeart/><span>倒数日</span></button><button :class="{active:activeView==='memos'}" :aria-current="activeView==='memos' ? 'page' : undefined" @click="switchView('memos')"><StickyNote/><span>备忘录</span></button><button :class="{active:activeView==='calendar'}" :aria-current="activeView==='calendar' ? 'page' : undefined" @click="switchView('calendar')"><CalendarDays/><span>日历订阅</span></button></nav>
<FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns','memos'].includes(activeView)" :show="showFloatingAdd" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : activeView==='memos' ? '添加备忘录' : '添加任务'" @activate="activateFloatingAdd" /> <FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns','memos'].includes(activeView)" :show="showFloatingAdd" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : activeView==='memos' ? '添加备忘录' : '添加任务'" @activate="activateFloatingAdd" />
<AppSheet :open="taskComposeOpen" variant="create" panel-class="task-compose-sheet" title-id="task-compose-title" initial-focus=".task-compose-input" :style="taskComposeStyle" @close="closeTaskCompose" @submit.prevent="submitTaskCompose"> <AppSheet :open="taskComposeOpen" variant="create" panel-class="task-compose-sheet" title-id="task-compose-title" initial-focus=".task-compose-input" :style="taskComposeStyle" @close="closeTaskCompose" @submit.prevent="submitTaskCompose">
<header class="app-sheet__header"><div><h2 id="task-compose-title">{{ taskComposeTitle }}</h2></div><button class="icon" type="button" aria-label="关闭添加任务" @click="closeTaskCompose"><X/></button></header> <header class="app-sheet__header"><div><h2 id="task-compose-title">{{ taskComposeTitle }}</h2></div><button class="icon" type="button" aria-label="关闭添加任务" @click="closeTaskCompose"><X/></button></header>
+8
View File
@@ -0,0 +1,8 @@
import { readFileSync } from 'node:fs'
import { describe,expect,it } from 'vitest'
const app=readFileSync('src/App.vue','utf8');const utils=readFileSync('src/lib/mvp-utils.ts','utf8');const css=readFileSync('src/calendar.css','utf8')
describe('calendar shell integration',()=>{
it('puts calendar beside the other first-level tools on desktop and mobile',()=>{const nav=app.slice(app.indexOf('<nav class="primary-nav">'),app.indexOf('</nav>',app.indexOf('<nav class="primary-nav">')));expect(nav.indexOf("switchView('habits')")).toBeLessThan(nav.indexOf("switchView('countdowns')"));expect(nav.indexOf("switchView('countdowns')")).toBeLessThan(nav.indexOf("switchView('memos')"));expect(nav.indexOf("switchView('memos')")).toBeLessThan(nav.indexOf("switchView('calendar')"));const bottom=app.slice(app.indexOf('<nav class="bottom"'),app.indexOf('</nav>',app.indexOf('<nav class="bottom"')));for(const view of ['today','habits','countdowns','memos','calendar'])expect(bottom).toContain(`switchView('${view}')`);expect(bottom).not.toContain("switchView('settings')")})
it('persists calendar navigation and mounts the panel',()=>{expect(utils).toContain("'calendar'");expect(app).toContain("import CalendarPanel from './CalendarPanel.vue'");expect(app).toContain("activeView==='calendar'")})
it('keeps five 44px mobile targets across required breakpoints',()=>{const shellCss=readFileSync('src/style.css','utf8');expect(shellCss).toContain('grid-template-columns:repeat(5,minmax(0,1fr))');expect(css).toContain('min-height:44px');expect(css).toContain('@media(max-width:720px)');expect(css).toContain('@media(min-width:931px)');expect(css).toContain('@media(min-width:1440px)')})
})
+113
View File
@@ -0,0 +1,113 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick } from 'vue'
import CalendarPanel from './CalendarPanel.vue'
const cleanups: Array<() => void> = []
const subscriptions = [{ id:'s1', name:'工作', url:'https://example.com/work.ics', color:'#f15a29', enabled:true, refreshed_at:'2026-09-20T08:00:00Z', last_error:null, stale:false }]
const events = [{ id:'e1', title:'发布会', starts_at:'2026-09-22T02:00:00Z', ends_at:'2026-09-22T03:00:00Z', all_day:false, description:'产品发布', location:'会议室', source_id:'s1', source_name:'工作', color:'#f15a29' }]
const json = (value:unknown, status=200) => new Response(JSON.stringify(value), { status, headers:{'content-type':'application/json'} })
async function flush(){ await Promise.resolve(); await new Promise(r=>setTimeout(r,0)); await nextTick() }
async function mount(fetchMock:ReturnType<typeof vi.fn>){ vi.stubGlobal('fetch',fetchMock); const host=document.createElement('div');document.body.append(host);const notices:string[]=[];const app=createApp(()=>h(CalendarPanel,{onNotice:(v:string)=>notices.push(v)}));app.mount(host);cleanups.push(()=>{app.unmount();host.remove()});await flush();return {host,notices} }
afterEach(()=>{cleanups.splice(0).forEach(fn=>fn());vi.unstubAllGlobals();vi.restoreAllMocks()})
describe('CalendarPanel',()=>{
it('loads subscriptions and the visible month then filters and opens event detail',async()=>{
const fetchMock=vi.fn((url:string)=>url.includes('calendar-events')?Promise.resolve(json({events,sources:[{id:'s1',name:'工作',stale:false}]})):Promise.resolve(json(subscriptions)))
const {host}=await mount(fetchMock)
const eventsUrl=String(fetchMock.mock.calls.find(([url])=>String(url).includes('calendar-events'))?.[0])
const params=new URL(eventsUrl,'http://localhost').searchParams
expect(params.get('start')).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
expect(params.get('end')).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
expect(host.textContent).toContain('发布会');expect(host.textContent).toContain('工作')
host.querySelector<HTMLButtonElement>('[data-event-id="e1"]')!.click();await nextTick()
expect(document.querySelector('.calendar-event-detail')?.textContent).toContain('产品发布')
host.querySelector<HTMLInputElement>('input[aria-label="筛选工作"]')!.click();await nextTick()
expect(host.querySelector('[data-event-id="e1"]')).toBeNull()
})
it('filters duplicate source names by source id',async()=>{
const duplicateSubscriptions=[subscriptions[0],{...subscriptions[0],id:'s2',url:'https://example.com/personal.ics',color:'#334455'}]
const duplicateEvents=[events[0],{...events[0],id:'e2',title:'私人日程',source_id:'s2',color:'#334455'}]
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:duplicateEvents,sources:[]}:duplicateSubscriptions)))
const {host}=await mount(fetchMock)
host.querySelectorAll<HTMLInputElement>('input[aria-label="筛选工作"]')[0].click();await nextTick()
expect(host.querySelector('[data-event-id="e1"]')).toBeNull()
expect(host.querySelector('[data-event-id="e2"]')).not.toBeNull()
})
it('groups UTC events by the browser-local calendar day',async()=>{
const boundary=[{...events[0],id:'boundary',starts_at:'2026-09-21T23:30:00Z',ends_at:'2026-09-22T00:30:00Z'}]
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:boundary,sources:[]}:subscriptions)))
const {host}=await mount(fetchMock)
const expected=new Intl.DateTimeFormat('zh-CN',{month:'long',day:'numeric',weekday:'short'}).format(new Date(boundary[0].starts_at))
expect(host.querySelector('.calendar-agenda h2')?.textContent).toBe(expected)
})
it('keeps the newest month response when requests finish out of order',async()=>{
const pending:Array<{url:string;resolve:(response:Response)=>void}>=[]
const fetchMock=vi.fn((url:string)=>String(url).includes('calendar-events')?new Promise<Response>(resolve=>pending.push({url:String(url),resolve})):Promise.resolve(json(subscriptions)))
const {host}=await mount(fetchMock)
expect(pending).toHaveLength(1)
host.querySelector<HTMLButtonElement>('[aria-label="下个月"]')!.click();await nextTick()
expect(pending).toHaveLength(2)
pending[1].resolve(json({events:[{...events[0],id:'new',title:'新月份'}],sources:[]}));await flush()
pending[0].resolve(json({events:[{...events[0],id:'old',title:'旧月份'}],sources:[]}));await flush()
expect(host.textContent).toContain('新月份')
expect(host.textContent).not.toContain('旧月份')
})
it('supports month navigation and today',async()=>{
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:[],sources:[]}:subscriptions)))
const {host}=await mount(fetchMock);const before=fetchMock.mock.calls.length
host.querySelector<HTMLButtonElement>('[aria-label="下个月"]')!.click();await flush()
host.querySelector<HTMLButtonElement>('[aria-label="回到今天"]')!.click();await flush()
expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(before+2)
})
it('closes the subscription form without submitting it',async()=>{
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:[],sources:[]}:subscriptions)))
const {host}=await mount(fetchMock)
host.querySelector<HTMLButtonElement>('[aria-label="管理日历源"]')!.click();await nextTick()
document.querySelector<HTMLButtonElement>('[aria-label="添加日历订阅"]')!.click();await nextTick()
const name=document.querySelector<HTMLInputElement>('input[aria-label="订阅名称"]')!,url=document.querySelector<HTMLInputElement>('input[aria-label="订阅地址"]')!
name.value='私人';name.dispatchEvent(new Event('input'));url.value='https://example.com/a.ics';url.dispatchEvent(new Event('input'));await nextTick()
document.querySelector<HTMLButtonElement>('[aria-label="关闭订阅表单"]')!.click();await flush()
const calls=fetchMock.mock.calls as unknown as Array<[string,RequestInit?]>
expect(calls.some(([,options])=>options?.method==='POST')).toBe(false)
})
it('creates, edits, toggles, refreshes and deletes a source with confirmation',async()=>{
const calls:Array<[string,RequestInit|undefined]>=[]
const fetchMock=vi.fn((url:string,options?:RequestInit)=>{calls.push([url,options]);if(options?.method==='DELETE')return Promise.resolve(new Response(null,{status:204}));if(options?.method)return Promise.resolve(json(subscriptions[0]));return Promise.resolve(json(url.includes('calendar-events')?{events,sources:[{id:'s1',name:'工作',stale:false}]}:subscriptions))})
const {host}=await mount(fetchMock)
host.querySelector<HTMLButtonElement>('[aria-label="管理日历源"]')!.click();await nextTick()
document.querySelector<HTMLButtonElement>('[aria-label="添加日历订阅"]')!.click();await nextTick()
const name=document.querySelector<HTMLInputElement>('input[aria-label="订阅名称"]')!,url=document.querySelector<HTMLInputElement>('input[aria-label="订阅地址"]')!;name.value='私人';name.dispatchEvent(new Event('input'));url.value='https://example.com/a.ics';url.dispatchEvent(new Event('input'));await nextTick();document.querySelector<HTMLButtonElement>('.calendar-subscription-form button[type="submit"]')!.click();await flush()
expect(calls.some(([u,o])=>u.endsWith('/calendar-subscriptions')&&o?.method==='POST')).toBe(true)
host.querySelector<HTMLButtonElement>('[aria-label="管理日历源"]')!.click();await nextTick()
document.querySelector<HTMLButtonElement>('[aria-label="编辑工作"]')!.click();await nextTick()
const editedName=document.querySelector<HTMLInputElement>('input[aria-label="订阅名称"]')!;editedName.value='工作日历';editedName.dispatchEvent(new Event('input'));await nextTick();document.querySelector<HTMLButtonElement>('.calendar-subscription-form button[type="submit"]')!.click();await flush()
expect(calls.some(([u,o])=>u.endsWith('/calendar-subscriptions/s1')&&o?.method==='PATCH'&&String(o.body).includes('工作日历'))).toBe(true)
host.querySelector<HTMLButtonElement>('[aria-label="管理日历源"]')!.click();await nextTick()
document.querySelector<HTMLButtonElement>('[aria-label="刷新工作"]')!.click();await flush()
expect(calls.some(([u,o])=>u.endsWith('/calendar-subscriptions/s1/refresh')&&o?.method==='POST')).toBe(true)
document.querySelector<HTMLInputElement>('[aria-label="启用工作"]')!.click();await flush()
expect(calls.some(([u,o])=>u.endsWith('/calendar-subscriptions/s1')&&o?.method==='PATCH')).toBe(true)
document.querySelector<HTMLButtonElement>('[aria-label="删除工作"]')!.click();await nextTick()
document.querySelector<HTMLButtonElement>('.app-dialog .danger-button')!.click();await flush()
expect(calls.some(([u,o])=>u.endsWith('/calendar-subscriptions/s1')&&o?.method==='DELETE')).toBe(true)
})
it('clears an earlier action error after a successful retry',async()=>{
let failRefresh=true
const fetchMock=vi.fn((url:string,options?:RequestInit)=>{
if(options?.method==='POST'&&String(url).endsWith('/refresh')&&failRefresh){failRefresh=false;return Promise.resolve(json({detail:'上游不可用'},502))}
if(options?.method)return Promise.resolve(json(subscriptions[0]))
return Promise.resolve(json(String(url).includes('calendar-events')?{events:[],sources:[]}:subscriptions))
})
const {host}=await mount(fetchMock);host.querySelector<HTMLButtonElement>('[aria-label="管理日历源"]')!.click();await nextTick()
document.querySelector<HTMLButtonElement>('[aria-label="刷新工作"]')!.click();await flush()
expect(host.querySelector('.inline-error')?.textContent).toContain('上游不可用')
document.querySelector<HTMLButtonElement>('[aria-label="刷新工作"]')!.click();await flush()
expect(host.querySelector('.inline-error')).toBeNull()
})
it('shows source-specific refresh errors',async()=>{
const failed=[{...subscriptions[0],last_error:'订阅地址无法访问'}]
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:[],sources:[]}:failed)))
const {host}=await mount(fetchMock);host.querySelector<HTMLButtonElement>('[aria-label="管理日历源"]')!.click();await nextTick()
expect(document.querySelector('[role="alert"]')?.textContent).toContain('订阅地址无法访问')
})
})
+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { CalendarDays, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Settings2, Trash2, X } from 'lucide-vue-next'
import { csrfHeader } from './lib/csrf'
import { formatApiErrorDetail } from './lib/mvp-utils'
import AppSheet from './components/AppSheet.vue'
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
type Subscription = { id:string; name:string; url:string; color:string; enabled:boolean; refreshed_at:string|null; last_error:string|null; stale:boolean }
type CalendarEvent = { id:string; title:string; starts_at:string; ends_at:string; all_day:boolean; source_id:string; source_name:string; color:string; description?:string|null; location?:string|null }
type EventResponse = { events:CalendarEvent[]; sources:Array<{id:string;name:string;stale:boolean}> }
type Form = { name:string; url:string; color:string; enabled:boolean }
const emit=defineEmits<{notice:[message:string]}>()
const subscriptions=ref<Subscription[]>([]),events=ref<CalendarEvent[]>([]),loading=ref(false),error=ref('')
let eventsRequestGeneration=0
const month=ref(new Date(new Date().getFullYear(),new Date().getMonth(),1)),hiddenSources=ref(new Set<string>())
const selected=ref<CalendarEvent|null>(null),manageOpen=ref(false),formOpen=ref(false),editing=ref<Subscription|null>(null),busyId=ref('')
const form=ref<Form>({name:'',url:'',color:'#f15a29',enabled:true})
const appDialog=ref<{show:(options:AppDialogOptions)=>Promise<boolean|string|null>}|null>(null)
const request=async(path:string,options:RequestInit={})=>{const headers:Record<string,string>={...(options.headers as Record<string,string>||{})};if(options.body)headers['Content-Type']='application/json';Object.assign(headers,csrfHeader(options.method));const response=await fetch('/api/v1'+path,{credentials:'include',...options,headers});if(!response.ok){const body=await response.json().catch(()=>({}));throw new Error(formatApiErrorDetail((body as {detail?:unknown}).detail??body))}return response.status===204?null:response.json()}
const key=(date:Date)=>`${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}`
const range=computed(()=>{const start=new Date(month.value.getFullYear(),month.value.getMonth(),1);const end=new Date(month.value.getFullYear(),month.value.getMonth()+1,1);return{start:start.toISOString(),end:end.toISOString()}})
const monthLabel=computed(()=>new Intl.DateTimeFormat('zh-CN',{year:'numeric',month:'long'}).format(month.value))
const eventStart=(event:CalendarEvent)=>event.starts_at
const eventEnd=(event:CalendarEvent)=>event.ends_at
const eventKey=(event:CalendarEvent)=>event.id
const visibleEvents=computed(()=>events.value.filter(event=>!hiddenSources.value.has(event.source_id)).sort((a,b)=>eventStart(a).localeCompare(eventStart(b))))
const localDayKey=(value:string)=>{const date=new Date(value);return Number.isNaN(date.getTime())?value.slice(0,10):key(date)}
const groupedEvents=computed(()=>{const groups=new Map<string,CalendarEvent[]>();for(const event of visibleEvents.value){const day=localDayKey(eventStart(event));groups.set(day,[...(groups.get(day)??[]),event])}return [...groups].map(([day,items])=>({day,items}))})
const eventTitle=(event:CalendarEvent)=>event.title||'未命名事件'
const eventSource=(event:CalendarEvent)=>event.source_name||'日历'
const eventColor=(event:CalendarEvent)=>event.color||'#f15a29'
function displayDay(day:string){const [y,m,d]=day.split('-').map(Number);return new Intl.DateTimeFormat('zh-CN',{month:'long',day:'numeric',weekday:'short'}).format(new Date(y,m-1,d))}
function displayTime(event:CalendarEvent){if(event.all_day)return'全天';const date=new Date(eventStart(event));return Number.isNaN(date.getTime())?'时间待定':new Intl.DateTimeFormat('zh-CN',{hour:'2-digit',minute:'2-digit'}).format(date)}
async function loadSubscriptions(){subscriptions.value=await request('/calendar-subscriptions') as Subscription[]}
async function loadEvents(){const generation=++eventsRequestGeneration;const requestedRange=range.value;const data=await request(`/calendar-events?start=${encodeURIComponent(requestedRange.start)}&end=${encodeURIComponent(requestedRange.end)}`) as EventResponse;if(generation===eventsRequestGeneration)events.value=data.events}
async function load(){loading.value=true;error.value='';try{await Promise.all([loadSubscriptions(),loadEvents()])}catch(reason){error.value=reason instanceof Error?reason.message:'日历载入失败'}finally{loading.value=false}}
async function moveMonth(offset:number){month.value=new Date(month.value.getFullYear(),month.value.getMonth()+offset,1);await loadEvents().catch(reason=>error.value=reason instanceof Error?reason.message:'事件载入失败')}
async function today(){const now=new Date();month.value=new Date(now.getFullYear(),now.getMonth(),1);await loadEvents().catch(reason=>error.value=reason instanceof Error?reason.message:'事件载入失败')}
function toggleFilter(id:string){const next=new Set(hiddenSources.value);next.has(id)?next.delete(id):next.add(id);hiddenSources.value=next}
function openCreate(){editing.value=null;form.value={name:'',url:'',color:'#f15a29',enabled:true};formOpen.value=true}
function openEdit(item:Subscription){editing.value=item;form.value={name:item.name,url:item.url,color:item.color||'#f15a29',enabled:item.enabled};formOpen.value=true}
async function save(){if(busyId.value||!form.value.name.trim()||!form.value.url.trim())return;busyId.value='form';error.value='';try{await request(editing.value?`/calendar-subscriptions/${editing.value.id}`:'/calendar-subscriptions',{method:editing.value?'PATCH':'POST',body:JSON.stringify({...form.value,name:form.value.name.trim(),url:form.value.url.trim()})});formOpen.value=false;await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice',editing.value?'日历订阅已更新':'日历订阅已添加')}catch(reason){error.value=reason instanceof Error?reason.message:'保存失败'}finally{busyId.value=''}}
async function toggleEnabled(item:Subscription){if(busyId.value)return;busyId.value=item.id;error.value='';try{await request(`/calendar-subscriptions/${item.id}`,{method:'PATCH',body:JSON.stringify({enabled:!item.enabled})});await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice',item.enabled?'日历订阅已停用':'日历订阅已启用')}catch(reason){error.value=reason instanceof Error?reason.message:'更新失败'}finally{busyId.value=''}}
async function refresh(item:Subscription){if(busyId.value)return;busyId.value=item.id;error.value='';try{await request(`/calendar-subscriptions/${item.id}/refresh`,{method:'POST'});await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice',`${item.name}已刷新`)}catch(reason){error.value=reason instanceof Error?reason.message:'刷新失败'}finally{busyId.value=''}}
async function remove(item:Subscription){if(busyId.value)return;if(await appDialog.value?.show({title:`删除“${item.name}”?`,description:'该来源的事件也会从日历中移除。',danger:true,confirmText:'删除'})!==true)return;busyId.value=item.id;error.value='';try{await request(`/calendar-subscriptions/${item.id}`,{method:'DELETE'});hiddenSources.value.delete(item.id);await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice','日历订阅已删除')}catch(reason){error.value=reason instanceof Error?reason.message:'删除失败'}finally{busyId.value=''}}
watch(manageOpen,open=>{if(!open)formOpen.value=false})
onMounted(()=>void load())
</script>
<template>
<section class="calendar-view" :class="{loading}">
<header class="calendar-heading"><div><h1>日历订阅</h1><p>{{visibleEvents.length}} 个日程 · {{subscriptions.length}} 个来源</p></div><button class="soft-button calendar-manage" aria-label="管理日历源" @click="manageOpen=true"><Settings2/>日历源</button></header>
<p v-if="error" class="inline-error" role="alert">{{error}}</p>
<div class="calendar-toolbar"><button aria-label="上个月" @click="moveMonth(-1)"><ChevronLeft/></button><button class="calendar-today" aria-label="回到今天" @click="today">今天</button><strong>{{monthLabel}}</strong><button aria-label="下个月" @click="moveMonth(1)"><ChevronRight/></button></div>
<div v-if="subscriptions.length" class="calendar-filters" aria-label="筛选日历源"><label v-for="source in subscriptions" :key="source.id"><input type="checkbox" :aria-label="`筛选${source.name}`" :checked="!hiddenSources.has(source.id)" @change="toggleFilter(source.id)"><i :style="{background:source.color}"/>{{source.name}}</label></div>
<div v-if="groupedEvents.length" class="calendar-agenda"><section v-for="group in groupedEvents" :key="group.day"><h2>{{displayDay(group.day)}}</h2><button v-for="event in group.items" :key="eventKey(event)" :data-event-id="event.id" class="calendar-event-row" @click="selected=event"><i :style="{background:eventColor(event)}"/><time>{{displayTime(event)}}</time><span><b>{{eventTitle(event)}}</b><small>{{eventSource(event)}}<template v-if="event.location"> · {{event.location}}</template></small></span><ChevronRight/></button></section></div>
<div v-else-if="!loading" class="calendar-empty"><CalendarDays/><b>这个月还没有日程</b><span>{{subscriptions.length?'可以切换月份或检查来源筛选':'先添加一个 iCal 日历订阅'}}</span><button v-if="!subscriptions.length" class="primary-small" @click="manageOpen=true;openCreate()">添加日历源</button></div>
<AppSheet :open="Boolean(selected)" variant="detail" panel-class="calendar-event-detail" title-id="calendar-event-title" initial-focus="button[aria-label='关闭日程详情']" @close="selected=null"><template v-if="selected"><header class="app-sheet__header"><h3 id="calendar-event-title">{{eventTitle(selected)}}</h3><button aria-label="关闭日程详情" @click="selected=null"><X/></button></header><div class="app-sheet__body"><dl><div><dt>时间</dt><dd>{{displayDay(localDayKey(eventStart(selected)))}} {{displayTime(selected)}}<template v-if="eventEnd(selected) && !selected.all_day"> {{displayTime({...selected,starts_at:eventEnd(selected)})}}</template></dd></div><div><dt>来源</dt><dd><i :style="{background:eventColor(selected)}"/>{{eventSource(selected)}}</dd></div><div v-if="selected.location"><dt>地点</dt><dd>{{selected.location}}</dd></div></dl><section v-if="selected.description"><h4>备注</h4><p>{{selected.description}}</p></section></div></template></AppSheet>
<AppSheet :open="manageOpen" variant="detail" panel-class="calendar-sources-sheet" title-id="calendar-sources-title" initial-focus="button[aria-label='关闭日历源']" @close="manageOpen=false"><header class="app-sheet__header"><h3 id="calendar-sources-title">日历源</h3><button aria-label="关闭日历源" @click="manageOpen=false"><X/></button></header><div class="app-sheet__body"><button class="primary-small calendar-source-add" aria-label="添加日历订阅" @click="openCreate"><Plus/>添加订阅</button><div class="calendar-source-list"><article v-for="source in subscriptions" :key="source.id"><div class="calendar-source-copy"><b><i :style="{background:source.color}"/>{{source.name}}</b><small>{{source.url}}</small><small v-if="source.last_error" class="calendar-source-error" role="alert">{{source.last_error}}</small></div><label class="calendar-source-toggle"><input type="checkbox" :aria-label="`启用${source.name}`" :checked="source.enabled" :disabled="Boolean(busyId)" @change="toggleEnabled(source)"><span>启用</span></label><button :aria-label="`刷新${source.name}`" :disabled="Boolean(busyId)" @click="refresh(source)"><RefreshCw/></button><button :aria-label="`编辑${source.name}`" :disabled="Boolean(busyId)" @click="openEdit(source)"><Pencil/></button><button class="danger-text" :aria-label="`删除${source.name}`" :disabled="Boolean(busyId)" @click="remove(source)"><Trash2/></button></article></div></div></AppSheet>
<AppSheet :open="formOpen" variant="create" panel-class="calendar-subscription-form" title-id="calendar-form-title" initial-focus="input[aria-label='订阅名称']" :busy="busyId==='form'" @close="formOpen=false" @submit.prevent="save"><header class="app-sheet__header"><h3 id="calendar-form-title">{{editing?'编辑订阅':'添加订阅'}}</h3><button type="button" aria-label="关闭订阅表单" @click="formOpen=false"><X/></button></header><div class="app-sheet__body"><label>名称<input v-model="form.name" aria-label="订阅名称" maxlength="120" required placeholder="例如:工作"></label><label>iCal 地址<input v-model="form.url" aria-label="订阅地址" type="url" required placeholder="https://example.com/calendar.ics"></label><label>颜色<input v-model="form.color" aria-label="订阅颜色" type="color"></label><label class="calendar-form-toggle"><input v-model="form.enabled" type="checkbox">启用此订阅</label></div><footer class="app-sheet__footer"><button type="button" class="secondary" @click="formOpen=false">取消</button><button type="submit" class="primary-small" :disabled="Boolean(busyId)||!form.name.trim()||!form.url.trim()">保存</button></footer></AppSheet>
<AppDialog ref="appDialog"/>
</section>
</template>
+3 -3
View File
@@ -7,13 +7,13 @@ const panel = readFileSync('src/MemoPanel.vue', 'utf8')
const editor = readFileSync('src/components/MemoEditor.vue', 'utf8') const editor = readFileSync('src/components/MemoEditor.vue', 'utf8')
describe('memo shell integration', () => { describe('memo shell integration', () => {
it('places Memos immediately after Countdowns in desktop navigation and keeps mobile tabs unchanged', () => { it('places Memos immediately after Countdowns in desktop navigation and exposes it as a direct mobile destination', () => {
const nav = app.slice(app.indexOf('<nav class="primary-nav">'), app.indexOf('</nav>', app.indexOf('<nav class="primary-nav">'))) const nav = app.slice(app.indexOf('<nav class="primary-nav">'), app.indexOf('</nav>', app.indexOf('<nav class="primary-nav">')))
expect(nav.indexOf("switchView('memos')")).toBeGreaterThan(nav.indexOf("switchView('countdowns')")) expect(nav.indexOf("switchView('memos')")).toBeGreaterThan(nav.indexOf("switchView('countdowns')"))
expect(nav.match(/switchView\('memos'\)/g)).toHaveLength(1) expect(nav.match(/switchView\('memos'\)/g)).toHaveLength(1)
const bottom = app.slice(app.indexOf('<nav class="bottom"'), app.indexOf('</nav>', app.indexOf('<nav class="bottom"'))) const bottom = app.slice(app.indexOf('<nav class="bottom"'), app.indexOf('</nav>', app.indexOf('<nav class="bottom"')))
expect(bottom).not.toContain("switchView('memos')") expect(bottom).toContain("switchView('memos')")
expect(bottom.match(/aria-current=/g)).toHaveLength(4) expect(bottom.match(/aria-current=/g)).toHaveLength(5)
}) })
it('routes the shared cat FAB to a local memo draft, hides it in trash, and defers POST until save', () => { it('routes the shared cat FAB to a local memo draft, hides it in trash, and defers POST until save', () => {
File diff suppressed because one or more lines are too long
+2
View File
@@ -67,6 +67,8 @@ describe('MVP view utilities', () => {
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'tasks', listId: 'list-2' }) expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'tasks', listId: 'list-2' })
writeStoredNavigation(fakeStorage, 'dodo.navigation', 'memos', 'list-2') writeStoredNavigation(fakeStorage, 'dodo.navigation', 'memos', 'list-2')
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'memos', listId: 'list-2' }) expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'memos', listId: 'list-2' })
writeStoredNavigation(fakeStorage, 'dodo.navigation', 'calendar', 'list-2')
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'calendar', listId: 'list-2' })
storage.set('dodo.navigation', JSON.stringify({ view: 'invalid', listId: 'list-2' })) storage.set('dodo.navigation', JSON.stringify({ view: 'invalid', listId: 'list-2' }))
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'today', listId: '' }) expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'today', listId: '' })
}) })
+2 -2
View File
@@ -1,7 +1,7 @@
type BooleanStorage = Pick<Storage, 'getItem' | 'setItem'> type BooleanStorage = Pick<Storage, 'getItem' | 'setItem'>
type NavigationView = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'settings' type NavigationView = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'calendar' | 'settings'
type StoredNavigation = { view: NavigationView; listId: string } type StoredNavigation = { view: NavigationView; listId: string }
const NAVIGATION_VIEWS = new Set<NavigationView>(['tasks', 'today', 'upcoming', 'trash', 'habits', 'countdowns', 'memos', 'settings']) const NAVIGATION_VIEWS = new Set<NavigationView>(['tasks', 'today', 'upcoming', 'trash', 'habits', 'countdowns', 'memos', 'calendar', 'settings'])
export function readStoredNavigation(storage: BooleanStorage, key: string): StoredNavigation { export function readStoredNavigation(storage: BooleanStorage, key: string): StoredNavigation {
try { try {
+1
View File
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import './style.css' import './style.css'
import './memo.css' import './memo.css'
import './calendar.css'
createApp(App).mount('#app') createApp(App).mount('#app')
if ('serviceWorker' in navigator && import.meta.env.PROD) { if ('serviceWorker' in navigator && import.meta.env.PROD) {
+1 -1
View File
@@ -83,7 +83,7 @@ input,select,textarea{background:var(--surface-raised);border-color:var(--border
.toast{background:#3b342c;color:#fff;border:1px solid #574d42;border-radius:var(--radius-control);box-shadow:var(--shadow-raised)}.error-toast{background:var(--danger);color:#fff;border:1px solid #9f2f22;border-radius:var(--radius-control);box-shadow:var(--shadow-raised)} .toast{background:#3b342c;color:#fff;border:1px solid #574d42;border-radius:var(--radius-control);box-shadow:var(--shadow-raised)}.error-toast{background:var(--danger);color:#fff;border:1px solid #9f2f22;border-radius:var(--radius-control);box-shadow:var(--shadow-raised)}
:focus-visible{outline:3px solid var(--focus-ring);outline-offset:2px}.task-check:focus-visible,.archived-lists-toggle:focus-visible,.archived-row-menu-trigger:focus-visible,.archived-row-actions button:focus-visible{outline:3px solid var(--focus-ring);outline-offset:2px} :focus-visible{outline:3px solid var(--focus-ring);outline-offset:2px}.task-check:focus-visible,.archived-lists-toggle:focus-visible,.archived-row-menu-trigger:focus-visible,.archived-row-actions button:focus-visible{outline:3px solid var(--focus-ring);outline-offset:2px}
@media(max-width:930px){.countdown-focus{height:144px;min-height:144px;max-height:144px;padding:10px 16px;gap:2px}.countdown-focus h3{margin:2px 0 0}.countdown-number{margin:0}} @media(max-width:930px){.countdown-focus{height:144px;min-height:144px;max-height:144px;padding:10px 16px;gap:2px}.countdown-focus h3{margin:2px 0 0}.countdown-number{margin:0}}
@media(max-width:930px){.bottom{left:0;right:0;bottom:0;height:calc(56px + var(--safe-area-bottom));display:grid;grid-template-columns:repeat(4,minmax(0,1fr));background:var(--surface-raised);border:0;border-top:1px solid var(--border-cream);border-radius:0;padding:4px 10px var(--safe-area-bottom);box-shadow:none}.bottom button{position:relative;min-width:0;min-height:44px;border-radius:0;padding:2px 4px;line-height:1.1}.bottom button svg{width:19px;height:19px}.bottom button.active{background:transparent;color:var(--accent)}.bottom button.active:before{content:"";position:absolute;left:23%;right:23%;top:-5px;height:3px;border-radius:0 0 3px 3px;background:var(--accent)}.sidebar{border-radius:0 var(--radius-panel) var(--radius-panel) 0}.detail,.app-sheet,.task-compose-sheet,.habit-detail-sheet,.countdown-detail-sheet{border-radius:var(--sheet-radius) var(--sheet-radius) 0 0!important}.task-list,.habit-list,.countdown-timeline{display:grid;gap:0}.task-row,.habit-row,.countdown-row{min-height:62px;background:var(--surface-raised);border:0;border-radius:0;box-shadow:none}.task-row+.task-row,.habit-row+.habit-row,.countdown-row+.countdown-row{border-top:1px solid var(--border-cream)}.countdown-row:first-of-type{border-top:0}.unified-fab{bottom:calc(68px + var(--safe-area-bottom))}} @media(max-width:930px){.bottom{left:0;right:0;bottom:0;height:calc(56px + var(--safe-area-bottom));display:grid;grid-template-columns:repeat(5,minmax(0,1fr));background:var(--surface-raised);border:0;border-top:1px solid var(--border-cream);border-radius:0;padding:4px 10px var(--safe-area-bottom);box-shadow:none}.bottom button{position:relative;min-width:0;min-height:44px;border-radius:0;padding:2px 4px;line-height:1.1}.bottom button svg{width:19px;height:19px}.bottom button.active{background:transparent;color:var(--accent)}.bottom button.active:before{content:"";position:absolute;left:23%;right:23%;top:-5px;height:3px;border-radius:0 0 3px 3px;background:var(--accent)}.sidebar{border-radius:0 var(--radius-panel) var(--radius-panel) 0}.detail,.app-sheet,.task-compose-sheet,.habit-detail-sheet,.countdown-detail-sheet{border-radius:var(--sheet-radius) var(--sheet-radius) 0 0!important}.task-list,.habit-list,.countdown-timeline{display:grid;gap:0}.task-row,.habit-row,.countdown-row{min-height:62px;background:var(--surface-raised);border:0;border-radius:0;box-shadow:none}.task-row+.task-row,.habit-row+.habit-row,.countdown-row+.countdown-row{border-top:1px solid var(--border-cream)}.countdown-row:first-of-type{border-top:0}.unified-fab{bottom:calc(68px + var(--safe-area-bottom))}}
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;transition-duration:.01ms!important}.completed-filter-pill,.completed-filter-pill__track,.completed-filter-pill__thumb{transition:none!important}.completed-filter-pill:active:not(:disabled){transform:none}.task-row.just-completed,.habit-row.just-completed,.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:none}} @media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;transition-duration:.01ms!important}.completed-filter-pill,.completed-filter-pill__track,.completed-filter-pill__thumb{transition:none!important}.completed-filter-pill:active:not(:disabled){transform:none}.task-row.just-completed,.habit-row.just-completed,.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:none}}
/* Shared plain-list rows for active tasks and habits. */ /* Shared plain-list rows for active tasks and habits. */
.plain-list{display:grid;gap:0;background:transparent;border:0;border-radius:0;box-shadow:none;overflow:visible} .plain-list{display:grid;gap:0;background:transparent;border:0;border-radius:0;box-shadow:none;overflow:visible}
+5 -5
View File
@@ -118,16 +118,16 @@ describe('approved cream solid button system', () => {
}) })
describe('mobile navigation styles', () => { describe('mobile navigation styles', () => {
it('renames the bottom More tab to a direct Settings tab', () => { it('uses five direct mobile destinations and keeps Settings in the sidebar', () => {
expect(app).not.toContain('aria-controls="mobile-more-menu"') expect(app).not.toContain('aria-controls="mobile-more-menu"')
expect(app).not.toContain('<Ellipsis/><span>更多</span>') expect(app).not.toContain('<Ellipsis/><span>更多</span>')
expect(app).toContain("<Settings/><span>设置</span>") expect(app).toContain("<StickyNote/><span>备忘录</span>")
expect(app).toContain("@click=\"switchView('settings')\"") expect(app).toContain("<CalendarDays/><span>日历订阅</span>")
expect(app).toContain('<span>设置</span>') expect(app).not.toContain("@click=\"switchView('settings')\"><Settings/><span>设置</span>")
}) })
it('marks only exact mobile destinations active and exposes aria-current only there', () => { it('marks only exact mobile destinations active and exposes aria-current only there', () => {
for (const view of ['today', 'habits', 'countdowns', 'settings']) { for (const view of ['today', 'habits', 'countdowns', 'memos', 'calendar']) {
expect(app).toContain(`:class="{active:activeView==='${view}'}" :aria-current="activeView==='${view}' ? 'page' : undefined"`) expect(app).toContain(`:class="{active:activeView==='${view}'}" :aria-current="activeView==='${view}' ? 'page' : undefined"`)
} }
expect(app).not.toContain("activeView==='tasks'||activeView==='upcoming'||activeView==='trash'||activeView==='settings'") expect(app).not.toContain("activeView==='tasks'||activeView==='upcoming'||activeView==='trash'||activeView==='settings'")
@@ -1,15 +1,15 @@
"""tombstone for reverted calendar subscriptions """restore calendar subscriptions after the reverted release
Revision ID: 0020_calendar_subscriptions Revision ID: 0020_calendar_subscriptions
Revises: 0019_backup_imports Revises: 0019_backup_imports
The calendar subscription feature (backend/calendar.py, backend/calendar_router.py) The original revision reached production before the feature was reverted. Existing
was reverted from main, but this revision id was already applied to the production databases may therefore already contain the table while fresh databases do not.
database. Keeping an empty migration under the same revision id lets Keep the revision id and make the schema operation idempotent for both cases.
`alembic upgrade head` succeed on databases stamped at this revision.
""" """
from alembic import op # noqa: F401 import sqlalchemy as sa
from alembic import op
revision = "0020_calendar_subscriptions" revision = "0020_calendar_subscriptions"
down_revision = "0019_backup_imports" down_revision = "0019_backup_imports"
@@ -18,11 +18,35 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
# Intentionally empty: the feature was reverted. The leftover bind = op.get_bind()
# calendar_subscriptions table on already-migrated databases is harmless if "calendar_subscriptions" in sa.inspect(bind).get_table_names():
# and intentionally left in place. return
pass op.create_table(
"calendar_subscriptions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("name", sa.String(length=120), nullable=False),
sa.Column("url", sa.Text(), nullable=False),
sa.Column("color", sa.String(length=32), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column("ics_cache", sa.Text(), nullable=True),
sa.Column("etag", sa.String(length=512), nullable=True),
sa.Column("last_modified", sa.String(length=512), nullable=True),
sa.Column("refreshed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_calendar_subscriptions_user_id", "calendar_subscriptions", ["user_id"]
)
def downgrade() -> None: def downgrade() -> None:
pass bind = op.get_bind()
if "calendar_subscriptions" not in sa.inspect(bind).get_table_names():
return
op.drop_index("ix_calendar_subscriptions_user_id", table_name="calendar_subscriptions")
op.drop_table("calendar_subscriptions")
+2
View File
@@ -16,6 +16,8 @@ dependencies = [
"structlog>=25,<26", "structlog>=25,<26",
"lunar-python>=1.2,<2", "lunar-python>=1.2,<2",
"httpx>=0.28,<1", "httpx>=0.28,<1",
"icalendar>=6,<7",
"python-dateutil>=2.9,<3",
] ]
[dependency-groups] [dependency-groups]
+60
View File
@@ -0,0 +1,60 @@
import asyncio
import json
import zipfile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from backend.backup.archive import parse_archive_path
from backend.backup.service import export_v2, validate_archive
from backend.db import get_engine
from backend.models import CalendarSubscription, User
def initialized(client):
response = client.post(
"/api/v1/setup/initialize",
json={"username": "owner", "password": "correct horse battery staple"},
)
assert response.status_code == 201
return client
def test_backup_exports_calendar_subscriptions_and_accepts_old_archive(client, tmp_path):
client = initialized(client)
async def prepare_and_export():
factory = async_sessionmaker(get_engine(), expire_on_commit=False)
async with factory() as db:
user = await db.scalar(select(User))
db.add(CalendarSubscription(
user_id=user.id,
name="Work",
url="https://example.com/work.ics",
color="#123abc",
ics_cache="BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n",
))
await db.commit()
path = await export_v2(db, user)
await db.commit()
return path
path = asyncio.run(prepare_and_export())
try:
parsed = parse_archive_path(path)
assert parsed.entities["calendar_subscriptions"][0]["name"] == "Work"
validate_archive(parsed)
old_path = tmp_path / "old.zip"
with zipfile.ZipFile(path) as source, zipfile.ZipFile(old_path, "w") as target:
manifest = json.loads(source.read("manifest.json"))
manifest["entities"].pop("calendar_subscriptions")
manifest["checksums"].pop("data/calendar_subscriptions.json")
for name in source.namelist():
if name not in {"manifest.json", "data/calendar_subscriptions.json"}:
target.writestr(name, source.read(name))
from backend.backup.archive import canonical_json
target.writestr("manifest.json", canonical_json(manifest))
validate_archive(parse_archive_path(old_path))
finally:
path.unlink(missing_ok=True)
+212
View File
@@ -0,0 +1,212 @@
import socket
from datetime import UTC, datetime
import pytest
from fastapi import HTTPException
from backend.calendar import (
FetchResult,
parse_ics_events,
validate_calendar_url,
)
ICS = b"""BEGIN:VCALENDAR\r
VERSION:2.0\r
BEGIN:VEVENT\r
UID:one\r
DTSTART:20260920T090000Z\r
DTEND:20260920T100000Z\r
SUMMARY:Meeting\r
END:VEVENT\r
END:VCALENDAR\r
"""
RECURRING_ICS = b"""BEGIN:VCALENDAR\r
VERSION:2.0\r
BEGIN:VEVENT\r
UID:daily\r
DTSTART;TZID=Asia/Shanghai:20260920T090000\r
DTEND;TZID=Asia/Shanghai:20260920T100000\r
RRULE:FREQ=DAILY;COUNT=3\r
EXDATE;TZID=Asia/Shanghai:20260921T090000\r
SUMMARY:Daily\r
END:VEVENT\r
BEGIN:VEVENT\r
UID:daily\r
RECURRENCE-ID;TZID=Asia/Shanghai:20260922T090000\r
DTSTART;TZID=Asia/Shanghai:20260922T110000\r
DTEND;TZID=Asia/Shanghai:20260922T120000\r
SUMMARY:Moved\r
END:VEVENT\r
END:VCALENDAR\r
"""
INHERITED_DURATION_ICS = RECURRING_ICS.replace(
b"DTEND;TZID=Asia/Shanghai:20260922T120000\r\n", b""
).replace(
b"DTEND;TZID=Asia/Shanghai:20260920T100000\r\n",
b"DTEND;TZID=Asia/Shanghai:20260920T103000\r\n",
)
def initialized(client, username="owner"):
response = client.post(
"/api/v1/setup/initialize",
json={"username": username, "password": "correct horse battery staple"},
)
assert response.status_code == 201
return client
def test_parser_restored_with_recurrence_exdates_overrides_and_timezone():
events = parse_ics_events(
RECURRING_ICS,
"Work",
"#123456",
datetime(2026, 9, 19, tzinfo=UTC),
datetime(2026, 9, 24, tzinfo=UTC),
"Asia/Shanghai",
)
assert [(event["title"], event["starts_at"].isoformat()) for event in events] == [
("Daily", "2026-09-20T01:00:00+00:00"),
("Moved", "2026-09-22T03:00:00+00:00"),
]
def test_recurrence_override_inherits_master_duration_and_source_id():
events = parse_ics_events(
INHERITED_DURATION_ICS,
"Work",
"#123456",
datetime(2026, 9, 19, tzinfo=UTC),
datetime(2026, 9, 24, tzinfo=UTC),
"Asia/Shanghai",
source_id="source-1",
)
moved = next(event for event in events if event["title"] == "Moved")
assert (moved["ends_at"] - moved["starts_at"]).total_seconds() == 90 * 60
assert moved["source_id"] == "source-1"
def test_parser_limits_recurrence_expansion():
endless = ICS.replace(b"UID:one", b"UID:one\r\nRRULE:FREQ=SECONDLY")
with pytest.raises(ValueError, match="recurrence limit"):
parse_ics_events(
endless,
"x",
"#000000",
datetime(2026, 9, 20, tzinfo=UTC),
datetime(2026, 9, 21, tzinfo=UTC),
"UTC",
recurrence_limit=10,
)
def test_url_validation_rejects_fragments_userinfo_and_mixed_dns(monkeypatch):
with pytest.raises(HTTPException):
validate_calendar_url("https://example.com/a.ics#secret")
with pytest.raises(HTTPException):
validate_calendar_url("https://[email protected]/a.ics")
monkeypatch.setattr(socket, "getaddrinfo", lambda *args, **kwargs: [
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443)),
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 443)),
])
with pytest.raises(HTTPException, match="public"):
validate_calendar_url("https://example.com/a.ics")
def test_subscription_crud_refresh_events_and_stale_cache(client, monkeypatch):
client = initialized(client)
monkeypatch.setattr(
"backend.calendar.validate_calendar_url",
lambda url: (url, "93.184.216.34", 443),
)
calls = []
def fetch(url, *, etag=None, last_modified=None):
calls.append((url, etag, last_modified))
if len(calls) == 1:
return FetchResult(ICS, '"v1"', "Sun, 20 Sep 2026 00:00:00 GMT", False)
raise HTTPException(502, "upstream down")
monkeypatch.setattr("backend.calendar.fetch_calendar", fetch)
created = client.post(
"/api/v1/calendar-subscriptions",
json={"name": " Work ", "url": "https://example.com/work.ics", "color": "#123abc"},
)
assert created.status_code == 201
body = created.json()
assert body["name"] == "Work"
assert body["enabled"] is True
assert body["stale"] is False
assert calls == [("https://example.com/work.ics", None, None)]
listed = client.get("/api/v1/calendar-subscriptions")
assert [item["id"] for item in listed.json()] == [body["id"]]
events = client.get(
"/api/v1/calendar-events",
params={"start": "2026-09-20T00:00:00Z", "end": "2026-09-21T00:00:00Z"},
)
assert events.status_code == 200
assert events.json()["events"][0]["title"] == "Meeting"
assert events.json()["sources"][0]["stale"] is False
refreshed = client.post(f"/api/v1/calendar-subscriptions/{body['id']}/refresh")
assert refreshed.status_code == 200
assert refreshed.json()["stale"] is True
assert refreshed.json()["last_error"] == "upstream down"
assert calls[1][1:] == ('"v1"', "Sun, 20 Sep 2026 00:00:00 GMT")
patched = client.patch(
f"/api/v1/calendar-subscriptions/{body['id']}",
json={"name": "Personal", "enabled": False, "color": "#abcdef"},
)
assert patched.status_code == 200
assert patched.json()["name"] == "Personal"
assert patched.json()["enabled"] is False
assert client.delete(f"/api/v1/calendar-subscriptions/{body['id']}").status_code == 204
def test_events_validate_window_and_disabled_sources_are_skipped(client, monkeypatch):
client = initialized(client)
monkeypatch.setattr(
"backend.calendar.validate_calendar_url",
lambda url: (url, "93.184.216.34", 443),
)
monkeypatch.setattr(
"backend.calendar.fetch_calendar",
lambda *args, **kwargs: FetchResult(ICS, None, None, False),
)
sub = client.post(
"/api/v1/calendar-subscriptions",
json={"name": "x", "url": "https://example.com/x.ics", "enabled": False},
).json()
assert sub["enabled"] is False
response = client.get(
"/api/v1/calendar-events",
params={"start": "2026-09-21T00:00:00Z", "end": "2026-09-20T00:00:00Z"},
)
assert response.status_code == 422
valid = client.get(
"/api/v1/calendar-events",
params={"start": "2026-09-20T00:00:00Z", "end": "2026-09-21T00:00:00Z"},
)
assert valid.json() == {"events": [], "sources": []}
def test_subscription_ownership_is_strict(client, monkeypatch):
client = initialized(client)
monkeypatch.setattr(
"backend.calendar.validate_calendar_url",
lambda url: (url, "93.184.216.34", 443),
)
monkeypatch.setattr(
"backend.calendar.fetch_calendar",
lambda *args, **kwargs: FetchResult(ICS, None, None, False),
)
sub = client.post(
"/api/v1/calendar-subscriptions",
json={"name": "private", "url": "https://example.com/private.ics"},
).json()
client.post("/api/v1/auth/logout")
assert client.patch(f"/api/v1/calendar-subscriptions/{sub['id']}", json={"name": "x"}).status_code == 401
assert client.delete(f"/api/v1/calendar-subscriptions/{sub['id']}").status_code == 401
Generated
+47
View File
@@ -277,8 +277,10 @@ dependencies = [
{ name = "asyncpg" }, { name = "asyncpg" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" }, { name = "httpx" },
{ name = "icalendar" },
{ name = "lunar-python" }, { name = "lunar-python" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "python-dateutil" },
{ name = "python-multipart" }, { name = "python-multipart" },
{ name = "sqlalchemy", extra = ["asyncio"] }, { name = "sqlalchemy", extra = ["asyncio"] },
{ name = "structlog" }, { name = "structlog" },
@@ -301,8 +303,10 @@ requires-dist = [
{ name = "asyncpg", specifier = ">=0.30,<1" }, { name = "asyncpg", specifier = ">=0.30,<1" },
{ name = "fastapi", specifier = ">=0.116,<1" }, { name = "fastapi", specifier = ">=0.116,<1" },
{ name = "httpx", specifier = ">=0.28,<1" }, { name = "httpx", specifier = ">=0.28,<1" },
{ name = "icalendar", specifier = ">=6,<7" },
{ name = "lunar-python", specifier = ">=1.2,<2" }, { name = "lunar-python", specifier = ">=1.2,<2" },
{ name = "pydantic-settings", specifier = ">=2.10,<3" }, { name = "pydantic-settings", specifier = ">=2.10,<3" },
{ name = "python-dateutil", specifier = ">=2.9,<3" },
{ name = "python-multipart", specifier = ">=0.0.20,<1" }, { name = "python-multipart", specifier = ">=0.0.20,<1" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0,<3" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0,<3" },
{ name = "structlog", specifier = ">=25,<26" }, { name = "structlog", specifier = ">=25,<26" },
@@ -474,6 +478,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
] ]
[[package]]
name = "icalendar"
version = "6.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
{ name = "tzdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/70/458092b3e7c15783423fe64d07e63ea3311a597e723be6a1060513e3db93/icalendar-6.3.2.tar.gz", hash = "sha256:e0c10ecbfcebe958d33af7d491f6e6b7580d11d475f2eeb29532d0424f9110a1", size = 178422 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/06/ee/2ff96bb5bd88fe03ab90aedf5180f96dc0f3ae4648ca264b473055bcaaff/icalendar-6.3.2-py3-none-any.whl", hash = "sha256:d400e9c9bb8c025e5a3c77c236941bb690494be52528a0b43cc7e8b7c9505064", size = 242403 },
]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.19" version = "3.19"
@@ -742,6 +759,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930 }, { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930 },
] ]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.3" version = "1.2.3"
@@ -831,6 +860,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850 }, { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850 },
] ]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 },
]
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.0.52" version = "2.0.52"
@@ -914,6 +952,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750 }, { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750 },
] ]
[[package]]
name = "tzdata"
version = "2026.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494 },
]
[[package]] [[package]]
name = "uuid-utils" name = "uuid-utils"
version = "0.17.0" version = "0.17.0"