feat: add iCal calendar subscriptions
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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"]))
|
||||||
@@ -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}
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)')})
|
||||||
|
})
|
||||||
@@ -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('订阅地址无法访问')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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: '' })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user