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, '>') .replace(/"/g, '"') .replace(/'/g, ''') 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(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 classifyTaskForToday( task: Pick, 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, target: Pick, ) { 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(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(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() }