Initial commit

This commit is contained in:
2026-03-08 01:34:54 +08:00
commit 1f104f73c8
441 changed files with 64911 additions and 0 deletions

View File

@@ -0,0 +1,171 @@
import { describe, it, expect } from 'vitest'
import { parseTodoContent, generateTodoContent } from '../parser.js'
import type { DayTodo } from '../types.js'
describe('parseTodoContent', () => {
it('should parse basic todo content correctly', () => {
const content = `## 2024-01-01
- √ 完成工作
- ○ 购物`
const result = parseTodoContent(content)
expect(result).toHaveLength(1)
expect(result[0].date).toBe('2024-01-01')
expect(result[0].items).toHaveLength(2)
expect(result[0].items[0].content).toBe('完成工作')
expect(result[0].items[1].content).toBe('购物')
})
it('should correctly identify completed status with √', () => {
const content = `## 2024-01-01
- √ 已完成任务`
const result = parseTodoContent(content)
expect(result[0].items[0].completed).toBe(true)
})
it('should correctly identify incomplete status with ○', () => {
const content = `## 2024-01-01
- ○ 未完成任务`
const result = parseTodoContent(content)
expect(result[0].items[0].completed).toBe(false)
})
it('should parse multiple days correctly', () => {
const content = `## 2024-01-01
- √ 第一天任务
## 2024-01-02
- ○ 第二天任务
## 2024-01-03
- √ 第三天任务`
const result = parseTodoContent(content)
expect(result).toHaveLength(3)
expect(result[0].date).toBe('2024-01-01')
expect(result[1].date).toBe('2024-01-02')
expect(result[2].date).toBe('2024-01-03')
expect(result[0].items[0].content).toBe('第一天任务')
expect(result[1].items[0].content).toBe('第二天任务')
expect(result[2].items[0].content).toBe('第三天任务')
})
it('should handle empty content', () => {
const content = ''
const result = parseTodoContent(content)
expect(result).toHaveLength(0)
})
it('should ignore invalid format lines', () => {
const content = `## 2024-01-01
这是一行普通文本
- 无效格式
- x 错误的标记
random line
- √ 有效的任务`
const result = parseTodoContent(content)
expect(result).toHaveLength(1)
expect(result[0].items).toHaveLength(1)
expect(result[0].items[0].content).toBe('有效的任务')
})
it('should generate unique IDs for items', () => {
const content = `## 2024-01-01
- √ 任务一
- ○ 任务二
- √ 任务三`
const result = parseTodoContent(content)
expect(result[0].items[0].id).toBe('2024-01-01-0')
expect(result[0].items[1].id).toBe('2024-01-01-1')
expect(result[0].items[2].id).toBe('2024-01-01-2')
})
})
describe('generateTodoContent', () => {
it('should generate basic todo content correctly', () => {
const dayTodos: DayTodo[] = [
{
date: '2024-01-01',
items: [
{ id: '2024-01-01-0', content: '完成工作', completed: true },
{ id: '2024-01-01-1', content: '购物', completed: false }
]
}
]
const result = generateTodoContent(dayTodos)
expect(result).toBe(`## 2024-01-01
- √ 完成工作
- ○ 购物`)
})
it('should include completed status in generated content', () => {
const dayTodos: DayTodo[] = [
{
date: '2024-01-01',
items: [
{ id: '2024-01-01-0', content: '已完成', completed: true },
{ id: '2024-01-01-1', content: '未完成', completed: false }
]
}
]
const result = generateTodoContent(dayTodos)
expect(result).toContain('√ 已完成')
expect(result).toContain('○ 未完成')
})
it('should sort dates in ascending order', () => {
const dayTodos: DayTodo[] = [
{ date: '2024-01-03', items: [{ id: '1', content: '第三天', completed: false }] },
{ date: '2024-01-01', items: [{ id: '2', content: '第一天', completed: false }] },
{ date: '2024-01-02', items: [{ id: '3', content: '第二天', completed: false }] }
]
const result = generateTodoContent(dayTodos)
const firstDateIndex = result.indexOf('2024-01-01')
const secondDateIndex = result.indexOf('2024-01-02')
const thirdDateIndex = result.indexOf('2024-01-03')
expect(firstDateIndex).toBeLessThan(secondDateIndex)
expect(secondDateIndex).toBeLessThan(thirdDateIndex)
})
it('should handle empty array', () => {
const dayTodos: DayTodo[] = []
const result = generateTodoContent(dayTodos)
expect(result).toBe('')
})
it('should generate content for multiple days with sorting', () => {
const dayTodos: DayTodo[] = [
{ date: '2024-01-02', items: [{ id: '1', content: '第二天', completed: true }] },
{ date: '2024-01-01', items: [{ id: '2', content: '第一天', completed: false }] }
]
const result = generateTodoContent(dayTodos)
expect(result).toBe(`## 2024-01-01
- ○ 第一天
## 2024-01-02
- √ 第二天`)
})
})

