export type MinimalTask = {
id: string
title: string
description?: string
parent_id?: string | null
completed?: boolean
list_name?: string
subtasks?: MinimalTask[]
}
const escapeHtml = (value: string) =>
value
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
const renderInline = (value: string) => {
let out = escapeHtml(value)
out = out.replace(/`([^`]+)`/g, '$1')
out = out.replace(/\*\*([^*]+)\*\*/g, '$1')
out = out.replace(/\*([^*]+)\*/g, '$1')
out = out.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
'$1',
)
return out
}
export function renderMarkdown(markdown = '') {
const lines = markdown.replace(/\r\n/g, '\n').split('\n')
const html: string[] = []
let inList = false
const closeList = () => {
if (inList) {
html.push('')
inList = false
}
}
for (const raw of lines) {
const line = raw.trimEnd()
if (!line.trim()) {
closeList()
continue
}
if (line.startsWith('# ')) {
closeList()
html.push(`
${renderInline(line.slice(2))}
`)
} else if (line.startsWith('## ')) {
closeList()
html.push(`${renderInline(line.slice(3))}
`)
} else if (/^[-*] /.test(line)) {
if (!inList) {
html.push('')
inList = true
}
html.push(`- ${renderInline(line.slice(2))}
`)
} else {
closeList()
html.push(`${renderInline(line)}
`)
}
}
closeList()
return html.join('')
}
export function filterTasks(tasks: T[], query: string) {
const q = query.trim().toLowerCase()
if (!q) return tasks
return tasks.filter((task) => {
const haystack = [
task.title,
task.description ?? '',
task.list_name ?? '',
].join(' ')
return haystack.toLowerCase().includes(q)
})
}
export function groupTaskTree(tasks: T[]) {
const children = new Map()
for (const task of tasks) {
if (!task.parent_id) continue
children.set(task.parent_id, [...(children.get(task.parent_id) ?? []), task])
}
return tasks.filter((task) => !task.parent_id).map((task) => ({
task,
subtasks: children.get(task.id) ?? (task.subtasks as T[] | undefined) ?? [],
}))
}
export function moveItemWithinScope(items: T[], sourceId: string, targetId: string, placement: 'before' | 'after') {
if (sourceId === targetId) return items
const source = items.find((item) => item.id === sourceId)
const target = items.find((item) => item.id === targetId)
if (!source || !target || (source.parent_id ?? null) !== (target.parent_id ?? null)) return items
const scoped = items.filter((item) => (item.parent_id ?? null) === (source.parent_id ?? null))
const sourceIndex = scoped.findIndex((item) => item.id === sourceId)
const targetIndex = scoped.findIndex((item) => item.id === targetId)
const reordered = scoped.filter((item) => item.id !== sourceId)
const adjustedTargetIndex = reordered.findIndex((item) => item.id === targetId)
reordered.splice(adjustedTargetIndex + (placement === 'after' ? 1 : 0), 0, source)
if (sourceIndex === targetIndex || reordered.every((item, index) => item.id === scoped[index]?.id)) return items
const reorderedIterator = reordered[Symbol.iterator]()
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 defaultTaskDueAt(now = new Date()) {
const year = now.getFullYear()
const month = `${now.getMonth() + 1}`.padStart(2, '0')
const day = `${now.getDate()}`.padStart(2, '0')
return `${year}-${month}-${day}`
}
export function toDateTimeLocal(value: string | null | undefined) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return ''
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
const hour = `${date.getHours()}`.padStart(2, '0')
const minute = `${date.getMinutes()}`.padStart(2, '0')
return `${year}-${month}-${day}T${hour}:${minute}`
}
export function fromDateTimeLocal(value: string) {
if (!value) return null
return new Date(value).toISOString()
}