fix: harden calendar subscriptions and add left-swipe reopen
ci / docker (push) Successful in 3m31s

This commit is contained in:
2026-09-06 09:45:00 +08:00
parent b937c8ca72
commit c98223b7d3
12 changed files with 264 additions and 103 deletions
+139 -83
View File
@@ -1,18 +1,21 @@
import asyncio import asyncio
import csv import csv
import http.client
import io import io
import ipaddress import ipaddress
import re import re
import socket import socket
import urllib.error import ssl
import urllib.parse import urllib.parse
import urllib.request
from datetime import UTC, date, datetime, time, timedelta from datetime import UTC, date, datetime, time, timedelta
from pathlib import Path from pathlib import Path
from uuid import UUID 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 import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from icalendar import Calendar
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
from sqlalchemy import case, delete, func, select from sqlalchemy import case, delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession 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} return {"id": row.id, "name": row.name, "url": row.url, "color": row.color, "enabled": row.enabled}
def parse_ics_datetime(value: str): LOCAL_TZ = ZoneInfo("Asia/Shanghai")
raw = value.strip()
if len(raw) == 8 and raw.isdigit():
return datetime.strptime(raw, "%Y%m%d").replace(tzinfo=UTC), True
cleaned = raw.removesuffix("Z")
parsed = datetime.fromisoformat(cleaned)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC), False
def parse_ics_events(content: str, source_name: str, color: str): 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 = [] events = []
current = None for event in calendar.walk("VEVENT"):
for raw_line in content.splitlines(): if not event.get("dtstart"):
line = raw_line.strip()
if line == "BEGIN:VEVENT":
current = {}
continue continue
if line == "END:VEVENT": starts_at, all_day = _as_utc(event.decoded("dtstart"))
if current and current.get("title") and current.get("starts_at"): duration = _event_duration(event, starts_at, all_day)
events.append({ excluded = _excluded_starts(event)
"id": current.get("uid") or f"{source_name}-{current['starts_at'].isoformat()}-{current['title']}", if event.get("rrule") and window_start and window_end:
"title": current["title"], rule_text = event.get("rrule").to_ical().decode()
"starts_at": current["starts_at"], rule = rrulestr(rule_text, dtstart=starts_at)
"ends_at": current.get("ends_at"), for occurrence in rule.between(window_start - duration, window_end, inc=True):
"all_day": current.get("all_day", False), occurrence = occurrence.astimezone(UTC) if occurrence.tzinfo else occurrence.replace(tzinfo=UTC)
"source_name": source_name, if occurrence in excluded:
"color": color, continue
}) ends_at = occurrence + duration
current = None if _overlaps(occurrence, ends_at, window_start, window_end):
_append_calendar_event(events, source_name, color, event, occurrence, ends_at, all_day)
continue continue
if current is None or ":" not in line: ends_at = starts_at + duration
continue if _overlaps(starts_at, ends_at, window_start, window_end):
key, value = line.split(":", 1) _append_calendar_event(events, source_name, color, event, starts_at, ends_at, all_day)
field = key.split(";", 1)[0].upper()
if field == "SUMMARY":
current["title"] = value.strip()
elif field == "UID":
current["uid"] = value.strip()
elif field in {"DTSTART", "DTEND"}:
try:
dt, all_day = parse_ics_datetime(value)
except ValueError:
continue
current["all_day"] = current.get("all_day", False) or all_day
current["starts_at" if field == "DTSTART" else "ends_at"] = dt
return events return events
def _validate_public_calendar_url(url: str): def _validated_calendar_target(url: str):
parsed = urllib.parse.urlparse(url) parsed = urllib.parse.urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname: if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise HTTPException(422, "日历订阅只支持 http/https 链接") 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: try:
addresses = socket.getaddrinfo(parsed.hostname, None) addresses = socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_STREAM)
except socket.gaierror as exc: except socket.gaierror as exc:
raise HTTPException(422, "日历订阅域名无法解析") from exc raise HTTPException(422, "日历订阅域名无法解析") from exc
for item in addresses: for item in addresses:
ip = ipaddress.ip_address(item[4][0]) ip = item[4][0]
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved: if not _is_forbidden_ip(ip):
raise HTTPException(422, "日历订阅不能指向内网地址") return parsed, ip, port
raise HTTPException(422, "日历订阅不能指向内网地址")
def fetch_calendar_events(url: str, source_name: str, color: str): def _validate_public_calendar_url(url: str):
class NoRedirect(urllib.request.HTTPRedirectHandler): _validated_calendar_target(url)
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
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 current_url = url
opener = urllib.request.build_opener(NoRedirect)
for _ in range(4): for _ in range(4):
_validate_public_calendar_url(current_url) status, headers, content_type, body = _request_pinned_calendar_url(current_url)
try: if status in {301, 302, 303, 307, 308}:
with opener.open(current_url, timeout=15) as response: location = dict(headers).get("Location")
content_type = response.headers.get_content_type()
if content_type not in {"text/calendar", "text/plain", "application/octet-stream"}:
raise HTTPException(422, "订阅链接没有返回 ICS 日历内容")
body = response.read(2_000_001)
if len(body) > 2_000_000:
raise HTTPException(413, "日历订阅内容超过 2MB")
return parse_ics_events(body.decode("utf-8", errors="ignore"), source_name, color)
except urllib.error.HTTPError as exc:
if exc.code not in {301, 302, 303, 307, 308}:
raise HTTPException(502, f"订阅拉取失败: HTTP {exc.code}") from exc
location = exc.headers.get("Location")
if not location: if not location:
raise HTTPException(502, "订阅重定向缺少目标地址") from exc raise HTTPException(502, "订阅重定向缺少目标地址")
current_url = urllib.parse.urljoin(current_url, location) current_url = urllib.parse.urljoin(current_url, location)
except urllib.error.URLError as exc: continue
raise HTTPException(502, f"订阅拉取失败: {exc.reason}") from exc 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, "日历订阅重定向次数过多") raise HTTPException(502, "日历订阅重定向次数过多")
@@ -388,11 +451,11 @@ async def today_calendar_events(
if not rows: if not rows:
return [] return []
tomorrow = day + timedelta(days=1) tomorrow = day + timedelta(days=1)
day_start = datetime.combine(day, time.min, tzinfo=UTC) day_start = datetime.combine(day, time.min, tzinfo=LOCAL_TZ).astimezone(UTC)
day_end = datetime.combine(tomorrow, time.min, tzinfo=UTC) day_end = datetime.combine(tomorrow, time.min, tzinfo=LOCAL_TZ).astimezone(UTC)
async def pull(row: CalendarSubscription): 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) results = await asyncio.gather(*(pull(row) for row in rows), return_exceptions=True)
events = [] events = []
@@ -400,18 +463,11 @@ async def today_calendar_events(
if isinstance(result, Exception): if isinstance(result, Exception):
continue continue
for item in result: 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({ events.append({
"id": item["id"], "id": item["id"],
"title": item["title"], "title": item["title"],
"starts_at": starts_at.isoformat(), "starts_at": item["starts_at"].isoformat(),
"ends_at": ends_at.isoformat() if ends_at else None, "ends_at": item["ends_at"].isoformat() if item.get("ends_at") else None,
"all_day": item["all_day"], "all_day": item["all_day"],
"source_name": row.name, "source_name": row.name,
"color": row.color, "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
+2 -2
View File
@@ -1,3 +1,3 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-BizE1tvq.js"></script> <!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-BR_rdQ3j.css"> <link rel="stylesheet" crossorigin href="/assets/index-CaaWUARH.css">
</head><body><div id="app"></div></body></html> </head><body><div id="app"></div></body></html>
+12 -5
View File
@@ -249,7 +249,11 @@ function moveTaskSwipe(task: Task, event: TouchEvent) {
if (!start || start.id !== task.id || !touch) return if (!start || start.id !== task.id || !touch) return
const deltaX = touch.clientX - start.x const deltaX = touch.clientX - start.x
const deltaY = touch.clientY - start.y 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) { async function finishTaskSwipe(task: Task, event: TouchEvent) {
const start = taskSwipeStart.value const start = taskSwipeStart.value
@@ -258,7 +262,10 @@ async function finishTaskSwipe(task: Task, event: TouchEvent) {
taskSwipeOffsets.value[task.id] = 0 taskSwipeOffsets.value[task.id] = 0
if (!start || start.id !== task.id || activeView.value === 'trash' || loading.value) return if (!start || start.id !== task.id || activeView.value === 'trash' || loading.value) return
const touch = event.changedTouches[0] 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 suppressTaskClickId = task.id
window.setTimeout(() => { if (suppressTaskClickId === task.id) suppressTaskClickId = '' }, 400) window.setTimeout(() => { if (suppressTaskClickId === task.id) suppressTaskClickId = '' }, 400)
await toggle(task) 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> <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}"> <section class="task-list" :class="{loading}">
<template v-for="node in taskTree" :key="node.task.id"> <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)"> <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"> 完成</span> <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> <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> <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> <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-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> <button v-if="activeView==='trash'" class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button>
</article> </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> </template>
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div> <div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div>
</section> </section>
+14 -6
View File
@@ -95,7 +95,12 @@ function moveHabitSwipe(h: Habit, event: TouchEvent) {
if (!start || start.id !== h.id || !touch) return if (!start || start.id !== h.id || !touch) return
const deltaX = touch.clientX - start.x const deltaX = touch.clientX - start.x
const deltaY = touch.clientY - start.y 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) { async function finishHabitSwipe(h: Habit, event: TouchEvent) {
const start = habitSwipeStart.value const start = habitSwipeStart.value
@@ -103,7 +108,10 @@ async function finishHabitSwipe(h: Habit, event: TouchEvent) {
habitSwipeOffsets.value[h.id] = 0 habitSwipeOffsets.value[h.id] = 0
if (!start || start.id !== h.id || h.kind === 'numeric' || busy.value) return if (!start || start.id !== h.id || h.kind === 'numeric' || busy.value) return
const touch = event.changedTouches[0] 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) await toggleHabit(h, todayKey.value)
} }
} }
@@ -283,11 +291,11 @@ onBeforeUnmount(() => {
<!-- 习惯列表布尔习惯整行右滑打卡数值习惯保留明确输入记录 --> <!-- 习惯列表布尔习惯整行右滑打卡数值习惯保留明确输入记录 -->
<div class="habit-list"> <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: (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)"> <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"> 打卡</span> <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"> <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> <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>
<div v-else class="habit-main numeric-habit"> <div v-else class="habit-main numeric-habit">
+1 -1
View File
@@ -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)}} *{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)} .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} .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} .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)} .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}} @media(max-width:1050px){.shell{grid-template-columns:220px minmax(400px,1fr) 310px}main{padding-inline:24px}}
+1
View File
@@ -15,6 +15,7 @@ dependencies = [
"uuid-utils>=0.11,<1", "uuid-utils>=0.11,<1",
"structlog>=25,<26", "structlog>=25,<26",
"apscheduler>=3.11,<4", "apscheduler>=3.11,<4",
"icalendar>=6,<7",
] ]
[dependency-groups] [dependency-groups]
+53
View File
@@ -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 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): def test_recurrence_mutations_keep_exact_timestamp_validation(client):
inbox = boot(client) inbox = boot(client)
task = client.post( task = client.post(
Generated
+36
View File
@@ -289,6 +289,7 @@ dependencies = [
{ name = "argon2-cffi" }, { name = "argon2-cffi" },
{ name = "asyncpg" }, { name = "asyncpg" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "icalendar" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "python-multipart" }, { name = "python-multipart" },
{ name = "sqlalchemy", extra = ["asyncio"] }, { name = "sqlalchemy", extra = ["asyncio"] },
@@ -313,6 +314,7 @@ requires-dist = [
{ name = "argon2-cffi", specifier = ">=25,<26" }, { name = "argon2-cffi", specifier = ">=25,<26" },
{ 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 = "icalendar", specifier = ">=6,<7" },
{ name = "pydantic-settings", specifier = ">=2.10,<3" }, { name = "pydantic-settings", specifier = ">=2.10,<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" },
@@ -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 }, { 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"
@@ -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 }, { 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"
@@ -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 }, { 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"