217 lines
6.9 KiB
TypeScript
217 lines
6.9 KiB
TypeScript
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)
|
|
}
|