This commit is contained in:
@@ -806,6 +806,8 @@ if static_dir.exists():
|
||||
|
||||
@app.get("/{path:path}", include_in_schema=False)
|
||||
async def spa(path: str):
|
||||
if path == "api" or path.startswith("api/"):
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
root = static_dir.resolve()
|
||||
target = (root / path).resolve()
|
||||
headers = {"Cache-Control": "no-cache, no-store, must-revalidate, max-age=0"}
|
||||
|
||||
+3
-122
@@ -4,7 +4,6 @@ import re
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -127,7 +126,6 @@ def occurrences(rule: str, starts: datetime, start: datetime, end: datetime, cut
|
||||
def is_occurrence(rule: str, starts: datetime, at: datetime) -> bool:
|
||||
"""Return True when `at` is the exact timestamp of an occurrence this rule generates."""
|
||||
parts = parse_rrule(rule)
|
||||
interval = int(parts.get("INTERVAL", 1))
|
||||
if starts.tzinfo is None:
|
||||
starts = starts.replace(tzinfo=UTC)
|
||||
else:
|
||||
@@ -143,38 +141,9 @@ def is_occurrence(rule: str, starts: datetime, at: datetime) -> bool:
|
||||
until = until.replace(tzinfo=UTC) if until.tzinfo is None else until.astimezone(UTC)
|
||||
if at > until:
|
||||
return False
|
||||
if "COUNT" in parts:
|
||||
count = int(parts["COUNT"])
|
||||
match = 0
|
||||
cursor = starts
|
||||
guard = 0
|
||||
while cursor <= at and guard < 40000:
|
||||
if parts["FREQ"] == "DAILY":
|
||||
include = (cursor.date() - starts.date()).days % interval == 0
|
||||
elif parts["FREQ"] == "WEEKLY":
|
||||
days = {_WEEKDAYS[x] for x in parts.get("BYDAY", list(_WEEKDAYS)[starts.weekday()]).split(",")}
|
||||
include = cursor.weekday() in days and ((cursor.date() - starts.date()).days // 7) % interval == 0
|
||||
elif parts["FREQ"] == "MONTHLY":
|
||||
month_delta = (cursor.year - starts.year) * 12 + cursor.month - starts.month
|
||||
month_days = {int(x) for x in parts.get("BYMONTHDAY", str(starts.day)).split(",")}
|
||||
include = month_delta % interval == 0 and cursor.day in month_days
|
||||
else:
|
||||
years = cursor.year - starts.year
|
||||
months = {int(x) for x in parts.get("BYMONTH", str(starts.month)).split(",")}
|
||||
month_days = {int(x) for x in parts.get("BYMONTHDAY", str(starts.day)).split(",")}
|
||||
include = years % interval == 0 and cursor.month in months and cursor.day in month_days
|
||||
if include and cursor >= starts:
|
||||
match += 1
|
||||
if cursor == at:
|
||||
return True
|
||||
if match >= count:
|
||||
return False
|
||||
guard += 1
|
||||
cursor += timedelta(days=1)
|
||||
return False
|
||||
# Exact-match validation via the same canonical day generator used for
|
||||
# rendering, so time-of-day is preserved and nothing outside the rule is
|
||||
# accepted as a valid occurrence.
|
||||
# Exact-match validation via the same canonical generator used for recurrence
|
||||
# mutations, so COUNT/UNTIL, time-of-day, BYDAY/BYMONTHDAY and sparse yearly
|
||||
# rules all share one behavior.
|
||||
candidates = occurrences(rule, starts, starts, at, None)
|
||||
return at in candidates
|
||||
|
||||
@@ -222,94 +191,6 @@ async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(curr
|
||||
return {"id": row.id, "task_id": row.task_id, "rrule": row.rrule, "starts_at": row.starts_at}
|
||||
|
||||
|
||||
@router.get("/calendar")
|
||||
async def calendar(start: date, end: date, timezone: str = Query(default="UTC", pattern=r"^[A-Za-z0-9_+\-]+(/[A-Za-z0-9_+\-]+)*$"), timezone_offset: int | None = Query(default=None, ge=-840, le=840), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
if end < start or (end - start).days > 366:
|
||||
raise HTTPException(422, "日期范围无效或超过一年")
|
||||
try:
|
||||
zone = ZoneInfo(timezone)
|
||||
except ZoneInfoNotFoundError:
|
||||
raise HTTPException(422, "未知时区")
|
||||
if timezone_offset is not None and timezone == "UTC":
|
||||
# Legacy clients pass an offset instead of an IANA name.
|
||||
zone = ZoneInfo("UTC")
|
||||
start_dt = datetime.combine(start, time.min, tzinfo=UTC) - timedelta(minutes=timezone_offset)
|
||||
end_dt = datetime.combine(end + timedelta(days=1), time.min, tzinfo=UTC) - timedelta(minutes=timezone_offset) - timedelta(microseconds=1)
|
||||
else:
|
||||
start_dt = datetime.combine(start, time.min, tzinfo=zone).astimezone(UTC)
|
||||
end_dt = datetime.combine(end + timedelta(days=1), time.min, tzinfo=zone).astimezone(UTC) - timedelta(microseconds=1)
|
||||
|
||||
def as_utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
recurrence_rows = (await db.execute(select(RecurrenceTemplate, Task).join(Task).where(RecurrenceTemplate.user_id == user.id, Task.deleted_at.is_(None)))).all()
|
||||
template_ids = [template.id for template, _ in recurrence_rows]
|
||||
exception_rows = [] if not template_ids else list((await db.scalars(select(RecurrenceException).where(RecurrenceException.template_id.in_(template_ids)))).all())
|
||||
exceptions_by_template = {}
|
||||
for exception in exception_rows:
|
||||
key = as_utc(exception.occurrence_at)
|
||||
exceptions_by_template.setdefault(exception.template_id, {})[key] = exception
|
||||
|
||||
recurring_task_ids = {task.id for _, task in recurrence_rows}
|
||||
normal_query = select(Task).where(
|
||||
Task.user_id == user.id,
|
||||
Task.deleted_at.is_(None),
|
||||
Task.parent_id.is_(None),
|
||||
Task.due_at >= start_dt,
|
||||
Task.due_at <= end_dt,
|
||||
)
|
||||
if recurring_task_ids:
|
||||
normal_query = normal_query.where(Task.id.not_in(recurring_task_ids))
|
||||
normal_tasks = list((await db.scalars(normal_query)).all())
|
||||
output = [{
|
||||
"id": task.id,
|
||||
"recurrence_id": None,
|
||||
"task_id": task.id,
|
||||
"occurrence_at": task.due_at,
|
||||
"title": task.title,
|
||||
"due_at": task.due_at,
|
||||
"completed": task.completed,
|
||||
"version": task.version,
|
||||
} for task in normal_tasks]
|
||||
emitted = set()
|
||||
for template, task in recurrence_rows:
|
||||
exceptions = exceptions_by_template.get(template.id, {})
|
||||
# Canonical series from the template's true start so every exception
|
||||
# whose occurrence is real and inside this series is considered.
|
||||
series_start = template.starts_at
|
||||
if exceptions:
|
||||
first_exception = min(as_utc(at) for at in exceptions)
|
||||
series_start = min(as_utc(series_start), first_exception)
|
||||
generated = occurrences(template.rrule, template.starts_at, as_utc(series_start), end_dt, template.ends_at)
|
||||
for at in generated:
|
||||
exception = exceptions.get(as_utc(at))
|
||||
if exception and exception.deleted:
|
||||
continue
|
||||
due_at = as_utc(exception.due_at) if exception and exception.due_at else at
|
||||
if due_at < start_dt or due_at > end_dt:
|
||||
continue
|
||||
output.append({"recurrence_id": template.id, "task_id": task.id, "occurrence_at": at, "title": exception.title if exception and exception.title else task.title, "due_at": due_at, "completed": bool(exception and exception.completed)})
|
||||
emitted.add(as_utc(at))
|
||||
# Fall back for stored exceptions whose original slot is real but which
|
||||
# the canonical generation skipped only because their original date is
|
||||
# outside the requested window (e.g. moved backwards across the month).
|
||||
for occurrence_at, exception in exceptions.items():
|
||||
if exception.deleted or exception.due_at is None:
|
||||
continue
|
||||
if occurrence_at in emitted:
|
||||
continue
|
||||
if not is_occurrence(template.rrule, template.starts_at, occurrence_at):
|
||||
continue
|
||||
if template.ends_at and occurrence_at > as_utc(template.ends_at):
|
||||
continue
|
||||
due_at = as_utc(exception.due_at)
|
||||
if start_dt <= due_at <= end_dt:
|
||||
output.append({"recurrence_id": template.id, "task_id": task.id, "occurrence_at": occurrence_at, "title": exception.title or task.title, "due_at": due_at, "completed": bool(exception.completed)})
|
||||
return sorted(output, key=lambda item: item["occurrence_at"].replace(tzinfo=UTC) if item["occurrence_at"].tzinfo is None else item["occurrence_at"])
|
||||
|
||||
|
||||
async def upsert_exception(db, template_id, at):
|
||||
row = await db.scalar(select(RecurrenceException).where(RecurrenceException.template_id == template_id, RecurrenceException.occurrence_at == at))
|
||||
if not row:
|
||||
|
||||
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-Dq8LoBCn.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CpBTIN38.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-CmIuUToo.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-nhCaMkMR.css">
|
||||
</head><body><div id="app"></div></body></html>
|
||||
@@ -1,5 +1,5 @@
|
||||
const CACHE = 'dodo-shell-v2'
|
||||
const SHELL = ['/', '/manifest.json', '/icon-192.png', '/icon-512.png', '/apple-touch-icon.png']
|
||||
const CACHE = 'dodo-shell-v3'
|
||||
const SHELL = ['/manifest.json', '/icon-192.png', '/icon-512.png', '/apple-touch-icon.png']
|
||||
self.addEventListener('install', (event) => {
|
||||
self.skipWaiting()
|
||||
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)))
|
||||
@@ -11,11 +11,16 @@ self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url)
|
||||
if (url.pathname.startsWith('/api/')) return
|
||||
if (event.request.method !== 'GET') return
|
||||
const isNavigation = event.request.mode === 'navigate'
|
||||
if (isNavigation) {
|
||||
event.respondWith(fetch(event.request).catch(() => caches.match('/offline.html')))
|
||||
return
|
||||
}
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => cached || fetch(event.request).then((response) => {
|
||||
const copy = response.clone()
|
||||
caches.open(CACHE).then((cache) => cache.put(event.request, copy))
|
||||
return response
|
||||
}).catch(() => caches.match('/'))),
|
||||
})),
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user