Files
dodo/frontend/src/lib/task-utils.ts
T
bboysoul 3155155f53
ci / gitleaks (push) Successful in 23s
ci / docker (push) Successful in 7m16s
[verified] feat: support monthly completion recurrence
2026-09-20 21:04:31 +08:00

241 lines
11 KiB
TypeScript

import MarkdownIt from 'markdown-it'
import taskLists from 'markdown-it-task-lists'
export type MinimalTask = {
id: string
title: string
description?: string
parent_id?: string | null
completed?: boolean
due_at?: string | null
list_name?: string
subtasks?: MinimalTask[]
}
const escapeHtml = (value: string) =>
value
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
export type MarkdownFormat = 'heading' | 'bold' | 'italic' | 'bullet' | 'ordered' | 'task' | 'link' | 'code' | 'codeblock' | 'quote'
export function applyMarkdownFormat(value: string, start: number, end: number, format: MarkdownFormat) {
const selected = value.slice(start, end)
const replace = (replacement: string, selectionStart: number, selectionEnd: number) => ({
value: value.slice(0, start) + replacement + value.slice(end),
start: start + selectionStart,
end: start + selectionEnd,
})
const wrap = (before: string, after = before, fallback = '文字') => {
const content = selected || fallback
return replace(`${before}${content}${after}`, before.length, before.length + content.length)
}
const prefixLines = (prefix: string, fallback = '列表项') => {
const content = selected || fallback
const replacement = content.split('\n').map((line) => `${prefix}${line}`).join('\n')
return replace(replacement, prefix.length, replacement.length)
}
if (format === 'heading') return prefixLines('## ', '标题')
if (format === 'bold') return wrap('**')
if (format === 'italic') return wrap('*')
if (format === 'bullet') return prefixLines('- ')
if (format === 'ordered') return prefixLines('1. ')
if (format === 'task') return prefixLines('- [ ] ')
if (format === 'link') return selected
? replace(`[${selected}](https://)`, 1, 1 + selected.length)
: replace('[链接文字](https://)', 1, 5)
if (format === 'code') return wrap('`', '`', '代码')
if (format === 'codeblock') return wrap('```\n', '\n```', '代码')
return prefixLines('> ', '引用')
}
const markdownRenderer = new MarkdownIt({
breaks: true,
html: false,
linkify: true,
typographer: false,
})
markdownRenderer.use(taskLists, { enabled: false, label: false })
const defaultLinkOpen = markdownRenderer.renderer.rules.link_open
markdownRenderer.renderer.rules.link_open = (tokens, index, options, env, self) => {
const token = tokens[index]
token.attrSet('target', '_blank')
token.attrSet('rel', 'noopener noreferrer')
return defaultLinkOpen ? defaultLinkOpen(tokens, index, options, env, self) : self.renderToken(tokens, index, options)
}
export function renderMarkdown(markdown = '') {
return markdownRenderer.render(markdown)
}
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 classifyTaskForToday(
task: Pick<MinimalTask, 'completed' | 'due_at'>,
start: Date,
end: Date,
): 'overdue' | 'today' | 'outside' {
if (task.completed || !task.due_at) return 'outside'
const due = Date.parse(task.due_at)
if (!Number.isFinite(due)) return 'outside'
if (due < start.valueOf()) return 'overdue'
return due < end.valueOf() ? 'today' : 'outside'
}
export function isSameTaskSortTier(
source: Pick<MinimalTask, 'completed' | 'due_at'>,
target: Pick<MinimalTask, 'completed' | 'due_at'>,
) {
if (Boolean(source.completed) !== Boolean(target.completed)) return false
if (!source.due_at && !target.due_at) return true
if (!source.due_at || !target.due_at) return false
const sourceTime = Date.parse(source.due_at)
const targetTime = Date.parse(target.due_at)
return Number.isFinite(sourceTime) && Number.isFinite(targetTime) && sourceTime === targetTime
}
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 function mergeReorderedSubset<T extends { id: string }>(items: T[], reorderedSubset: T[]) {
const subsetIds = new Set(reorderedSubset.map((item) => item.id))
const reorderedIterator = reorderedSubset[Symbol.iterator]()
return items.map((item) => subsetIds.has(item.id) ? 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 type TaskRepeatOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'after_completion' | 'custom'
export type AfterCompletionUnit = 'days' | 'months'
export type TaskRecurrenceRecord = { rrule?: string | null; trigger_mode?: 'scheduled' | 'after_completion'; after_completion_days?: number | null; after_completion_unit?: AfterCompletionUnit | null }
export function buildTaskRecurrencePayload(option: TaskRepeatOption, values: { afterCompletionDays: string | number; afterCompletionUnit?: AfterCompletionUnit; repeatConfig?: TaskRepeatConfig }) {
if (option === 'none') return {}
if (option === 'after_completion') {
const raw = String(values.afterCompletionDays).trim()
const days = Number(raw)
if (!/^\d+$/.test(raw) || !Number.isInteger(days) || days < 1 || days > 3650) throw new Error('请输入 1 到 3650 的整数')
return { trigger_mode: 'after_completion' as const, after_completion_days: days, after_completion_unit: values.afterCompletionUnit ?? 'days' }
}
const rrule = option === 'custom'
? buildTaskRrule(values.repeatConfig ?? { frequency: 'daily', interval: 1, endMode: 'never' })
: `FREQ=${option.toUpperCase()}`
return { trigger_mode: 'scheduled' as const, rrule }
}
export function parseTaskRecurrence(recurrence?: TaskRecurrenceRecord | null) {
if (!recurrence) return { option: 'none' as TaskRepeatOption, afterCompletionDays: 1, afterCompletionUnit: 'days' as AfterCompletionUnit }
if (recurrence.trigger_mode === 'after_completion') {
return { option: 'after_completion' as TaskRepeatOption, afterCompletionDays: recurrence.after_completion_days ?? 1, afterCompletionUnit: recurrence.after_completion_unit ?? 'days' as AfterCompletionUnit }
}
const parsed = parseTaskRrule(recurrence.rrule ?? '')
const simple = parsed.interval === 1 && !parsed.weekdays?.length && !parsed.monthDays?.length && parsed.endMode === 'never'
return { option: (simple ? parsed.frequency : 'custom') as TaskRepeatOption, afterCompletionDays: 1, afterCompletionUnit: 'days' as AfterCompletionUnit }
}
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 type TaskDueDraft = { date: string; hasTime: boolean; time: string }
export function parseTaskDueDraft(value: string | null | undefined, dueHasTime: boolean): TaskDueDraft {
const local = toDateTimeLocal(value)
if (!local) return { date: '', hasTime: false, time: '12:00' }
const [date, time = '12:00'] = local.split('T')
return { date, hasTime: dueHasTime, time: dueHasTime ? time : '12:00' }
}
export function buildTaskDueDraft(draft: TaskDueDraft) {
if (!draft.date) return { due_at: null, due_has_time: false }
const hasTime = draft.hasTime && Boolean(draft.time)
const local = `${draft.date}T${hasTime ? draft.time : '23:59'}`
return { due_at: fromDateTimeLocal(local), due_has_time: hasTime }
}
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()
}