This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { filterTasks, groupTaskTree, moveItemWithinScope, renderMarkdown, toDateTimeLocal } from './task-utils'
|
||||
import { buildTaskRrule, filterTasks, groupTaskTree, moveItemWithinScope, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils'
|
||||
|
||||
type SearchTask = {
|
||||
id: string
|
||||
@@ -38,6 +38,15 @@ describe('task utilities', () => {
|
||||
expect(moveItemWithinScope(rows, 'a1', 'b', 'before')).toEqual(rows)
|
||||
})
|
||||
|
||||
it('builds and parses custom repeat rules like TickTick', () => {
|
||||
expect(buildTaskRrule({ frequency: 'weekly', interval: 2, weekdays: ['MO', 'WE', 'FR'], endMode: 'never' })).toBe('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR')
|
||||
expect(buildTaskRrule({ frequency: 'monthly', interval: 1, monthDays: [1, 15, 31], endMode: 'count', count: 10 })).toBe('FREQ=MONTHLY;BYMONTHDAY=1,15,31;COUNT=10')
|
||||
expect(buildTaskRrule({ frequency: 'yearly', interval: 1, weekdays: [], monthDays: [], endMode: 'date', count: 10, until: '2027-12-31' })).toBe('FREQ=YEARLY;UNTIL=2027-12-31T23:59:59')
|
||||
expect(parseTaskRrule('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;COUNT=8')).toEqual({ frequency: 'weekly', interval: 2, weekdays: ['MO', 'WE'], monthDays: [], endMode: 'count', count: 8, until: '' })
|
||||
expect(() => buildTaskRrule({ frequency: 'weekly', interval: 2, weekdays: [], monthDays: [], endMode: 'never', count: 10, until: '' })).toThrow('至少选择一个重复日期')
|
||||
expect(() => buildTaskRrule({ frequency: 'daily', interval: 0, weekdays: [], monthDays: [], endMode: 'never', count: 10, until: '' })).toThrow('重复间隔至少为 1')
|
||||
})
|
||||
|
||||
it('renders safe basic markdown and strips unsafe html', () => {
|
||||
const html = renderMarkdown('# Plan\n**bold** [link](https://example.com)\n<script>alert(1)</script>')
|
||||
expect(html).toContain('<h1>Plan</h1>')
|
||||
|
||||
@@ -104,6 +104,46 @@ export function moveItemWithinScope<T extends { id: string; parent_id?: string |
|
||||
return items.map((item) => (item.parent_id ?? null) === (source.parent_id ?? null) ? reorderedIterator.next().value! : item)
|
||||
}
|
||||
|
||||
export type TaskRepeatConfig = {
|
||||
frequency: 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||
interval: number
|
||||
weekdays?: string[]
|
||||
monthDays?: number[]
|
||||
endMode: 'never' | 'date' | 'count'
|
||||
count?: number
|
||||
until?: string
|
||||
}
|
||||
|
||||
export function buildTaskRrule(config: TaskRepeatConfig) {
|
||||
const interval = Math.floor(Number(config.interval))
|
||||
if (!Number.isFinite(interval) || interval < 1) throw new Error('重复间隔至少为 1')
|
||||
if (config.frequency === 'weekly' && !config.weekdays?.length) throw new Error('至少选择一个重复日期')
|
||||
if (config.frequency === 'monthly' && (!config.monthDays?.length || config.monthDays.some((day) => day < 1 || day > 31))) throw new Error('请输入 1 到 31 的每月日期')
|
||||
if (config.endMode === 'count' && (!config.count || config.count < 1)) throw new Error('重复次数至少为 1')
|
||||
if (config.endMode === 'date' && !config.until) throw new Error('请选择结束日期')
|
||||
const parts = [`FREQ=${config.frequency.toUpperCase()}`]
|
||||
if (interval > 1) parts.push(`INTERVAL=${interval}`)
|
||||
if (config.frequency === 'weekly' && config.weekdays?.length) parts.push(`BYDAY=${config.weekdays.join(',')}`)
|
||||
if (config.frequency === 'monthly' && config.monthDays?.length) parts.push(`BYMONTHDAY=${config.monthDays.join(',')}`)
|
||||
if (config.endMode === 'count') parts.push(`COUNT=${Math.max(1, Math.floor(config.count ?? 1))}`)
|
||||
if (config.endMode === 'date' && config.until) parts.push(`UNTIL=${config.until}T23:59:59`)
|
||||
return parts.join(';')
|
||||
}
|
||||
|
||||
export function parseTaskRrule(rrule = ''): TaskRepeatConfig {
|
||||
const parts = Object.fromEntries(rrule.split(';').filter(Boolean).map((part) => part.split('=', 2)))
|
||||
const frequency = (parts.FREQ?.toLowerCase() || 'daily') as TaskRepeatConfig['frequency']
|
||||
return {
|
||||
frequency,
|
||||
interval: Math.max(1, Number(parts.INTERVAL ?? 1)),
|
||||
weekdays: parts.BYDAY?.split(',').filter(Boolean) ?? [],
|
||||
monthDays: parts.BYMONTHDAY?.split(',').map(Number).filter(Number.isFinite) ?? [],
|
||||
endMode: parts.COUNT ? 'count' : parts.UNTIL ? 'date' : 'never',
|
||||
count: parts.COUNT ? Number(parts.COUNT) : 1,
|
||||
until: parts.UNTIL?.slice(0, 10) ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
export function toDateTimeLocal(value: string | null | undefined) {
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
|
||||
Reference in New Issue
Block a user