diff --git a/README.md b/README.md index 2ce3a1f..3305599 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - 首次初始化管理员 - 用户名密码登录,Cookie Session - 文件夹、清单、任务基础 CRUD -- 任务支持截止时间,以及每天 / 每周 / 每月 / 每年重复 +- 任务支持截止时间,以及每天 / 每周 / 每月 / 每年和自定义重复(间隔、星期、月日期、次数或截止日期) - 收集箱系统清单 - 习惯打卡与倒数纪念日 - 倒数日支持倒数日、纪念日、生日,以及每周/月/年重复 diff --git a/backend/mvp.py b/backend/mvp.py index f5ffa95..8085544 100644 --- a/backend/mvp.py +++ b/backend/mvp.py @@ -73,9 +73,22 @@ def parse_rrule(value: str) -> dict[str, str]: raise HTTPException(422, "无效的 RRULE") key, val = part.split("=", 1) parts[key] = val + allowed = {"FREQ", "INTERVAL", "BYDAY", "BYMONTHDAY", "BYMONTH", "COUNT", "UNTIL"} + if set(parts) - allowed: + raise HTTPException(422, "重复规则包含不支持的字段") if parts.get("FREQ") not in {"DAILY", "WEEKLY", "MONTHLY", "YEARLY"}: raise HTTPException(422, "仅支持 DAILY、WEEKLY、MONTHLY、YEARLY") + if "BYDAY" in parts: + weekdays = parts["BYDAY"].split(",") + if not weekdays or any(day not in _WEEKDAYS for day in weekdays): + raise HTTPException(422, "无效的重复星期") try: + month_days = [int(day) for day in parts.get("BYMONTHDAY", "").split(",") if day] + months = [int(month) for month in parts.get("BYMONTH", "").split(",") if month] + if any(day < 1 or day > 31 for day in month_days) or any(month < 1 or month > 12 for month in months): + raise ValueError + if "UNTIL" in parts: + datetime.fromisoformat(parts["UNTIL"]) if "INTERVAL" in parts and int(parts["INTERVAL"]) < 1: raise ValueError if "COUNT" in parts and int(parts["COUNT"]) < 1: diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e84b448..50f6393 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -5,7 +5,7 @@ import { GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search, Settings, Trash2, X, Repeat2, } from 'lucide-vue-next' -import { filterTasks, fromDateTimeLocal, groupTaskTree, moveItemWithinScope, renderMarkdown, toDateTimeLocal } from './lib/task-utils' +import { buildTaskRrule, filterTasks, fromDateTimeLocal, groupTaskTree, moveItemWithinScope, parseTaskRrule, renderMarkdown, toDateTimeLocal, type TaskRepeatConfig } from './lib/task-utils' import { defaultView, isTaskView, nextTotalAfterLocalTaskAdd, quickTaskFields, shouldToggleRowSwipe, writeCountdownCache } from './lib/mvp-utils' import { csrfHeader } from './lib/csrf' import MvpPanel from './MvpPanel.vue' @@ -15,7 +15,7 @@ import FloatingAddButton from './components/FloatingAddButton.vue' type FolderItem = { id: string; name: string } type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean } type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; version: number; due_at: string | null; subtasks?: Task[] } -type RepeatOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' +type RepeatOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom' type Recurrence = { id: string; task_id: string; rrule: string } type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'settings' @@ -63,6 +63,10 @@ const composeDescription = ref('') const composeRepeat = ref('none') const selectedTaskRepeat = ref('none') const selectedTaskRecurrence = ref(null) +const defaultRepeatConfig = (): TaskRepeatConfig => ({ frequency: 'daily', interval: 1, weekdays: [], monthDays: [], endMode: 'never', count: 10, until: '' }) +const composeRepeatConfig = ref(defaultRepeatConfig()) +const selectedRepeatConfig = ref(defaultRepeatConfig()) +const weekdayOptions = [{ value: 'MO', label: '一' }, { value: 'TU', label: '二' }, { value: 'WE', label: '三' }, { value: 'TH', label: '四' }, { value: 'FR', label: '五' }, { value: 'SA', label: '六' }, { value: 'SU', label: '日' }] let recurrenceLoadToken = 0 const habitComposer = ref | null>(null) const countdownComposer = ref | null>(null) @@ -79,6 +83,7 @@ function openTaskCompose() { composePriority.value = 0 composeDescription.value = '' composeRepeat.value = 'none' + composeRepeatConfig.value = defaultRepeatConfig() taskComposeOpen.value = true nextTick(() => document.querySelector('.task-compose-input')?.focus()) } @@ -90,14 +95,18 @@ function activateFloatingAdd(origin: { x: number; y: number }) { else if (activeView.value === 'countdowns') countdownComposer.value?.openCountdownComposer(origin) else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose() } -function repeatRrule(value: RepeatOption) { - return value === 'none' ? '' : `FREQ=${value.toUpperCase()}` +function repeatRrule(value: RepeatOption, config: TaskRepeatConfig) { + if (value === 'none') return '' + if (value === 'custom') return buildTaskRrule(config) + return `FREQ=${value.toUpperCase()}` } function repeatOption(rrule?: string): RepeatOption { - const value = /FREQ=(DAILY|WEEKLY|MONTHLY|YEARLY)/.exec(rrule ?? '')?.[1]?.toLowerCase() - return (value as RepeatOption | undefined) ?? 'none' + if (!rrule) return 'none' + const parsed = parseTaskRrule(rrule) + const simple = parsed.interval === 1 && !parsed.weekdays?.length && !parsed.monthDays?.length && parsed.endMode === 'never' + return simple ? parsed.frequency : 'custom' } -async function saveRepeat(task: Task, value: RepeatOption) { +async function saveRepeat(task: Task, value: RepeatOption, config = selectedRepeatConfig.value) { if (value !== 'none' && !task.due_at) throw new Error('请先设置截止时间') if (value === 'none') { if (selectedTaskRecurrence.value) await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'DELETE' }) @@ -105,7 +114,7 @@ async function saveRepeat(task: Task, value: RepeatOption) { selectedTaskRepeat.value = 'none' return } - const rrule = repeatRrule(value) + const rrule = repeatRrule(value, config) if (selectedTaskRecurrence.value) { await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'PATCH', body: JSON.stringify({ rrule }) }) selectedTaskRecurrence.value = { ...selectedTaskRecurrence.value, rrule } @@ -123,6 +132,7 @@ async function loadTaskRecurrence(task: Task) { if (token !== recurrenceLoadToken || selectedTask.value?.id !== task.id) return selectedTaskRecurrence.value = recurrence selectedTaskRepeat.value = repeatOption(recurrence?.rrule) + selectedRepeatConfig.value = recurrence ? parseTaskRrule(recurrence.rrule) : defaultRepeatConfig() } catch (reason) { if (token === recurrenceLoadToken) fail(reason) } } async function updateSelectedTaskRepeat() { @@ -142,7 +152,7 @@ async function submitTaskCompose() { priority: composePriority.value, description: composeDescription.value, }) }) - if (composeRepeat.value !== 'none') await api('/recurrences', { method: 'POST', body: JSON.stringify({ task_id: task.id, rrule: repeatRrule(composeRepeat.value) }) }) + if (composeRepeat.value !== 'none') await api('/recurrences', { method: 'POST', body: JSON.stringify({ task_id: task.id, rrule: repeatRrule(composeRepeat.value, composeRepeatConfig.value) }) }) if (isTaskView(activeView.value)) { tasks.value.push(task) totalTasks.value = nextTotalAfterLocalTaskAdd(totalTasks.value) @@ -703,7 +713,8 @@ onMounted(bootstrap)