This commit is contained in:
+139
-83
@@ -1,18 +1,21 @@
|
||||
import asyncio
|
||||
import csv
|
||||
import http.client
|
||||
import io
|
||||
import ipaddress
|
||||
import re
|
||||
import socket
|
||||
import urllib.error
|
||||
import ssl
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from dateutil.rrule import rrulestr
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from icalendar import Calendar
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import case, delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -268,97 +271,157 @@ def calendar_subscription_dict(row: CalendarSubscription):
|
||||
return {"id": row.id, "name": row.name, "url": row.url, "color": row.color, "enabled": row.enabled}
|
||||
|
||||
|
||||
def parse_ics_datetime(value: str):
|
||||
raw = value.strip()
|
||||
if len(raw) == 8 and raw.isdigit():
|
||||
return datetime.strptime(raw, "%Y%m%d").replace(tzinfo=UTC), True
|
||||
cleaned = raw.removesuffix("Z")
|
||||
parsed = datetime.fromisoformat(cleaned)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
return parsed.astimezone(UTC), False
|
||||
LOCAL_TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def parse_ics_events(content: str, source_name: str, color: str):
|
||||
def _is_forbidden_ip(value: str) -> bool:
|
||||
ip = ipaddress.ip_address(value)
|
||||
return ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved
|
||||
|
||||
|
||||
def _as_utc(value, default_tz=LOCAL_TZ):
|
||||
if isinstance(value, datetime):
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=default_tz)
|
||||
return value.astimezone(UTC), False
|
||||
if isinstance(value, date):
|
||||
return datetime.combine(value, time.min, tzinfo=default_tz).astimezone(UTC), True
|
||||
raise ValueError("unsupported ICS datetime")
|
||||
|
||||
|
||||
def _event_duration(event, starts_at: datetime, all_day: bool):
|
||||
if event.get("dtend"):
|
||||
ends_at, _ = _as_utc(event.decoded("dtend"))
|
||||
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 _excluded_starts(event):
|
||||
excluded = set()
|
||||
exdates = event.get("exdate")
|
||||
if not exdates:
|
||||
return excluded
|
||||
if not isinstance(exdates, list):
|
||||
exdates = [exdates]
|
||||
for exdate in exdates:
|
||||
for item in getattr(exdate, "dts", []):
|
||||
excluded.add(_as_utc(item.dt)[0])
|
||||
return excluded
|
||||
|
||||
|
||||
def _append_calendar_event(events, source_name, color, event, starts_at, ends_at, all_day):
|
||||
uid = str(event.get("uid") or "")
|
||||
title = str(event.get("summary") or "未命名事件").strip() or "未命名事件"
|
||||
events.append({
|
||||
"id": uid or f"{source_name}-{starts_at.isoformat()}-{title}",
|
||||
"title": title,
|
||||
"starts_at": starts_at,
|
||||
"ends_at": ends_at,
|
||||
"all_day": all_day,
|
||||
"source_name": source_name,
|
||||
"color": color,
|
||||
})
|
||||
|
||||
|
||||
def _overlaps(starts_at: datetime, ends_at: datetime | None, window_start: datetime | None, window_end: datetime | None) -> bool:
|
||||
if not window_start or not window_end:
|
||||
return True
|
||||
return starts_at < window_end and (ends_at or starts_at) > window_start
|
||||
|
||||
|
||||
def parse_ics_events(content: str, source_name: str, color: str, window_start: datetime | None = None, window_end: datetime | None = None):
|
||||
calendar = Calendar.from_ical(content)
|
||||
events = []
|
||||
current = None
|
||||
for raw_line in content.splitlines():
|
||||
line = raw_line.strip()
|
||||
if line == "BEGIN:VEVENT":
|
||||
current = {}
|
||||
for event in calendar.walk("VEVENT"):
|
||||
if not event.get("dtstart"):
|
||||
continue
|
||||
if line == "END:VEVENT":
|
||||
if current and current.get("title") and current.get("starts_at"):
|
||||
events.append({
|
||||
"id": current.get("uid") or f"{source_name}-{current['starts_at'].isoformat()}-{current['title']}",
|
||||
"title": current["title"],
|
||||
"starts_at": current["starts_at"],
|
||||
"ends_at": current.get("ends_at"),
|
||||
"all_day": current.get("all_day", False),
|
||||
"source_name": source_name,
|
||||
"color": color,
|
||||
})
|
||||
current = None
|
||||
starts_at, all_day = _as_utc(event.decoded("dtstart"))
|
||||
duration = _event_duration(event, starts_at, all_day)
|
||||
excluded = _excluded_starts(event)
|
||||
if event.get("rrule") and window_start and window_end:
|
||||
rule_text = event.get("rrule").to_ical().decode()
|
||||
rule = rrulestr(rule_text, dtstart=starts_at)
|
||||
for occurrence in rule.between(window_start - duration, window_end, inc=True):
|
||||
occurrence = occurrence.astimezone(UTC) if occurrence.tzinfo else occurrence.replace(tzinfo=UTC)
|
||||
if occurrence in excluded:
|
||||
continue
|
||||
ends_at = occurrence + duration
|
||||
if _overlaps(occurrence, ends_at, window_start, window_end):
|
||||
_append_calendar_event(events, source_name, color, event, occurrence, ends_at, all_day)
|
||||
continue
|
||||
if current is None or ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
field = key.split(";", 1)[0].upper()
|
||||
if field == "SUMMARY":
|
||||
current["title"] = value.strip()
|
||||
elif field == "UID":
|
||||
current["uid"] = value.strip()
|
||||
elif field in {"DTSTART", "DTEND"}:
|
||||
try:
|
||||
dt, all_day = parse_ics_datetime(value)
|
||||
except ValueError:
|
||||
continue
|
||||
current["all_day"] = current.get("all_day", False) or all_day
|
||||
current["starts_at" if field == "DTSTART" else "ends_at"] = dt
|
||||
ends_at = starts_at + duration
|
||||
if _overlaps(starts_at, ends_at, window_start, window_end):
|
||||
_append_calendar_event(events, source_name, color, event, starts_at, ends_at, all_day)
|
||||
return events
|
||||
|
||||
|
||||
def _validate_public_calendar_url(url: str):
|
||||
def _validated_calendar_target(url: str):
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||
raise HTTPException(422, "日历订阅只支持 http/https 链接")
|
||||
if parsed.username or parsed.password:
|
||||
raise HTTPException(422, "日历订阅链接不能包含账号密码")
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
try:
|
||||
addresses = socket.getaddrinfo(parsed.hostname, None)
|
||||
addresses = socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_STREAM)
|
||||
except socket.gaierror as exc:
|
||||
raise HTTPException(422, "日历订阅域名无法解析") from exc
|
||||
for item in addresses:
|
||||
ip = ipaddress.ip_address(item[4][0])
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved:
|
||||
raise HTTPException(422, "日历订阅不能指向内网地址")
|
||||
ip = item[4][0]
|
||||
if not _is_forbidden_ip(ip):
|
||||
return parsed, ip, port
|
||||
raise HTTPException(422, "日历订阅不能指向内网地址")
|
||||
|
||||
|
||||
def fetch_calendar_events(url: str, source_name: str, color: str):
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
def _validate_public_calendar_url(url: str):
|
||||
_validated_calendar_target(url)
|
||||
|
||||
|
||||
def _request_pinned_calendar_url(url: str):
|
||||
parsed, ip, port = _validated_calendar_target(url)
|
||||
target_host = f"[{ip}]" if ":" in ip else ip
|
||||
path = urllib.parse.urlunparse(("", "", parsed.path or "/", parsed.params, parsed.query, ""))
|
||||
headers = {"Host": parsed.hostname or "", "User-Agent": "dodo-calendar-fetch/1.0"}
|
||||
if parsed.port:
|
||||
headers["Host"] = f"{headers['Host']}:{parsed.port}"
|
||||
if parsed.scheme == "https":
|
||||
connection = http.client.HTTPSConnection(target_host, port=port, timeout=15, context=ssl.create_default_context())
|
||||
else:
|
||||
connection = http.client.HTTPConnection(target_host, port=port, timeout=15)
|
||||
try:
|
||||
if parsed.scheme == "https":
|
||||
raw = socket.create_connection((ip, port), timeout=15)
|
||||
sock = ssl.create_default_context().wrap_socket(raw, server_hostname=parsed.hostname)
|
||||
connection.sock = sock
|
||||
connection.request("GET", path, headers=headers)
|
||||
response = connection.getresponse()
|
||||
body = response.read(2_000_001)
|
||||
return response.status, response.getheaders(), response.getheader("Content-Type") or "", body
|
||||
except OSError as exc:
|
||||
raise HTTPException(502, f"订阅拉取失败: {exc}") from exc
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def fetch_calendar_events(url: str, source_name: str, color: str, window_start: datetime | None = None, window_end: datetime | None = None):
|
||||
current_url = url
|
||||
opener = urllib.request.build_opener(NoRedirect)
|
||||
for _ in range(4):
|
||||
_validate_public_calendar_url(current_url)
|
||||
try:
|
||||
with opener.open(current_url, timeout=15) as response:
|
||||
content_type = response.headers.get_content_type()
|
||||
if content_type not in {"text/calendar", "text/plain", "application/octet-stream"}:
|
||||
raise HTTPException(422, "订阅链接没有返回 ICS 日历内容")
|
||||
body = response.read(2_000_001)
|
||||
if len(body) > 2_000_000:
|
||||
raise HTTPException(413, "日历订阅内容超过 2MB")
|
||||
return parse_ics_events(body.decode("utf-8", errors="ignore"), source_name, color)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code not in {301, 302, 303, 307, 308}:
|
||||
raise HTTPException(502, f"订阅拉取失败: HTTP {exc.code}") from exc
|
||||
location = exc.headers.get("Location")
|
||||
status, headers, content_type, body = _request_pinned_calendar_url(current_url)
|
||||
if status in {301, 302, 303, 307, 308}:
|
||||
location = dict(headers).get("Location")
|
||||
if not location:
|
||||
raise HTTPException(502, "订阅重定向缺少目标地址") from exc
|
||||
raise HTTPException(502, "订阅重定向缺少目标地址")
|
||||
current_url = urllib.parse.urljoin(current_url, location)
|
||||
except urllib.error.URLError as exc:
|
||||
raise HTTPException(502, f"订阅拉取失败: {exc.reason}") from exc
|
||||
continue
|
||||
if status >= 400:
|
||||
raise HTTPException(502, f"订阅拉取失败: HTTP {status}")
|
||||
if content_type.split(";", 1)[0].lower() not in {"text/calendar", "text/plain", "application/octet-stream"}:
|
||||
raise HTTPException(422, "订阅链接没有返回 ICS 日历内容")
|
||||
if len(body) > 2_000_000:
|
||||
raise HTTPException(413, "日历订阅内容超过 2MB")
|
||||
return parse_ics_events(body.decode("utf-8", errors="ignore"), source_name, color, window_start, window_end)
|
||||
raise HTTPException(502, "日历订阅重定向次数过多")
|
||||
|
||||
|
||||
@@ -388,11 +451,11 @@ async def today_calendar_events(
|
||||
if not rows:
|
||||
return []
|
||||
tomorrow = day + timedelta(days=1)
|
||||
day_start = datetime.combine(day, time.min, tzinfo=UTC)
|
||||
day_end = datetime.combine(tomorrow, time.min, tzinfo=UTC)
|
||||
day_start = datetime.combine(day, time.min, tzinfo=LOCAL_TZ).astimezone(UTC)
|
||||
day_end = datetime.combine(tomorrow, time.min, tzinfo=LOCAL_TZ).astimezone(UTC)
|
||||
|
||||
async def pull(row: CalendarSubscription):
|
||||
return await asyncio.to_thread(fetch_calendar_events, row.url, row.name, row.color)
|
||||
return await asyncio.to_thread(fetch_calendar_events, row.url, row.name, row.color, day_start, day_end)
|
||||
|
||||
results = await asyncio.gather(*(pull(row) for row in rows), return_exceptions=True)
|
||||
events = []
|
||||
@@ -400,18 +463,11 @@ async def today_calendar_events(
|
||||
if isinstance(result, Exception):
|
||||
continue
|
||||
for item in result:
|
||||
starts_at = item["starts_at"]
|
||||
ends_at = item.get("ends_at")
|
||||
if item["all_day"]:
|
||||
if not (day_start <= starts_at < day_end):
|
||||
continue
|
||||
elif not (day_start <= starts_at < day_end):
|
||||
continue
|
||||
events.append({
|
||||
"id": item["id"],
|
||||
"title": item["title"],
|
||||
"starts_at": starts_at.isoformat(),
|
||||
"ends_at": ends_at.isoformat() if ends_at else None,
|
||||
"starts_at": item["starts_at"].isoformat(),
|
||||
"ends_at": item["ends_at"].isoformat() if item.get("ends_at") else None,
|
||||
"all_day": item["all_day"],
|
||||
"source_name": row.name,
|
||||
"color": row.color,
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-BizE1tvq.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BR_rdQ3j.css">
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-BWTHcY_k.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CaaWUARH.css">
|
||||
</head><body><div id="app"></div></body></html>
|
||||
+12
-5
@@ -249,7 +249,11 @@ function moveTaskSwipe(task: Task, event: TouchEvent) {
|
||||
if (!start || start.id !== task.id || !touch) return
|
||||
const deltaX = touch.clientX - start.x
|
||||
const deltaY = touch.clientY - start.y
|
||||
if (deltaX > 0 && Math.abs(deltaX) > Math.abs(deltaY)) taskSwipeOffsets.value[task.id] = Math.min(deltaX, 92)
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||
if ((!task.completed && deltaX > 0) || (task.completed && deltaX < 0)) {
|
||||
taskSwipeOffsets.value[task.id] = Math.max(-92, Math.min(deltaX, 92))
|
||||
}
|
||||
}
|
||||
}
|
||||
async function finishTaskSwipe(task: Task, event: TouchEvent) {
|
||||
const start = taskSwipeStart.value
|
||||
@@ -258,7 +262,10 @@ async function finishTaskSwipe(task: Task, event: TouchEvent) {
|
||||
taskSwipeOffsets.value[task.id] = 0
|
||||
if (!start || start.id !== task.id || activeView.value === 'trash' || loading.value) return
|
||||
const touch = event.changedTouches[0]
|
||||
if (touch && shouldToggleRowSwipe(touch.clientX - start.x, touch.clientY - start.y)) {
|
||||
const deltaX = touch ? touch.clientX - start.x : 0
|
||||
const deltaY = touch ? touch.clientY - start.y : 0
|
||||
const expectedDirection = task.completed ? deltaX < 0 : deltaX > 0
|
||||
if (touch && expectedDirection && shouldToggleRowSwipe(Math.abs(deltaX), deltaY)) {
|
||||
suppressTaskClickId = task.id
|
||||
window.setTimeout(() => { if (suppressTaskClickId === task.id) suppressTaskClickId = '' }, 400)
|
||||
await toggle(task)
|
||||
@@ -437,8 +444,8 @@ onMounted(bootstrap)
|
||||
<div v-if="activeView!=='trash' && totalPages > 1" class="pager"><button class="secondary" :disabled="page<=1 || loading" @click="previousPage">上一页</button><span>{{page}} / {{totalPages}}</span><button class="secondary" :disabled="page>=totalPages || loading" @click="nextPage">下一页</button></div>
|
||||
<section class="task-list" :class="{loading}">
|
||||
<template v-for="node in taskTree" :key="node.task.id">
|
||||
<article class="task-row swipeable" :class="{done:node.task.completed,selected:selectedTask?.id===node.task.id,ready:(taskSwipeOffsets[node.task.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px` }" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)">
|
||||
<span class="swipe-bg">✓ 完成</span>
|
||||
<article class="task-row swipeable" :class="{done:node.task.completed,selected:selectedTask?.id===node.task.id,ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px`, '--swipe-width': `${Math.abs(taskSwipeOffsets[node.task.id] ?? 0)}px` }" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)">
|
||||
<span class="swipe-bg" :class="{ cancel: node.task.completed }">{{ node.task.completed ? '↩ 未完成' : '✓ 完成' }}</span>
|
||||
<div class="task-main" role="button" tabindex="0" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)"><strong>{{node.task.title}}</strong><span class="meta"><span v-if="node.task.due_at"><CalendarDays/>{{formatDue(node.task.due_at)}}</span><span v-if="node.subtasks.length"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span><i v-for="tag in node.task.tags" :key="tag.id" class="tag-dot" :style="{background:tag.color}" :title="tag.name"/></span></div>
|
||||
<button v-if="activeView!=='trash'" class="task-swipe-hint" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)">{{ node.task.completed ? '已完成' : '完成' }}</button>
|
||||
<span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','低','中','高'][node.task.priority]}}</span>
|
||||
@@ -446,7 +453,7 @@ onMounted(bootstrap)
|
||||
<button v-else class="icon ghost" aria-label="删除任务" @click.stop="removeTask(node.task)"><Trash2/></button>
|
||||
<button v-if="activeView==='trash'" class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button>
|
||||
</article>
|
||||
<article v-for="subtask in node.subtasks" :key="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,ready:(taskSwipeOffsets[subtask.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px` }" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><span class="swipe-bg">✓ 完成</span><GripVertical/><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(subtask)" @keydown.enter="selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="selectTaskUnlessSwiped(subtask)"><strong>{{subtask.title}}</strong></div><button class="task-swipe-hint" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)">{{ subtask.completed ? '已完成' : '完成' }}</button></article>
|
||||
<article v-for="subtask in node.subtasks" :key="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,ready:Math.abs(taskSwipeOffsets[subtask.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px`, '--swipe-width': `${Math.abs(taskSwipeOffsets[subtask.id] ?? 0)}px` }" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><span class="swipe-bg" :class="{ cancel: subtask.completed }">{{ subtask.completed ? '↩ 未完成' : '✓ 完成' }}</span><GripVertical/><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(subtask)" @keydown.enter="selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="selectTaskUnlessSwiped(subtask)"><strong>{{subtask.title}}</strong></div><button class="task-swipe-hint" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)">{{ subtask.completed ? '已完成' : '完成' }}</button></article>
|
||||
</template>
|
||||
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div>
|
||||
</section>
|
||||
|
||||
@@ -95,7 +95,12 @@ function moveHabitSwipe(h: Habit, event: TouchEvent) {
|
||||
if (!start || start.id !== h.id || !touch) return
|
||||
const deltaX = touch.clientX - start.x
|
||||
const deltaY = touch.clientY - start.y
|
||||
if (deltaX > 0 && Math.abs(deltaX) > Math.abs(deltaY)) habitSwipeOffsets.value[h.id] = Math.min(deltaX, 92)
|
||||
const done = isDone(h, todayKey.value)
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||
if ((!done && deltaX > 0) || (done && deltaX < 0)) {
|
||||
habitSwipeOffsets.value[h.id] = Math.max(-92, Math.min(deltaX, 92))
|
||||
}
|
||||
}
|
||||
}
|
||||
async function finishHabitSwipe(h: Habit, event: TouchEvent) {
|
||||
const start = habitSwipeStart.value
|
||||
@@ -103,7 +108,10 @@ async function finishHabitSwipe(h: Habit, event: TouchEvent) {
|
||||
habitSwipeOffsets.value[h.id] = 0
|
||||
if (!start || start.id !== h.id || h.kind === 'numeric' || busy.value) return
|
||||
const touch = event.changedTouches[0]
|
||||
if (touch && shouldToggleRowSwipe(touch.clientX - start.x, touch.clientY - start.y)) {
|
||||
const deltaX = touch ? touch.clientX - start.x : 0
|
||||
const deltaY = touch ? touch.clientY - start.y : 0
|
||||
const expectedDirection = isDone(h, todayKey.value) ? deltaX < 0 : deltaX > 0
|
||||
if (touch && expectedDirection && shouldToggleRowSwipe(Math.abs(deltaX), deltaY)) {
|
||||
await toggleHabit(h, todayKey.value)
|
||||
}
|
||||
}
|
||||
@@ -283,11 +291,11 @@ onBeforeUnmount(() => {
|
||||
|
||||
|
||||
<!-- 习惯列表:布尔习惯整行右滑打卡,数值习惯保留明确输入记录 -->
|
||||
<div class="habit-list">
|
||||
<article v-for="h in habits" :key="h.id" class="habit-row" :class="{ done: isDone(h, todayKey), swipeable: h.kind !== 'numeric', ready: (habitSwipeOffsets[h.id] ?? 0) >= 64 }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px` }" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
|
||||
<span v-if="h.kind !== 'numeric'" class="swipe-bg">✓ 打卡</span>
|
||||
<div v-if="view === 'habits'" class="habit-list">
|
||||
<article v-for="h in habits" :key="h.id" class="habit-row" :class="{ done: isDone(h, todayKey), swipeable: h.kind !== 'numeric', ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64 }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px`, '--swipe-width': `${Math.abs(habitSwipeOffsets[h.id] ?? 0)}px` }" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
|
||||
<span v-if="h.kind !== 'numeric'" class="swipe-bg" :class="{ cancel: isDone(h, todayKey) }">{{ isDone(h, todayKey) ? '↩ 未完成' : '✓ 打卡' }}</span>
|
||||
<div v-if="h.kind !== 'numeric'" class="habit-main">
|
||||
<span><span class="habit-name">{{ h.name }}</span><small>{{ isDone(h, todayKey) ? '今天已完成 · 右滑可取消' : '右滑整行打卡' }}</small></span>
|
||||
<span><span class="habit-name">{{ h.name }}</span><small>{{ isDone(h, todayKey) ? '今天已完成 · 左滑取消' : '右滑整行打卡' }}</small></span>
|
||||
<button class="habit-swipe-hint" :aria-label="isDone(h, todayKey) ? `取消${h.name}今天的打卡` : `完成${h.name}今天的打卡`" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)">{{ isDone(h, todayKey) ? '已完成' : '打卡' }}</button>
|
||||
</div>
|
||||
<div v-else class="habit-main numeric-habit">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*{box-sizing:border-box}body{margin:0;background:#f8f3e8}button,input,textarea,select{font:inherit;color:inherit}button{cursor:pointer}svg{width:18px;height:18px;stroke-width:1.8}.center,.auth-shell{min-height:100vh;display:grid;place-items:center}.center{gap:12px;align-content:center;color:var(--muted)}.loader{width:24px;height:24px;border:2px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.auth-shell{background:radial-gradient(circle at 20% 10%,#ffe3d5 0,transparent 28%),linear-gradient(135deg,#f8f3e8,#fffaf0)}.auth-card{width:min(390px,90vw);padding:38px;background:var(--paper);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);display:grid;gap:16px}.brand{font-size:40px;font-weight:850;letter-spacing:-3px}.brand span{color:var(--accent)}.brand.small{font-size:29px}.auth-card p{margin:0 0 8px;color:var(--muted)}.auth-card label,.detail-form>label{display:grid;gap:7px;font-size:12px;font-weight:650;color:#756d61}.auth-card input,.detail-form input,.detail-form select{width:100%;border:1px solid var(--line);background:#fff;padding:11px;border-radius:9px;outline:none}.auth-card input:focus,.detail-form input:focus,.detail-form select:focus,.detail-form textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.1)}.primary{border:0;background:var(--accent);color:#fff;padding:12px;border-radius:9px;font-weight:700;box-shadow:0 5px 12px rgba(241,90,41,.2)}.auth-card small{color:var(--danger)}
|
||||
.shell{height:100vh;display:grid;grid-template-columns:238px minmax(430px,1fr) 350px;background:var(--paper);overflow:hidden}.sidebar{border-right:1px solid var(--line);background:var(--sidebar);display:flex;flex-direction:column;min-height:0}.brand-row{height:76px;padding:0 20px;display:flex;align-items:center;justify-content:space-between}.primary-nav{display:grid;padding:4px 12px 12px;gap:3px}.primary-nav button,.settings{display:flex;align-items:center;gap:10px;border:0;background:transparent;padding:10px 12px;border-radius:9px;color:#665e52;text-align:left}.primary-nav button:hover,.list-row:hover,.folder-row>button:hover{background:rgba(255,255,255,.52)}.primary-nav button.active,.list-row.active{background:var(--accent-soft);color:#b7421e;font-weight:700}.section-title{padding:17px 17px 7px 22px;display:flex;justify-content:space-between;align-items:center;color:#958b7d;text-transform:uppercase;letter-spacing:.08em;font-size:11px;font-weight:750}.section-title>span:last-child{display:flex}.mini-icon,.row-actions button{border:0;background:transparent;padding:4px;color:#8e8477}.mini-icon svg,.row-actions svg{width:14px;height:14px}.folders{padding:0 10px;overflow:auto}.folder-row{display:flex;align-items:center}.folder-row>button{flex:1;min-width:0;border:0;background:transparent;padding:8px;display:flex;align-items:center;gap:7px;color:#6d6559;text-align:left}.folder-row>button svg{width:14px}.row-actions{display:flex;opacity:0;transition:opacity .15s}.folder-row:hover .row-actions,.list-row:hover .row-actions{opacity:1}.list-row{width:100%;border:0;background:transparent;padding:8px 7px 8px 31px;border-radius:8px;display:flex;align-items:center;gap:9px;text-align:left;color:#665f55}.list-row>span:nth-child(2){flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.list-row i{width:8px;height:8px;background:#d89b62;border-radius:3px}.settings{margin:auto 12px 14px}.mobile-only,.bottom,.fab{display:none}
|
||||
main{min-width:0;padding:27px 34px 50px;overflow:auto;background:linear-gradient(rgba(255,253,248,.92),rgba(255,253,248,.92)),repeating-linear-gradient(0deg,transparent,transparent 31px,#eee3d2 32px)}.topbar{display:flex;align-items:center;gap:14px}.topbar>div{flex:1}.topbar p{margin:0;color:var(--muted);font-size:12px}.topbar h1{font-size:27px;margin:3px 0 21px;letter-spacing:-.03em}.search{margin-bottom:18px;display:flex;align-items:center;gap:8px;width:min(260px,36%);padding:8px 10px;background:#faf7f0;border:1px solid var(--line);border-radius:9px;color:var(--muted)}.search input{min-width:0;width:100%;border:0;outline:0;background:transparent}.search kbd{font-size:10px;white-space:nowrap;border:1px solid var(--line);padding:2px 4px;border-radius:4px}.quick{display:flex;align-items:center;gap:10px;background:#fff;border:1px solid var(--line);border-radius:11px;padding:7px 7px 7px 13px;box-shadow:0 2px 10px rgba(81,61,38,.05);transition:.18s}.quick:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.09)}.quick>svg{color:var(--accent)}.quick input{border:0;outline:0;background:transparent;min-width:0;flex:1}.quick button{border:0;border-radius:8px;background:var(--accent);color:white;padding:8px 15px;font-weight:650}.list-toolbar{height:42px;display:flex;align-items:center;gap:14px;color:var(--muted);font-size:12px}.list-toolbar label{margin-right:auto}.link{border:0;background:transparent;color:var(--accent);padding:3px;display:inline-flex;gap:3px;align-items:center}.link svg{width:14px}.task-list{transition:opacity .2s}.task-list.loading{opacity:.45}.task-row{--swipe-x:0px;min-height:53px;display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--line);padding:4px 8px;transition:background .15s,transform .18s ease;position:relative;overflow:hidden;isolation:isolate}.task-row.swipeable{touch-action:pan-y;transform:translateX(var(--swipe-x))}.task-row.swipeable:not([style*="0px"]){transition:none}.task-row:hover,.task-row.selected{background:rgba(250,240,229,.75)}.task-row:hover{transform:translateX(calc(var(--swipe-x) + 2px))}.swipe-bg{position:absolute;z-index:-1;inset:0 auto 0 calc(-1 * var(--swipe-x));width:var(--swipe-x);min-width:0;display:flex;align-items:center;padding-left:14px;background:#e9f7ee;color:#24814f;font-size:12px;font-weight:800;white-space:nowrap;overflow:hidden}.task-row.ready .swipe-bg,.habit-row.ready .swipe-bg{background:#dff3e7;color:#187142}.check{width:19px;height:19px;flex:0 0 19px;border:1.6px solid #c6baa8;background:#fff;border-radius:5px;padding:0;display:grid;place-items:center}.check svg{width:13px}.check.p1{border-color:#4b93d1}.check.p2{border-color:#d79b25}.check.p3{border-color:#dc4b30}.done .check{background:#afa595;color:white}.task-main{border:0;background:transparent;flex:1;min-width:0;text-align:left;padding:8px 0}.task-main strong{display:block;font-size:14px;font-weight:590;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.done .task-main strong{text-decoration:line-through;color:#9b9388}.task-swipe-hint{min-height:44px;flex:0 0 auto;border:0;border-radius:999px;background:#f3ede3;color:#9a8f80;padding:8px 11px;font-size:11px;font-weight:700}.task-row.done .task-swipe-hint{background:var(--accent-soft);color:#b7421e}.meta{display:flex;align-items:center;gap:9px;color:#9b9286;font-size:11px;margin-top:3px}.meta span{display:flex;align-items:center;gap:3px}.meta svg{width:12px}.tag-dot{width:7px;height:7px;border-radius:50%}.priority{font-size:10px;font-weight:750;padding:3px 6px;border-radius:5px}.priority.p1{color:#3f80ba;background:#e5f2fc}.priority.p2{color:#a56b05;background:#fff1cb}.priority.p3{color:#bd3827;background:#fde2dc}.icon,.ghost{border:0;background:transparent;display:grid;place-items:center;padding:5px;border-radius:6px}.ghost{opacity:0;color:#9d9387}.task-row:hover .ghost{opacity:1}.ghost:hover{color:var(--danger);background:#fce7e2}.restore{display:flex;align-items:center;gap:5px;border:1px solid var(--line);background:#fff;border-radius:7px;padding:6px 8px;font-size:12px}.restore svg{width:14px}.subtask{padding-left:53px;min-height:42px;color:#6d655b}.subtask>svg{width:13px;color:#bbb0a2}.empty{min-height:300px;display:grid;place-items:center;align-content:center;gap:8px;color:#aaa094;text-align:center}.empty>svg{width:38px;height:38px;color:#d8cabb}.empty b{color:#6f675c}.empty span{font-size:13px}
|
||||
main{min-width:0;padding:27px 34px 50px;overflow:auto;background:linear-gradient(rgba(255,253,248,.92),rgba(255,253,248,.92)),repeating-linear-gradient(0deg,transparent,transparent 31px,#eee3d2 32px)}.topbar{display:flex;align-items:center;gap:14px}.topbar>div{flex:1}.topbar p{margin:0;color:var(--muted);font-size:12px}.topbar h1{font-size:27px;margin:3px 0 21px;letter-spacing:-.03em}.search{margin-bottom:18px;display:flex;align-items:center;gap:8px;width:min(260px,36%);padding:8px 10px;background:#faf7f0;border:1px solid var(--line);border-radius:9px;color:var(--muted)}.search input{min-width:0;width:100%;border:0;outline:0;background:transparent}.search kbd{font-size:10px;white-space:nowrap;border:1px solid var(--line);padding:2px 4px;border-radius:4px}.quick{display:flex;align-items:center;gap:10px;background:#fff;border:1px solid var(--line);border-radius:11px;padding:7px 7px 7px 13px;box-shadow:0 2px 10px rgba(81,61,38,.05);transition:.18s}.quick:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.09)}.quick>svg{color:var(--accent)}.quick input{border:0;outline:0;background:transparent;min-width:0;flex:1}.quick button{border:0;border-radius:8px;background:var(--accent);color:white;padding:8px 15px;font-weight:650}.list-toolbar{height:42px;display:flex;align-items:center;gap:14px;color:var(--muted);font-size:12px}.list-toolbar label{margin-right:auto}.link{border:0;background:transparent;color:var(--accent);padding:3px;display:inline-flex;gap:3px;align-items:center}.link svg{width:14px}.task-list{transition:opacity .2s}.task-list.loading{opacity:.45}.task-row{--swipe-x:0px;min-height:53px;display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--line);padding:4px 8px;transition:background .15s,transform .18s ease;position:relative;overflow:hidden;isolation:isolate}.task-row.swipeable{touch-action:pan-y;transform:translateX(var(--swipe-x))}.task-row.swipeable:not([style*="0px"]){transition:none}.task-row:hover,.task-row.selected{background:rgba(250,240,229,.75)}.task-row:hover{transform:translateX(calc(var(--swipe-x) + 2px))}.swipe-bg{position:absolute;z-index:-1;inset:0 auto 0 calc(-1 * var(--swipe-x));width:var(--swipe-width);min-width:0;display:flex;align-items:center;padding-left:14px;background:#e9f7ee;color:#24814f;font-size:12px;font-weight:800;white-space:nowrap;overflow:hidden}.swipe-bg.cancel{left:auto;right:var(--swipe-x);justify-content:flex-end;padding:0 14px 0 0;background:#fff2e8;color:#c65a23}.task-row.ready .swipe-bg,.habit-row.ready .swipe-bg{background:#dff3e7;color:#187142}.task-row.ready .swipe-bg.cancel,.habit-row.ready .swipe-bg.cancel{background:#ffe4d3;color:#b94716}.check{width:19px;height:19px;flex:0 0 19px;border:1.6px solid #c6baa8;background:#fff;border-radius:5px;padding:0;display:grid;place-items:center}.check svg{width:13px}.check.p1{border-color:#4b93d1}.check.p2{border-color:#d79b25}.check.p3{border-color:#dc4b30}.done .check{background:#afa595;color:white}.task-main{border:0;background:transparent;flex:1;min-width:0;text-align:left;padding:8px 0}.task-main strong{display:block;font-size:14px;font-weight:590;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.done .task-main strong{text-decoration:line-through;color:#9b9388}.task-swipe-hint{min-height:44px;flex:0 0 auto;border:0;border-radius:999px;background:#f3ede3;color:#9a8f80;padding:8px 11px;font-size:11px;font-weight:700}.task-row.done .task-swipe-hint{background:var(--accent-soft);color:#b7421e}.meta{display:flex;align-items:center;gap:9px;color:#9b9286;font-size:11px;margin-top:3px}.meta span{display:flex;align-items:center;gap:3px}.meta svg{width:12px}.tag-dot{width:7px;height:7px;border-radius:50%}.priority{font-size:10px;font-weight:750;padding:3px 6px;border-radius:5px}.priority.p1{color:#3f80ba;background:#e5f2fc}.priority.p2{color:#a56b05;background:#fff1cb}.priority.p3{color:#bd3827;background:#fde2dc}.icon,.ghost{border:0;background:transparent;display:grid;place-items:center;padding:5px;border-radius:6px}.ghost{opacity:0;color:#9d9387}.task-row:hover .ghost{opacity:1}.ghost:hover{color:var(--danger);background:#fce7e2}.restore{display:flex;align-items:center;gap:5px;border:1px solid var(--line);background:#fff;border-radius:7px;padding:6px 8px;font-size:12px}.restore svg{width:14px}.subtask{padding-left:53px;min-height:42px;color:#6d655b}.subtask>svg{width:13px;color:#bbb0a2}.empty{min-height:300px;display:grid;place-items:center;align-content:center;gap:8px;color:#aaa094;text-align:center}.empty>svg{width:38px;height:38px;color:#d8cabb}.empty b{color:#6f675c}.empty span{font-size:13px}
|
||||
.detail{min-width:0;border-left:1px solid var(--line);background:#faf7f0;overflow:auto}.detail-head{height:57px;display:flex;align-items:center;justify-content:space-between;padding:0 21px;border-bottom:1px solid var(--line);font-size:12px;font-weight:700;color:#80766a;text-transform:uppercase;letter-spacing:.08em}.paper{margin:22px;padding:28px 20px;min-height:180px;background:#fff;border:1px solid var(--line);border-radius:11px;box-shadow:0 4px 18px rgba(76,57,34,.05);display:grid;place-items:center;align-content:center;text-align:center;color:#8f8578}.paper svg{width:32px;height:32px;color:#ceb8a4;margin-bottom:12px}.paper b{color:#625b50}.paper p{font-size:13px;line-height:1.6}.detail-form{padding:19px;display:grid;gap:15px}.detail-title{display:flex;align-items:flex-start;gap:10px}.check.large{margin-top:8px;width:22px;height:22px;flex-basis:22px}.detail-title textarea{flex:1;border:0;background:transparent;resize:none;outline:none;font-size:19px;line-height:1.4;font-weight:700}.detail-form>label{grid-template-columns:80px 1fr;align-items:center}.detail-form>label input,.detail-form>label select{padding:8px}.field{display:grid;gap:7px}.field-label{display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:700;color:#756d61}.tag-picker{display:flex;flex-wrap:wrap;gap:6px}.tag-picker button{border:1px solid var(--line);background:#fff;border-radius:999px;padding:5px 8px;display:flex;align-items:center;gap:5px;font-size:11px}.tag-picker button.chosen{border-color:#c89077;background:#fff1e9}.tag-picker i{width:8px;height:8px;border-radius:50%}.hint{font-size:12px;color:#a49a8d}.markdown .field-label>span:last-child{display:flex;background:#eee7dc;padding:2px;border-radius:6px}.markdown .field-label button{border:0;background:transparent;padding:4px 8px;border-radius:5px;font-size:11px}.markdown .field-label button.active{background:#fff;color:var(--accent)}.markdown textarea{border:1px solid var(--line);background:#fff;border-radius:9px;padding:11px;resize:vertical;outline:none;font:13px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown-preview{min-height:160px;padding:10px 12px;background:#fff;border:1px solid var(--line);border-radius:9px;font-size:13px;line-height:1.65;overflow-wrap:anywhere}.markdown-preview h1{font-size:20px}.markdown-preview h2{font-size:16px}.markdown-preview p{margin:8px 0}.markdown-preview code{background:#f2ece2;padding:2px 4px;border-radius:4px}.markdown-preview a{color:var(--accent)}.subtasks{display:grid;gap:5px}.subtask-detail{display:flex;align-items:center;gap:8px;border:0;background:#fff;padding:8px;border-radius:7px;text-align:left}.subtask-detail .check{pointer-events:none}.strike{text-decoration:line-through;color:var(--muted)}.detail-actions{display:flex;justify-content:space-between;align-items:center;padding-top:10px;border-top:1px solid var(--line)}.secondary{border:1px solid var(--line);background:#fff;padding:8px 11px;border-radius:8px;font-weight:650}.danger-text{border:0;background:transparent;color:var(--danger);display:flex;align-items:center;gap:5px;font-size:12px}.danger-text svg{width:14px}
|
||||
.toast,.error-toast{position:fixed;z-index:50;left:50%;bottom:24px;transform:translateX(-50%);background:#322d28;color:#fff;border-radius:9px;padding:10px 15px;box-shadow:var(--shadow);font-size:13px}.error-toast{background:var(--danger);display:flex;align-items:center;gap:10px}.error-toast button{border:0;background:transparent;color:#fff;padding:0}.toast-enter-active,.toast-leave-active{transition:.2s}.toast-enter-from,.toast-leave-to{opacity:0;transform:translate(-50%,8px)}
|
||||
@media(max-width:1050px){.shell{grid-template-columns:220px minmax(400px,1fr) 310px}main{padding-inline:24px}}
|
||||
|
||||
@@ -15,6 +15,7 @@ dependencies = [
|
||||
"uuid-utils>=0.11,<1",
|
||||
"structlog>=25,<26",
|
||||
"apscheduler>=3.11,<4",
|
||||
"icalendar>=6,<7",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -34,6 +34,59 @@ def test_calendar_endpoint_is_removed(client):
|
||||
assert client.get("/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30"}).status_code == 404
|
||||
|
||||
|
||||
def test_calendar_parser_handles_tzid_recurrence_folding_and_multiday():
|
||||
from backend.mvp import parse_ics_events
|
||||
|
||||
ics = """BEGIN:VCALENDAR\r
|
||||
VERSION:2.0\r
|
||||
BEGIN:VEVENT\r
|
||||
UID:tzid-recurring\r
|
||||
DTSTART;TZID=Asia/Shanghai:20260906T090000\r
|
||||
DTEND;TZID=Asia/Shanghai:20260906T100000\r
|
||||
RRULE:FREQ=DAILY;COUNT=2\r
|
||||
EXDATE;TZID=Asia/Shanghai:20260907T090000\r
|
||||
SUMMARY:折叠\r
|
||||
标题\r
|
||||
END:VEVENT\r
|
||||
BEGIN:VEVENT\r
|
||||
UID:multi-day\r
|
||||
DTSTART;TZID=Asia/Shanghai:20260905T230000\r
|
||||
DTEND;TZID=Asia/Shanghai:20260906T010000\r
|
||||
SUMMARY:跨天事件\r
|
||||
END:VEVENT\r
|
||||
END:VCALENDAR\r
|
||||
"""
|
||||
events = parse_ics_events(
|
||||
ics,
|
||||
"测试日历",
|
||||
"#fff",
|
||||
datetime(2026, 9, 5, 16, tzinfo=UTC),
|
||||
datetime(2026, 9, 6, 16, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert [(row["title"], row["starts_at"].isoformat()) for row in events] == [
|
||||
("折叠标题", "2026-09-06T01:00:00+00:00"),
|
||||
("跨天事件", "2026-09-05T15:00:00+00:00"),
|
||||
]
|
||||
|
||||
|
||||
def test_calendar_url_rejects_private_addresses(monkeypatch):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from backend.mvp import _validated_calendar_target
|
||||
|
||||
monkeypatch.setattr(
|
||||
"backend.mvp.socket.getaddrinfo",
|
||||
lambda *_args, **_kwargs: [(None, None, None, None, ("127.0.0.1", 80))],
|
||||
)
|
||||
try:
|
||||
_validated_calendar_target("http://example.com/calendar.ics")
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 422
|
||||
else:
|
||||
raise AssertionError("private calendar target should be rejected")
|
||||
|
||||
|
||||
def test_recurrence_mutations_keep_exact_timestamp_validation(client):
|
||||
inbox = boot(client)
|
||||
task = client.post(
|
||||
|
||||
@@ -289,6 +289,7 @@ dependencies = [
|
||||
{ name = "argon2-cffi" },
|
||||
{ name = "asyncpg" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "icalendar" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "sqlalchemy", extra = ["asyncio"] },
|
||||
@@ -313,6 +314,7 @@ requires-dist = [
|
||||
{ name = "argon2-cffi", specifier = ">=25,<26" },
|
||||
{ name = "asyncpg", specifier = ">=0.30,<1" },
|
||||
{ name = "fastapi", specifier = ">=0.116,<1" },
|
||||
{ name = "icalendar", specifier = ">=6,<7" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.10,<3" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.20,<1" },
|
||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0,<3" },
|
||||
@@ -486,6 +488,19 @@ wheels = [
|
||||
{ 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]]
|
||||
name = "idna"
|
||||
version = "3.19"
|
||||
@@ -748,6 +763,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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.3"
|
||||
@@ -837,6 +864,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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.52"
|
||||
|
||||
Reference in New Issue
Block a user