28
api/modules/todo/index.ts Normal file
View File

@@ -0,0 +1,28 @@
import type { Router } from 'express'
import type { ServiceContainer } from '../../infra/container.js'
import { createApiModule } from '../../infra/createModule.js'
import { TODO_MODULE } from '../../../shared/modules/todo/index.js'
import { TodoService } from './service.js'
import { createTodoRoutes } from './routes.js'
export * from './types.js'
export * from './parser.js'
export * from './service.js'
export * from './schemas.js'
export * from './routes.js'
export const createTodoModule = () => {
return createApiModule(TODO_MODULE, {
routes: (container: ServiceContainer): Router => {
const todoService = container.getSync<TodoService>('todoService')
return createTodoRoutes({ todoService })
},
lifecycle: {
onLoad: (container: ServiceContainer): void => {
container.register('todoService', () => new TodoService())
},
},
})
}
export default createTodoModule

View File

@@ -0,0 +1,51 @@
import type { TodoItem, DayTodo } from './types.js'
export const parseTodoContent = (content: string): DayTodo[] => {
const lines = content.split('\n')
const result: DayTodo[] = []
let currentDate: string | null = null
let currentItems: TodoItem[] = []
let itemId = 0
for (const line of lines) {
const dateMatch = line.match(/^##\s*(\d{4}-\d{2}-\d{2})/)
if (dateMatch) {
if (currentDate) {
result.push({ date: currentDate, items: currentItems })
}
currentDate = dateMatch[1]
currentItems = []
} else if (currentDate) {
const todoMatch = line.match(/^- (√|○) (.*)$/)
if (todoMatch) {
currentItems.push({
id: `${currentDate}-${itemId++}`,
content: todoMatch[2],
completed: todoMatch[1] === '√'
})
}
}
}
if (currentDate) {
result.push({ date: currentDate, items: currentItems })
}
return result
}
export const generateTodoContent = (dayTodos: DayTodo[]): string => {
const lines: string[] = []
const sortedDays = [...dayTodos].sort((a, b) => a.date.localeCompare(b.date))
for (const day of sortedDays) {
lines.push(`## ${day.date}`)
for (const item of day.items) {
const checkbox = item.completed ? '√' : '○'
lines.push(`- ${checkbox} ${item.content}`)
}
lines.push('')
}
return lines.join('\n').trimEnd()
}

View File

@@ -0,0 +1,99 @@
import express, { type Request, type Response, type Router } from 'express'
import { asyncHandler } from '../../utils/asyncHandler.js'
import { successResponse } from '../../utils/response.js'
import { validateBody, validateQuery } from '../../middlewares/validate.js'
import { TodoService } from './service.js'
import {
getTodoQuerySchema,
saveTodoSchema,
addTodoSchema,
toggleTodoSchema,
updateTodoSchema,
deleteTodoSchema,
} from './schemas.js'
export interface TodoRoutesDependencies {
todoService: TodoService
}
export const createTodoRoutes = (deps: TodoRoutesDependencies): Router => {
const router = express.Router()
const { todoService } = deps
router.get(
'/',
validateQuery(getTodoQuerySchema),
asyncHandler(async (req: Request, res: Response) => {
const year = parseInt(req.query.year as string) || new Date().getFullYear()
const month = parseInt(req.query.month as string) || new Date().getMonth() + 1
const result = await todoService.getTodo(year, month)
successResponse(res, result)
}),
)
router.post(
'/save',
validateBody(saveTodoSchema),
asyncHandler(async (req: Request, res: Response) => {
const { year, month, dayTodos } = req.body
await todoService.saveTodo(year, month, dayTodos)
successResponse(res, null)
}),
)
router.post(
'/add',
validateBody(addTodoSchema),
asyncHandler(async (req: Request, res: Response) => {
const { year, month, date, content: todoContent } = req.body
const dayTodos = await todoService.addTodo(year, month, date, todoContent)
successResponse(res, { dayTodos })
}),
)
router.post(
'/toggle',
validateBody(toggleTodoSchema),
asyncHandler(async (req: Request, res: Response) => {
const { year, month, date, itemIndex, completed } = req.body
const dayTodos = await todoService.toggleTodo(year, month, date, itemIndex, completed)
successResponse(res, { dayTodos })
}),
)
router.post(
'/update',
validateBody(updateTodoSchema),
asyncHandler(async (req: Request, res: Response) => {
const { year, month, date, itemIndex, content: newContent } = req.body
const dayTodos = await todoService.updateTodo(year, month, date, itemIndex, newContent)
successResponse(res, { dayTodos })
}),
)
router.delete(
'/delete',
validateBody(deleteTodoSchema),
asyncHandler(async (req: Request, res: Response) => {
const { year, month, date, itemIndex } = req.body
const dayTodos = await todoService.deleteTodo(year, month, date, itemIndex)
successResponse(res, { dayTodos })
}),
)
return router
}
const todoService = new TodoService()
export default createTodoRoutes({ todoService })

View File

@@ -0,0 +1,53 @@
import { z } from 'zod'
const todoItemSchema = z.object({
id: z.string(),
content: z.string(),
completed: z.boolean(),
})
const dayTodoSchema = z.object({
date: z.string(),
items: z.array(todoItemSchema),
})
export const getTodoQuerySchema = z.object({
year: z.string().optional(),
month: z.string().optional(),
})
export const saveTodoSchema = z.object({
year: z.number().int().positive(),
month: z.number().int().min(1).max(12),
dayTodos: z.array(dayTodoSchema),
})
export const addTodoSchema = z.object({
year: z.number().int().positive(),
month: z.number().int().min(1).max(12),
date: z.string(),
content: z.string(),
})
export const toggleTodoSchema = z.object({
year: z.number().int().positive(),
month: z.number().int().min(1).max(12),
date: z.string(),
itemIndex: z.number().int().nonnegative(),
completed: z.boolean(),
})
export const updateTodoSchema = z.object({
year: z.number().int().positive(),
month: z.number().int().min(1).max(12),
date: z.string(),
itemIndex: z.number().int().nonnegative(),
content: z.string(),
})
export const deleteTodoSchema = z.object({
year: z.number().int().positive(),
month: z.number().int().min(1).max(12),
date: z.string(),
itemIndex: z.number().int().nonnegative(),
})

216
api/modules/todo/service.ts Normal file
View File

@@ -0,0 +1,216 @@
import fs from 'fs/promises'
import path from 'path'
import { resolveNotebookPath } from '../../utils/pathSafety.js'
import { NotFoundError } from '../../../shared/errors/index.js'
import { parseTodoContent, generateTodoContent } from './parser.js'
import type { DayTodo, TodoFilePath, ParsedTodoFile, GetTodoResult } from './types.js'
export interface TodoServiceDependencies {
}
export class TodoService {
constructor(private deps: TodoServiceDependencies = {}) {}
getTodoFilePath(year: number, month: number): TodoFilePath {
const yearStr = year.toString()
const monthStr = month.toString().padStart(2, '0')
const relPath = `TODO/${yearStr}/${yearStr}${monthStr}TODO.md`
const { fullPath } = resolveNotebookPath(relPath)
return { relPath, fullPath }
}
async ensureTodoFileExists(fullPath: string): Promise<void> {
const dir = path.dirname(fullPath)
await fs.mkdir(dir, { recursive: true })
try {
await fs.access(fullPath)
} catch {
await fs.writeFile(fullPath, '', 'utf-8')
}
}
async loadAndParseTodoFile(year: number, month: number): Promise<ParsedTodoFile> {
const { fullPath } = this.getTodoFilePath(year, month)
try {
await fs.access(fullPath)
} catch {
throw new NotFoundError('TODO file not found')
}
const content = await fs.readFile(fullPath, 'utf-8')
return { fullPath, dayTodos: parseTodoContent(content) }
}
async saveTodoFile(fullPath: string, dayTodos: DayTodo[]): Promise<void> {
const content = generateTodoContent(dayTodos)
await fs.writeFile(fullPath, content, 'utf-8')
}
async getTodo(year: number, month: number): Promise<GetTodoResult> {
const { fullPath } = this.getTodoFilePath(year, month)
let dayTodos: DayTodo[] = []
try {
await fs.access(fullPath)
const content = await fs.readFile(fullPath, 'utf-8')
dayTodos = parseTodoContent(content)
} catch {
// 文件不存在
}
const now = new Date()
const todayStr = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`
const yesterday = new Date(now)
yesterday.setDate(yesterday.getDate() - 1)
const yesterdayStr = `${yesterday.getFullYear()}-${(yesterday.getMonth() + 1).toString().padStart(2, '0')}-${yesterday.getDate().toString().padStart(2, '0')}`
if (year === now.getFullYear() && month === now.getMonth() + 1) {
const migrated = this.migrateIncompleteItems(dayTodos, todayStr, yesterdayStr)
if (migrated) {
const newContent = generateTodoContent(dayTodos)
await this.ensureTodoFileExists(fullPath)
await fs.writeFile(fullPath, newContent, 'utf-8')
}
}
return { dayTodos, year, month }
}
private migrateIncompleteItems(dayTodos: DayTodo[], todayStr: string, yesterdayStr: string): boolean {
let migrated = false
const yesterdayTodo = dayTodos.find(d => d.date === yesterdayStr)
if (yesterdayTodo) {
const incompleteItems = yesterdayTodo.items.filter(item => !item.completed)
if (incompleteItems.length > 0) {
const todayTodo = dayTodos.find(d => d.date === todayStr)
if (todayTodo) {
const existingIds = new Set(todayTodo.items.map(i => i.id))
const itemsToAdd = incompleteItems.map((item, idx) => ({
...item,
id: existingIds.has(item.id) ? `${todayStr}-migrated-${idx}` : item.id
}))
todayTodo.items = [...itemsToAdd, ...todayTodo.items]
} else {
dayTodos.push({
date: todayStr,
items: incompleteItems.map((item, idx) => ({
...item,
id: `${todayStr}-migrated-${idx}`
}))
})
}
yesterdayTodo.items = yesterdayTodo.items.filter(item => item.completed)
if (yesterdayTodo.items.length === 0) {
const index = dayTodos.findIndex(d => d.date === yesterdayStr)
if (index !== -1) {
dayTodos.splice(index, 1)
}
}
migrated = true
}
}
return migrated
}
async saveTodo(year: number, month: number, dayTodos: DayTodo[]): Promise<void> {
const { fullPath } = this.getTodoFilePath(year, month)
await this.ensureTodoFileExists(fullPath)
const content = generateTodoContent(dayTodos)
await fs.writeFile(fullPath, content, 'utf-8')
}
async addTodo(year: number, month: number, date: string, todoContent: string): Promise<DayTodo[]> {
const { fullPath } = this.getTodoFilePath(year, month)
await this.ensureTodoFileExists(fullPath)
let fileContent = await fs.readFile(fullPath, 'utf-8')
const dayTodos = parseTodoContent(fileContent)
const existingDay = dayTodos.find(d => d.date === date)
if (existingDay) {
const newId = `${date}-${existingDay.items.length}`
existingDay.items.push({
id: newId,
content: todoContent,
completed: false
})
} else {
dayTodos.push({
date,
items: [{
id: `${date}-0`,
content: todoContent,
completed: false
}]
})
}
fileContent = generateTodoContent(dayTodos)
await fs.writeFile(fullPath, fileContent, 'utf-8')
return dayTodos
}
async toggleTodo(year: number, month: number, date: string, itemIndex: number, completed: boolean): Promise<DayTodo[]> {
const { fullPath, dayTodos } = await this.loadAndParseTodoFile(year, month)
const day = dayTodos.find(d => d.date === date)
if (!day || itemIndex >= day.items.length) {
throw new NotFoundError('TODO item not found')
}
day.items[itemIndex].completed = completed
await this.saveTodoFile(fullPath, dayTodos)
return dayTodos
}
async updateTodo(year: number, month: number, date: string, itemIndex: number, newContent: string): Promise<DayTodo[]> {
const { fullPath, dayTodos } = await this.loadAndParseTodoFile(year, month)
const day = dayTodos.find(d => d.date === date)
if (!day || itemIndex >= day.items.length) {
throw new NotFoundError('TODO item not found')
}
day.items[itemIndex].content = newContent
await this.saveTodoFile(fullPath, dayTodos)
return dayTodos
}
async deleteTodo(year: number, month: number, date: string, itemIndex: number): Promise<DayTodo[]> {
const { fullPath, dayTodos } = await this.loadAndParseTodoFile(year, month)
const dayIndex = dayTodos.findIndex(d => d.date === date)
if (dayIndex === -1) {
throw new NotFoundError('Day not found')
}
const day = dayTodos[dayIndex]
if (itemIndex >= day.items.length) {
throw new NotFoundError('TODO item not found')
}
day.items.splice(itemIndex, 1)
if (day.items.length === 0) {
dayTodos.splice(dayIndex, 1)
}
await this.saveTodoFile(fullPath, dayTodos)
return dayTodos
}
}
export const createTodoService = (deps?: TodoServiceDependencies): TodoService => {
return new TodoService(deps)
}

View File

@@ -0,0 +1,7 @@
export { type TodoItem, type DayTodo } from '../../../shared/types/todo.js'
export {
type TodoFilePath,
type ParsedTodoFile,
type GetTodoResult,
type MigrationContext,
} from '../../../shared/modules/todo/types.js'