241 lines
11 KiB
TypeScript
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, '<')
|
|
.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<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()
|
|
}
|