174 lines
6.5 KiB
TypeScript
174 lines
6.5 KiB
TypeScript
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, '"')
|
|
.replace(/'/g, ''')
|
|
|
|
const renderInline = (value: string) => {
|
|
let out = escapeHtml(value)
|
|
out = out.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
out = out.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
|
out = out.replace(
|
|
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
|
|
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
|
|
)
|
|
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('</ul>')
|
|
inList = false
|
|
}
|
|
}
|
|
|
|
for (const raw of lines) {
|
|
const line = raw.trimEnd()
|
|
if (!line.trim()) {
|
|
closeList()
|
|
continue
|
|
}
|
|
if (line.startsWith('# ')) {
|
|
closeList()
|
|
html.push(`<h1>${renderInline(line.slice(2))}</h1>`)
|
|
} else if (line.startsWith('## ')) {
|
|
closeList()
|
|
html.push(`<h2>${renderInline(line.slice(3))}</h2>`)
|
|
} else if (/^[-*] /.test(line)) {
|
|
if (!inList) {
|
|
html.push('<ul>')
|
|
inList = true
|
|
}
|
|
html.push(`<li>${renderInline(line.slice(2))}</li>`)
|
|
} else {
|
|
closeList()
|
|
html.push(`<p>${renderInline(line)}</p>`)
|
|
}
|
|
}
|
|
closeList()
|
|
return html.join('')
|
|
}
|
|
|
|
export function filterTasks<T extends MinimalTask>(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<T extends MinimalTask>(tasks: T[]) {
|
|
const children = new Map<string, T[]>()
|
|
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<T extends { id: string; parent_id?: string | null }>(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()
|
|
}
|