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

0
api/modules/.gitkeep Normal file
View File

17
api/modules/ai/index.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { Router } from 'express'
import type { ServiceContainer } from '../../infra/container.js'
import { createApiModule } from '../../infra/createModule.js'
import { AI_MODULE } from '../../../shared/modules/ai/index.js'
import { createAiRoutes } from './routes.js'
export * from './routes.js'
export const createAiModule = () => {
return createApiModule(AI_MODULE, {
routes: (_container: ServiceContainer): Router => {
return createAiRoutes()
},
})
}
export default createAiModule

112
api/modules/ai/routes.ts Normal file
View File

@@ -0,0 +1,112 @@
import express, { type Request, type Response } from 'express'
import { spawn } from 'child_process'
import path from 'path'
import { fileURLToPath } from 'url'
import fs from 'fs/promises'
import fsSync from 'fs'
import { asyncHandler } from '../../utils/asyncHandler.js'
import { successResponse } from '../../utils/response.js'
import { resolveNotebookPath } from '../../utils/pathSafety.js'
import { ValidationError, NotFoundError, InternalError } from '../../../shared/errors/index.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const router = express.Router()
const PYTHON_TIMEOUT_MS = 30000
const spawnPythonWithTimeout = (
scriptPath: string,
args: string[],
stdinContent: string,
timeoutMs: number = PYTHON_TIMEOUT_MS
): Promise<string> => {
return new Promise((resolve, reject) => {
const pythonProcess = spawn('python', args, {
env: { ...process.env },
})
let stdout = ''
let stderr = ''
let timeoutId: NodeJS.Timeout | null = null
const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
}
}
timeoutId = setTimeout(() => {
cleanup()
pythonProcess.kill()
reject(new Error(`Python script timed out after ${timeoutMs}ms`))
}, timeoutMs)
pythonProcess.stdout.on('data', (data) => {
stdout += data.toString()
})
pythonProcess.stderr.on('data', (data) => {
stderr += data.toString()
})
pythonProcess.on('close', (code) => {
cleanup()
if (code !== 0) {
reject(new Error(`Python script exited with code ${code}. Stderr: ${stderr}`))
} else {
resolve(stdout)
}
})
pythonProcess.on('error', (err) => {
cleanup()
reject(new Error(`Failed to start python process: ${err.message}`))
})
pythonProcess.stdin.write(stdinContent)
pythonProcess.stdin.end()
})
}
router.post(
'/doubao',
asyncHandler(async (req: Request, res: Response) => {
const { task, path: relPath } = req.body as { task?: string; path?: string }
if (!task) throw new ValidationError('Task is required')
if (!relPath) throw new ValidationError('Path is required')
const { fullPath } = resolveNotebookPath(relPath)
try {
await fs.access(fullPath)
} catch {
throw new NotFoundError('File not found')
}
const content = await fs.readFile(fullPath, 'utf-8')
const projectRoot = path.resolve(__dirname, '..', '..', '..')
const scriptPath = path.join(projectRoot, 'tools', 'doubao', 'main.py')
if (!fsSync.existsSync(scriptPath)) {
throw new InternalError(`Python script not found: ${scriptPath}`)
}
try {
const result = await spawnPythonWithTimeout(scriptPath, ['--task', task], content)
await fs.writeFile(fullPath, result, 'utf-8')
successResponse(res, { message: 'Task completed successfully' })
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
throw new InternalError(`AI task failed: ${message}`)
}
})
)
export const createAiRoutes = (): express.Router => router
export default createAiRoutes

View File

@@ -0,0 +1,217 @@
import express, { type Request, type Response } from 'express'
import path from 'path'
import fs from 'fs/promises'
import { existsSync } from 'fs'
import axios from 'axios'
import { asyncHandler } from '../../utils/asyncHandler.js'
import { successResponse } from '../../utils/response.js'
import { resolveNotebookPath } from '../../utils/pathSafety.js'
import { getUniqueFilename } from '../../utils/file.js'
import { formatTimestamp } from '../../../shared/utils/date.js'
import { getTempDir } from '../../utils/tempDir.js'
import {
createJobContext,
spawnPythonScript,
findImageDestinations,
applyReplacements,
copyLocalImage,
cleanupJob,
getScriptPath,
ensureScriptExists,
} from './documentParser.js'
import type { ImageReplacement } from './documentParser.js'
import { ValidationError, InternalError } from '../../../shared/errors/index.js'
import { logger } from '../../utils/logger.js'
const router = express.Router()
const tempDir = getTempDir()
router.post(
'/parse-local',
asyncHandler(async (req: Request, res: Response) => {
const { htmlPath, htmlDir, assetsDirName, assetsFiles, targetPath } = req.body as {
htmlPath?: string
htmlDir?: string
assetsDirName?: string
assetsFiles?: string[]
targetPath?: string
}
if (!htmlPath || !htmlDir || !targetPath) {
throw new ValidationError('htmlPath, htmlDir and targetPath are required')
}
let fullTargetPath: string
try {
const resolved = resolveNotebookPath(targetPath)
fullTargetPath = resolved.fullPath
} catch (error) {
throw error
}
const scriptPath = getScriptPath('blog', 'parse_blog.py')
if (!ensureScriptExists(scriptPath)) {
throw new InternalError('Parser script not found')
}
const jobContext = await createJobContext('blog')
let htmlPathInJob = ''
try {
htmlPathInJob = path.join(jobContext.jobDir, 'input.html')
await fs.copyFile(htmlPath, htmlPathInJob)
if (assetsDirName && assetsFiles && assetsFiles.length > 0) {
const assetsDirPath = path.join(htmlDir, assetsDirName)
for (const relPath of assetsFiles) {
const srcPath = path.join(assetsDirPath, relPath)
if (existsSync(srcPath)) {
const destPath = path.join(jobContext.jobDir, assetsDirName, relPath)
await fs.mkdir(path.dirname(destPath), { recursive: true })
await fs.copyFile(srcPath, destPath)
}
}
}
} catch (err) {
await cleanupJob(jobContext.jobDir)
throw err
}
processHtmlInBackground({
jobDir: jobContext.jobDir,
htmlPath: htmlPathInJob,
targetPath: fullTargetPath,
cwd: path.dirname(scriptPath),
jobContext,
originalHtmlDir: htmlDir,
originalAssetsDirName: assetsDirName,
}).catch(err => {
logger.error('Background HTML processing failed:', err)
fs.writeFile(fullTargetPath, `# 解析失败\n\n> 错误信息: ${err.message}`, 'utf-8').catch(() => { })
cleanupJob(jobContext.jobDir).catch(() => { })
})
successResponse(res, {
message: 'HTML parsing started in background.',
status: 'processing'
})
}),
)
interface ProcessHtmlArgs {
jobDir: string
htmlPath: string
targetPath: string
cwd: string
jobContext: ReturnType<typeof createJobContext> extends Promise<infer T> ? T : never
originalHtmlDir?: string
originalAssetsDirName?: string
}
async function processHtmlInBackground(args: ProcessHtmlArgs) {
const { jobDir, htmlPath, targetPath, cwd, jobContext, originalHtmlDir, originalAssetsDirName } = args
try {
await spawnPythonScript({
scriptPath: 'parse_blog.py',
args: [htmlPath],
cwd,
})
const parsedPathObj = path.parse(htmlPath)
const markdownPath = path.join(parsedPathObj.dir, `${parsedPathObj.name}.md`)
if (!existsSync(markdownPath)) {
throw new Error('Markdown result file not found')
}
let mdContent = await fs.readFile(markdownPath, 'utf-8')
const ctx = await jobContext
const htmlDir = path.dirname(htmlPath)
const replacements: ImageReplacement[] = []
const destinations = findImageDestinations(mdContent)
for (const dest of destinations) {
const originalSrc = dest.url
if (!originalSrc) continue
if (originalSrc.startsWith('http://') || originalSrc.startsWith('https://')) {
try {
const response = await axios.get(originalSrc, { responseType: 'arraybuffer', timeout: 10000 })
const contentType = response.headers['content-type']
let ext = '.jpg'
if (contentType) {
if (contentType.includes('png')) ext = '.png'
else if (contentType.includes('gif')) ext = '.gif'
else if (contentType.includes('webp')) ext = '.webp'
else if (contentType.includes('svg')) ext = '.svg'
else if (contentType.includes('jpeg') || contentType.includes('jpg')) ext = '.jpg'
}
const urlExt = path.extname(originalSrc.split('?')[0])
if (urlExt) ext = urlExt
const baseName = formatTimestamp(ctx.now)
const newFilename = await getUniqueFilename(ctx.destImagesDir, baseName, ext)
const newPath = path.join(ctx.destImagesDir, newFilename)
await fs.writeFile(newPath, response.data)
replacements.push({
start: dest.start,
end: dest.end,
original: originalSrc,
replacement: `/${ctx.imagesSubDir}/${newFilename}`
})
} catch { }
continue
}
if (originalSrc.startsWith('data:')) continue
let result = await copyLocalImage(
originalSrc,
jobDir,
htmlDir,
ctx.destImagesDir,
ctx.imagesSubDir,
ctx.now
)
if (!result && originalHtmlDir && originalAssetsDirName) {
const srcWithFiles = originalSrc.replace(/^\.\//, '').replace(/^\//, '')
const possiblePaths = [
path.join(originalHtmlDir, originalAssetsDirName, srcWithFiles),
path.join(originalHtmlDir, originalAssetsDirName, path.basename(srcWithFiles)),
]
for (const p of possiblePaths) {
if (existsSync(p)) {
const ext = path.extname(p) || '.jpg'
const baseName = formatTimestamp(ctx.now)
const newFilename = await getUniqueFilename(ctx.destImagesDir, baseName, ext)
const newPath = path.join(ctx.destImagesDir, newFilename)
await fs.copyFile(p, newPath)
result = { newLink: `/${ctx.imagesSubDir}/${newFilename}` }
break
}
}
}
if (result) {
replacements.push({
start: dest.start,
end: dest.end,
original: originalSrc,
replacement: result.newLink
})
}
}
mdContent = applyReplacements(mdContent, replacements)
await fs.writeFile(targetPath, mdContent, 'utf-8')
await fs.unlink(markdownPath).catch(() => { })
} finally {
await cleanupJob(jobDir)
}
}
export default router

View File

@@ -0,0 +1,184 @@
import path from 'path'
import { spawn } from 'child_process'
import fs from 'fs/promises'
import { existsSync, mkdirSync } from 'fs'
import { PROJECT_ROOT, NOTEBOOK_ROOT, TEMP_ROOT } from '../../config/paths.js'
import { getUniqueFilename } from '../../utils/file.js'
import { formatTimestamp, pad2 } from '../../../shared/utils/date.js'
import { logger } from '../../utils/logger.js'
if (!existsSync(TEMP_ROOT)) {
mkdirSync(TEMP_ROOT, { recursive: true })
}
export interface JobContext {
jobDir: string
now: Date
imagesSubDir: string
destImagesDir: string
}
export const createJobContext = async (prefix: string): Promise<JobContext> => {
const now = new Date()
const jobDir = path.join(TEMP_ROOT, `${prefix}_${formatTimestamp(now)}`)
await fs.mkdir(jobDir, { recursive: true })
const year = now.getFullYear()
const month = pad2(now.getMonth() + 1)
const day = pad2(now.getDate())
const imagesSubDir = `images/${year}/${month}/${day}`
const destImagesDir = path.join(NOTEBOOK_ROOT, imagesSubDir)
await fs.mkdir(destImagesDir, { recursive: true })
return { jobDir, now, imagesSubDir, destImagesDir }
}
export interface SpawnPythonOptions {
scriptPath: string
args: string[]
cwd: string
inputContent?: string
}
export const spawnPythonScript = async (options: SpawnPythonOptions): Promise<string> => {
const { scriptPath, args, cwd, inputContent } = options
return new Promise<string>((resolve, reject) => {
const pythonProcess = spawn('python', ['-X', 'utf8', scriptPath, ...args], {
cwd,
env: { ...process.env, PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' },
})
let stdout = ''
let stderr = ''
pythonProcess.stdout.on('data', (data) => {
stdout += data.toString()
})
pythonProcess.stderr.on('data', (data) => {
stderr += data.toString()
})
pythonProcess.on('close', (code) => {
if (code !== 0) {
logger.error('Python script error:', stderr)
reject(new Error(`Process exited with code ${code}. Error: ${stderr}`))
} else {
resolve(stdout)
}
})
pythonProcess.on('error', (err) => {
reject(err)
})
if (inputContent !== undefined) {
pythonProcess.stdin.write(inputContent)
pythonProcess.stdin.end()
}
})
}
export interface ImageReplacement {
start: number
end: number
original: string
replacement: string
}
export const findImageDestinations = (md: string): Array<{ url: string; start: number; end: number }> => {
const results: Array<{ url: string; start: number; end: number }> = []
let i = 0
while (i < md.length) {
const bang = md.indexOf('![', i)
if (bang === -1) break
const closeBracket = md.indexOf(']', bang + 2)
if (closeBracket === -1) break
if (md[closeBracket + 1] !== '(') {
i = closeBracket + 1
continue
}
const urlStart = closeBracket + 2
let depth = 1
let j = urlStart
for (; j < md.length; j++) {
const ch = md[j]
if (ch === '(') depth++
else if (ch === ')') {
depth--
if (depth === 0) break
}
}
if (depth !== 0) break
results.push({ url: md.slice(urlStart, j), start: urlStart, end: j })
i = j + 1
}
return results
}
export const applyReplacements = (md: string, replacements: ImageReplacement[]): string => {
const sorted = [...replacements].sort((a, b) => b.start - a.start)
let result = md
for (const r of sorted) {
result = `${result.slice(0, r.start)}${r.replacement}${result.slice(r.end)}`
}
return result
}
export const copyLocalImage = async (
src: string,
jobDir: string,
htmlDir: string,
destImagesDir: string,
imagesSubDir: string,
now: Date
): Promise<{ newLink: string } | null> => {
const s0 = src.trim().replace(/^<|>$/g, '')
if (!s0) return null
let decoded = s0
try {
decoded = decodeURI(s0)
} catch {}
const s1 = decoded.replace(/\\/g, '/')
const s2 = s1.startsWith('./') ? s1.slice(2) : s1
const candidates = s2.startsWith('/')
? [path.join(jobDir, s2.slice(1)), path.join(htmlDir, s2.slice(1))]
: [path.resolve(htmlDir, s2), path.resolve(jobDir, s2)]
let foundFile: string | null = null
for (const c of candidates) {
if (existsSync(c)) {
foundFile = c
break
}
}
if (!foundFile) return null
const ext = path.extname(foundFile) || '.jpg'
const baseName = formatTimestamp(now)
const newFilename = await getUniqueFilename(destImagesDir, baseName, ext)
const newPath = path.join(destImagesDir, newFilename)
await fs.copyFile(foundFile, newPath)
return { newLink: `/${imagesSubDir}/${newFilename}` }
}
export const cleanupJob = async (jobDir: string, additionalPaths: string[] = []): Promise<void> => {
await fs.rm(jobDir, { recursive: true, force: true }).catch(() => {})
for (const p of additionalPaths) {
await fs.unlink(p).catch(() => {})
}
}
export const getScriptPath = (toolName: string, scriptName: string): string => {
return path.join(PROJECT_ROOT, 'tools', toolName, scriptName)
}
export const ensureScriptExists = (scriptPath: string): boolean => {
return existsSync(scriptPath)
}

View File

@@ -0,0 +1,23 @@
import express, { type Router } from 'express'
import type { ServiceContainer } from '../../infra/container.js'
import { createApiModule } from '../../infra/createModule.js'
import { DOCUMENT_PARSER_MODULE } from '../../../shared/modules/document-parser/index.js'
import blogRoutes from './blogRoutes.js'
import mineruRoutes from './mineruRoutes.js'
export * from './documentParser.js'
export { default as blogRoutes } from './blogRoutes.js'
export { default as mineruRoutes } from './mineruRoutes.js'
export const createDocumentParserModule = () => {
return createApiModule(DOCUMENT_PARSER_MODULE, {
routes: (_container: ServiceContainer): Router => {
const router = express.Router()
router.use('/blog', blogRoutes)
router.use('/mineru', mineruRoutes)
return router
},
})
}
export default createDocumentParserModule

View File

@@ -0,0 +1,158 @@
import express, { type Request, type Response } from 'express'
import multer from 'multer'
import path from 'path'
import fs from 'fs/promises'
import { existsSync } from 'fs'
import { asyncHandler } from '../../utils/asyncHandler.js'
import { successResponse } from '../../utils/response.js'
import { resolveNotebookPath } from '../../utils/pathSafety.js'
import { getUniqueFilename } from '../../utils/file.js'
import { formatTimestamp } from '../../../shared/utils/date.js'
import { getTempDir } from '../../utils/tempDir.js'
import {
createJobContext,
spawnPythonScript,
findImageDestinations,
applyReplacements,
cleanupJob,
getScriptPath,
ensureScriptExists,
} from './documentParser.js'
import type { ImageReplacement } from './documentParser.js'
import { ValidationError, InternalError } from '../../../shared/errors/index.js'
import { logger } from '../../utils/logger.js'
const router = express.Router()
const tempDir = getTempDir()
const upload = multer({
dest: tempDir,
limits: {
fileSize: 50 * 1024 * 1024
}
})
router.post(
'/parse',
upload.single('file'),
asyncHandler(async (req: Request, res: Response) => {
if (!req.file) {
throw new ValidationError('File is required')
}
const { targetPath } = req.body as { targetPath?: string }
if (!targetPath) {
await fs.unlink(req.file.path).catch(() => {})
throw new ValidationError('Target path is required')
}
let fullTargetPath: string
try {
const resolved = resolveNotebookPath(targetPath)
fullTargetPath = resolved.fullPath
} catch (error) {
await fs.unlink(req.file.path).catch(() => {})
throw error
}
const scriptPath = getScriptPath('mineru', 'mineru_parser.py')
if (!ensureScriptExists(scriptPath)) {
await fs.unlink(req.file.path).catch(() => {})
throw new InternalError('Parser script not found')
}
processPdfInBackground(req.file.path, fullTargetPath, path.dirname(scriptPath))
.catch(err => {
logger.error('Background PDF processing failed:', err)
fs.writeFile(fullTargetPath, `# 解析失败\n\n> 错误信息: ${err.message}`, 'utf-8').catch(() => {})
})
successResponse(res, {
message: 'PDF upload successful. Parsing started in background.',
status: 'processing'
})
}),
)
async function processPdfInBackground(filePath: string, targetPath: string, cwd: string) {
try {
const output = await spawnPythonScript({
scriptPath: 'mineru_parser.py',
args: [filePath],
cwd,
})
const match = output.match(/JSON_RESULT:(.*)/)
if (!match) {
throw new Error('Failed to parse Python script output: JSON_RESULT not found')
}
const result = JSON.parse(match[1])
const markdownPath = result.markdown_file
const outputDir = result.output_dir
if (!existsSync(markdownPath)) {
throw new Error('Markdown result file not found')
}
let mdContent = await fs.readFile(markdownPath, 'utf-8')
const imagesDir = path.join(outputDir, 'images')
if (existsSync(imagesDir)) {
const jobContext = await createJobContext('pdf_images')
const destinations = findImageDestinations(mdContent)
const replacements: ImageReplacement[] = []
for (const dest of destinations) {
const originalSrc = dest.url
if (!originalSrc) continue
const possibleFilenames = [originalSrc, path.basename(originalSrc)]
let foundFile: string | null = null
for (const fname of possibleFilenames) {
const localPath = path.join(imagesDir, fname)
if (existsSync(localPath)) {
foundFile = localPath
break
}
const directPath = path.join(outputDir, originalSrc)
if (existsSync(directPath)) {
foundFile = directPath
break
}
}
if (foundFile) {
const ext = path.extname(foundFile)
const baseName = formatTimestamp(jobContext.now)
const newFilename = await getUniqueFilename(jobContext.destImagesDir, baseName, ext)
const newPath = path.join(jobContext.destImagesDir, newFilename)
await fs.copyFile(foundFile, newPath)
replacements.push({
start: dest.start,
end: dest.end,
original: originalSrc,
replacement: `${jobContext.imagesSubDir}/${newFilename}`
})
}
}
mdContent = applyReplacements(mdContent, replacements)
}
await fs.writeFile(targetPath, mdContent, 'utf-8')
await fs.unlink(markdownPath).catch(() => {})
if (outputDir && outputDir.includes('temp')) {
await fs.rm(outputDir, { recursive: true, force: true }).catch(() => {})
}
} finally {
await fs.unlink(filePath).catch(() => {})
}
}
export default router

68
api/modules/index.ts Normal file
View File

@@ -0,0 +1,68 @@
import { readdirSync, statSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
import type { ApiModule } from '../infra/types.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const moduleFactoryPattern = /^create\w+Module$/
async function discoverModules(): Promise<ApiModule[]> {
const modules: ApiModule[] = []
const entries = readdirSync(__dirname)
for (const entry of entries) {
const entryPath = join(__dirname, entry)
try {
const stats = statSync(entryPath)
if (!stats.isDirectory()) {
continue
}
const moduleIndexPath = join(entryPath, 'index.ts')
let moduleIndexStats: ReturnType<typeof statSync>
try {
moduleIndexStats = statSync(moduleIndexPath)
} catch {
continue
}
if (!moduleIndexStats.isFile()) {
continue
}
const moduleExports = await import(`./${entry}/index.js`)
for (const exportName of Object.keys(moduleExports)) {
if (moduleFactoryPattern.test(exportName)) {
const factory = moduleExports[exportName]
if (typeof factory === 'function') {
const module = factory() as ApiModule
modules.push(module)
}
}
}
} catch (error) {
console.warn(`[ModuleLoader] Failed to load module '${entry}':`, error)
}
}
modules.sort((a, b) => {
const orderA = a.metadata.order ?? 0
const orderB = b.metadata.order ?? 0
return orderA - orderB
})
return modules
}
export const apiModules: ApiModule[] = await discoverModules()
export * from './todo/index.js'
export * from './time-tracking/index.js'
export * from './recycle-bin/index.js'
export * from './pydemos/index.js'
export * from './document-parser/index.js'
export * from './ai/index.js'
export * from './remote/index.js'

View File

@@ -0,0 +1,17 @@
import type { Router } from 'express'
import type { ServiceContainer } from '../../infra/container.js'
import { createApiModule } from '../../infra/createModule.js'
import { PYDEMOS_MODULE } from '../../../shared/modules/pydemos/index.js'
import { createPyDemosRoutes } from './routes.js'
export * from './routes.js'
export const createPyDemosModule = () => {
return createApiModule(PYDEMOS_MODULE, {
routes: (_container: ServiceContainer): Router => {
return createPyDemosRoutes()
},
})
}
export default createPyDemosModule

View File

@@ -0,0 +1,258 @@
import express, { type Request, type Response, type Router } from 'express'
import fs from 'fs/promises'
import path from 'path'
import multer from 'multer'
import { asyncHandler } from '../../utils/asyncHandler.js'
import { successResponse } from '../../utils/response.js'
import { resolveNotebookPath } from '../../utils/pathSafety.js'
import { getTempDir } from '../../utils/tempDir.js'
import { validateBody, validateQuery } from '../../middlewares/validate.js'
import {
listPyDemosQuerySchema,
createPyDemoSchema,
deletePyDemoSchema,
renamePyDemoSchema,
} from '../../schemas/index.js'
import { NotFoundError, AlreadyExistsError, isNodeError, ValidationError } from '../../../shared/errors/index.js'
import type { PyDemoItem, PyDemoMonth } from '../../../shared/types/pydemos.js'
const tempDir = getTempDir()
const upload = multer({
dest: tempDir,
limits: {
fileSize: 50 * 1024 * 1024
}
})
const toPosixPath = (p: string) => p.replace(/\\/g, '/')
const getYearPath = (year: number): { relPath: string; fullPath: string } => {
const relPath = `pydemos/${year}`
const { fullPath } = resolveNotebookPath(relPath)
return { relPath, fullPath }
}
const getMonthPath = (year: number, month: number): { relPath: string; fullPath: string } => {
const monthStr = month.toString().padStart(2, '0')
const relPath = `pydemos/${year}/${monthStr}`
const { fullPath } = resolveNotebookPath(relPath)
return { relPath, fullPath }
}
const countFilesInDir = async (dirPath: string): Promise<number> => {
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true })
return entries.filter(e => e.isFile()).length
} catch {
return 0
}
}
export const createPyDemosRoutes = (): Router => {
const router = express.Router()
router.get(
'/',
validateQuery(listPyDemosQuerySchema),
asyncHandler(async (req: Request, res: Response) => {
const year = parseInt(req.query.year as string) || new Date().getFullYear()
const { fullPath: yearPath } = getYearPath(year)
const months: PyDemoMonth[] = []
try {
await fs.access(yearPath)
} catch {
successResponse(res, { months })
return
}
const monthEntries = await fs.readdir(yearPath, { withFileTypes: true })
for (const monthEntry of monthEntries) {
if (!monthEntry.isDirectory()) continue
const monthNum = parseInt(monthEntry.name)
if (isNaN(monthNum) || monthNum < 1 || monthNum > 12) continue
const monthPath = path.join(yearPath, monthEntry.name)
const demoEntries = await fs.readdir(monthPath, { withFileTypes: true })
const demos: PyDemoItem[] = []
for (const demoEntry of demoEntries) {
if (!demoEntry.isDirectory()) continue
const demoPath = path.join(monthPath, demoEntry.name)
const relDemoPath = `pydemos/${year}/${monthEntry.name}/${demoEntry.name}`
let created: string
try {
const stats = await fs.stat(demoPath)
created = stats.birthtime.toISOString()
} catch {
created = new Date().toISOString()
}
const fileCount = await countFilesInDir(demoPath)
demos.push({
name: demoEntry.name,
path: toPosixPath(relDemoPath),
created,
fileCount
})
}
demos.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
if (demos.length > 0) {
months.push({
month: monthNum,
demos
})
}
}
months.sort((a, b) => a.month - b.month)
successResponse(res, { months })
}),
)
router.post(
'/create',
upload.array('files'),
validateBody(createPyDemoSchema),
asyncHandler(async (req: Request, res: Response) => {
const { name, year, month, folderStructure } = req.body
const yearNum = parseInt(year)
const monthNum = parseInt(month)
if (!/^[a-zA-Z0-9_\-\u4e00-\u9fa5]+$/.test(name)) {
throw new ValidationError('Invalid name format')
}
const { fullPath: monthPath, relPath: monthRelPath } = getMonthPath(yearNum, monthNum)
const demoPath = path.join(monthPath, name)
const relDemoPath = `${monthRelPath}/${name}`
try {
await fs.access(demoPath)
throw new AlreadyExistsError('Demo already exists')
} catch (err: unknown) {
if (isNodeError(err) && err.code === 'ENOENT') {
// 目录不存在,可以创建
} else if (err instanceof AlreadyExistsError) {
throw err
} else {
throw err
}
}
await fs.mkdir(demoPath, { recursive: true })
const files = req.files as Express.Multer.File[] | undefined
let fileCount = 0
if (files && files.length > 0) {
let structure: Record<string, string> = {}
if (folderStructure) {
try {
structure = JSON.parse(folderStructure)
} catch {
structure = {}
}
}
for (const file of files) {
const relativePath = structure[file.originalname] || file.originalname
const targetPath = path.join(demoPath, relativePath)
const targetDir = path.dirname(targetPath)
await fs.mkdir(targetDir, { recursive: true })
await fs.copyFile(file.path, targetPath)
await fs.unlink(file.path).catch(() => { })
fileCount++
}
}
successResponse(res, { path: toPosixPath(relDemoPath), fileCount })
}),
)
router.delete(
'/delete',
validateBody(deletePyDemoSchema),
asyncHandler(async (req: Request, res: Response) => {
const { path: demoPath } = req.body
if (!demoPath.startsWith('pydemos/')) {
throw new ValidationError('Invalid path')
}
const { fullPath } = resolveNotebookPath(demoPath)
try {
await fs.access(fullPath)
} catch {
throw new NotFoundError('Demo not found')
}
await fs.rm(fullPath, { recursive: true, force: true })
successResponse(res, null)
}),
)
router.post(
'/rename',
validateBody(renamePyDemoSchema),
asyncHandler(async (req: Request, res: Response) => {
const { oldPath, newName } = req.body
if (!oldPath.startsWith('pydemos/')) {
throw new ValidationError('Invalid path')
}
if (!/^[a-zA-Z0-9_\-\u4e00-\u9fa5]+$/.test(newName)) {
throw new ValidationError('Invalid name format')
}
const { fullPath: oldFullPath } = resolveNotebookPath(oldPath)
try {
await fs.access(oldFullPath)
} catch {
throw new NotFoundError('Demo not found')
}
const parentDir = path.dirname(oldFullPath)
const newFullPath = path.join(parentDir, newName)
const newPath = toPosixPath(path.join(path.dirname(oldPath), newName))
try {
await fs.access(newFullPath)
throw new AlreadyExistsError('Demo with this name already exists')
} catch (err: unknown) {
if (isNodeError(err) && err.code === 'ENOENT') {
// 目录不存在,可以重命名
} else if (err instanceof AlreadyExistsError) {
throw err
} else {
throw err
}
}
await fs.rename(oldFullPath, newFullPath)
successResponse(res, { newPath })
}),
)
return router
}
export default createPyDemosRoutes()

View File

@@ -0,0 +1,17 @@
import type { Router } from 'express'
import type { ServiceContainer } from '../../infra/container.js'
import { createApiModule } from '../../infra/createModule.js'
import { RECYCLE_BIN_MODULE } from '../../../shared/modules/recycle-bin/index.js'
import router from './routes.js'
export * from './recycleBinService.js'
export const createRecycleBinModule = () => {
return createApiModule(RECYCLE_BIN_MODULE, {
routes: (_container: ServiceContainer): Router => {
return router
},
})
}
export default createRecycleBinModule

View File

@@ -0,0 +1,78 @@
import fs from 'fs/promises'
import path from 'path'
import { resolveNotebookPath } from '../../utils/pathSafety.js'
export async function restoreFile(
srcPath: string,
destPath: string,
deletedDate: string,
year: string,
month: string,
day: string
) {
const { fullPath: imagesDir } = resolveNotebookPath(`images/${year}/${month}/${day}`)
let content = await fs.readFile(srcPath, 'utf-8')
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g
let match
const imageReplacements: { oldPath: string; newPath: string }[] = []
while ((match = imageRegex.exec(content)) !== null) {
const imagePath = match[2]
const imageName = path.basename(imagePath)
const rbImageName = `${deletedDate}_${imageName}`
const { fullPath: srcImagePath } = resolveNotebookPath(`RB/${rbImageName}`)
try {
await fs.access(srcImagePath)
await fs.mkdir(imagesDir, { recursive: true })
const destImagePath = path.join(imagesDir, imageName)
await fs.rename(srcImagePath, destImagePath)
const newImagePath = `images/${year}/${month}/${day}/${imageName}`
imageReplacements.push({ oldPath: imagePath, newPath: newImagePath })
} catch {
}
}
for (const { oldPath, newPath } of imageReplacements) {
content = content.replace(new RegExp(oldPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newPath)
}
await fs.writeFile(destPath, content, 'utf-8')
await fs.unlink(srcPath)
}
export async function restoreFolder(
srcPath: string,
destPath: string,
deletedDate: string,
year: string,
month: string,
day: string
) {
await fs.mkdir(destPath, { recursive: true })
const entries = await fs.readdir(srcPath, { withFileTypes: true })
for (const entry of entries) {
const srcEntryPath = path.join(srcPath, entry.name)
const destEntryPath = path.join(destPath, entry.name)
if (entry.isDirectory()) {
await restoreFolder(srcEntryPath, destEntryPath, deletedDate, year, month, day)
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
await restoreFile(srcEntryPath, destEntryPath, deletedDate, year, month, day)
} else {
await fs.rename(srcEntryPath, destEntryPath)
}
}
const remaining = await fs.readdir(srcPath)
if (remaining.length === 0) {
await fs.rmdir(srcPath)
}
}

View File

@@ -0,0 +1,175 @@
import express, { type Request, type Response } from 'express'
import fs from 'fs/promises'
import path from 'path'
import { asyncHandler } from '../../utils/asyncHandler.js'
import { successResponse } from '../../utils/response.js'
import { resolveNotebookPath } from '../../utils/pathSafety.js'
import { restoreFile, restoreFolder } from './recycleBinService.js'
import {
NotFoundError,
BadRequestError,
ValidationError,
AlreadyExistsError,
} from '../../../shared/errors/index.js'
const router = express.Router()
router.get(
'/',
asyncHandler(async (req: Request, res: Response) => {
const { fullPath: rbDir } = resolveNotebookPath('RB')
try {
await fs.access(rbDir)
} catch {
successResponse(res, { groups: [] })
return
}
const entries = await fs.readdir(rbDir, { withFileTypes: true })
const items: { name: string; originalName: string; type: 'file' | 'dir'; deletedDate: string; path: string }[] = []
for (const entry of entries) {
const match = entry.name.match(/^(\d{8})_(.+)$/)
if (!match) continue
const [, dateStr, originalName] = match
if (entry.isDirectory()) {
items.push({
name: entry.name,
originalName,
type: 'dir',
deletedDate: dateStr,
path: `RB/${entry.name}`,
})
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
items.push({
name: entry.name,
originalName,
type: 'file',
deletedDate: dateStr,
path: `RB/${entry.name}`,
})
}
}
const groupedMap = new Map<string, typeof items>()
for (const item of items) {
const existing = groupedMap.get(item.deletedDate) || []
existing.push(item)
groupedMap.set(item.deletedDate, existing)
}
const groups = Array.from(groupedMap.entries())
.map(([date, items]) => ({
date,
items: items.sort((a, b) => a.originalName.localeCompare(b.originalName)),
}))
.sort((a, b) => b.date.localeCompare(a.date))
successResponse(res, { groups })
}),
)
router.post(
'/restore',
asyncHandler(async (req: Request, res: Response) => {
const { path: relPath, type } = req.body as { path?: string; type?: 'file' | 'dir' }
if (!relPath || !type) {
throw new ValidationError('Path and type are required')
}
const { fullPath: itemPath } = resolveNotebookPath(relPath)
try {
await fs.access(itemPath)
} catch {
throw new NotFoundError('Item not found in recycle bin')
}
const match = path.basename(itemPath).match(/^(\d{8})_(.+)$/)
if (!match) {
throw new BadRequestError('Invalid recycle bin item name')
}
const [, dateStr, originalName] = match
const year = dateStr.substring(0, 4)
const month = dateStr.substring(4, 6)
const day = dateStr.substring(6, 8)
const { fullPath: markdownsDir } = resolveNotebookPath('markdowns')
await fs.mkdir(markdownsDir, { recursive: true })
const destPath = path.join(markdownsDir, originalName)
const existing = await fs.stat(destPath).catch(() => null)
if (existing) {
throw new AlreadyExistsError('A file or folder with this name already exists')
}
if (type === 'dir') {
await restoreFolder(itemPath, destPath, dateStr, year, month, day)
} else {
await restoreFile(itemPath, destPath, dateStr, year, month, day)
}
successResponse(res, null)
}),
)
router.delete(
'/permanent',
asyncHandler(async (req: Request, res: Response) => {
const { path: relPath, type } = req.body as { path?: string; type?: 'file' | 'dir' }
if (!relPath || !type) {
throw new ValidationError('Path and type are required')
}
const { fullPath: itemPath } = resolveNotebookPath(relPath)
try {
await fs.access(itemPath)
} catch {
throw new NotFoundError('Item not found in recycle bin')
}
if (type === 'dir') {
await fs.rm(itemPath, { recursive: true, force: true })
} else {
await fs.unlink(itemPath)
}
successResponse(res, null)
}),
)
router.delete(
'/empty',
asyncHandler(async (req: Request, res: Response) => {
const { fullPath: rbDir } = resolveNotebookPath('RB')
try {
await fs.access(rbDir)
} catch {
successResponse(res, null)
return
}
const entries = await fs.readdir(rbDir, { withFileTypes: true })
for (const entry of entries) {
const entryPath = path.join(rbDir, entry.name)
if (entry.isDirectory()) {
await fs.rm(entryPath, { recursive: true, force: true })
} else {
await fs.unlink(entryPath)
}
}
successResponse(res, null)
}),
)
export default router

View File

@@ -0,0 +1,25 @@
import type { Router } from 'express'
import type { ServiceContainer } from '../../infra/container.js'
import { createApiModule } from '../../infra/createModule.js'
import { REMOTE_MODULE } from '../../../shared/modules/remote/index.js'
import { RemoteService } from './service.js'
import { createRemoteRoutes } from './routes.js'
export * from './service.js'
export * from './routes.js'
export const createRemoteModule = () => {
return createApiModule(REMOTE_MODULE, {
routes: (container: ServiceContainer): Router => {
const remoteService = container.getSync<RemoteService>('remoteService')
return createRemoteRoutes({ remoteService })
},
lifecycle: {
onLoad: (container: ServiceContainer): void => {
container.register('remoteService', () => new RemoteService())
},
},
})
}
export default createRemoteModule

View File

@@ -0,0 +1,80 @@
import express, { type Request, type Response, type Router } from 'express'
import { asyncHandler } from '../../utils/asyncHandler.js'
import { successResponse } from '../../utils/response.js'
import { RemoteService, type DeviceData } from './service.js'
export interface RemoteRoutesDependencies {
remoteService: RemoteService
}
export const createRemoteRoutes = (deps: RemoteRoutesDependencies): Router => {
const router = express.Router()
const { remoteService } = deps
router.get(
'/config',
asyncHandler(async (req: Request, res: Response) => {
const config = await remoteService.getConfig()
successResponse(res, config)
}),
)
router.post(
'/config',
asyncHandler(async (req: Request, res: Response) => {
const config = req.body
await remoteService.saveConfig(config)
successResponse(res, null)
}),
)
router.get(
'/screenshot',
asyncHandler(async (req: Request, res: Response) => {
const deviceName = req.query.device as string | undefined
const buffer = await remoteService.getScreenshot(deviceName)
if (!buffer) {
return successResponse(res, '')
}
const base64 = `data:image/png;base64,${buffer.toString('base64')}`
successResponse(res, base64)
}),
)
router.post(
'/screenshot',
asyncHandler(async (req: Request, res: Response) => {
const { dataUrl, deviceName } = req.body
console.log('[Remote] saveScreenshot called:', { deviceName, hasDataUrl: !!dataUrl })
await remoteService.saveScreenshot(dataUrl, deviceName)
successResponse(res, null)
}),
)
router.get(
'/data',
asyncHandler(async (req: Request, res: Response) => {
const deviceName = req.query.device as string | undefined
const data = await remoteService.getData(deviceName)
successResponse(res, data)
}),
)
router.post(
'/data',
asyncHandler(async (req: Request, res: Response) => {
const { deviceName, lastConnected } = req.body
const data: DeviceData = {}
if (lastConnected !== undefined) {
data.lastConnected = lastConnected
}
await remoteService.saveData(data, deviceName)
successResponse(res, null)
}),
)
return router
}
const remoteService = new RemoteService()
export default createRemoteRoutes({ remoteService })

View File

@@ -0,0 +1,178 @@
import fs from 'fs/promises'
import path from 'path'
import { resolveNotebookPath } from '../../utils/pathSafety.js'
import type { RemoteConfig, RemoteDevice } from '../../../shared/modules/remote/types.js'
export interface RemoteServiceDependencies { }
const REMOTE_DIR = 'remote'
export interface DeviceData {
lastConnected?: string
}
export class RemoteService {
constructor(private deps: RemoteServiceDependencies = {}) { }
private getRemoteDir(): { relPath: string; fullPath: string } {
const { fullPath } = resolveNotebookPath(REMOTE_DIR)
return { relPath: REMOTE_DIR, fullPath }
}
private getDeviceDir(deviceName: string): { relPath: string; fullPath: string } {
const safeName = this.sanitizeFileName(deviceName)
const { fullPath } = resolveNotebookPath(path.join(REMOTE_DIR, safeName))
return { relPath: path.join(REMOTE_DIR, safeName), fullPath }
}
private sanitizeFileName(name: string): string {
return name.replace(/[<>:"/\\|?*]/g, '_').trim() || 'unnamed'
}
private getDeviceConfigPath(deviceName: string): string {
const { fullPath } = this.getDeviceDir(deviceName)
return path.join(fullPath, 'config.json')
}
private getDeviceScreenshotPath(deviceName: string): string {
const { fullPath } = this.getDeviceDir(deviceName)
return path.join(fullPath, 'screenshot.png')
}
private getDeviceDataPath(deviceName: string): string {
const { fullPath } = this.getDeviceDir(deviceName)
return path.join(fullPath, 'data.json')
}
private async ensureDir(dirPath: string): Promise<void> {
await fs.mkdir(dirPath, { recursive: true })
}
private async getDeviceNames(): Promise<string[]> {
const { fullPath } = this.getRemoteDir()
try {
const entries = await fs.readdir(fullPath, { withFileTypes: true })
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name)
return dirs
} catch {
return []
}
}
async getConfig(): Promise<RemoteConfig> {
const deviceNames = await this.getDeviceNames()
const devices: RemoteDevice[] = await Promise.all(
deviceNames.map(async (name) => {
try {
const configPath = this.getDeviceConfigPath(name)
const content = await fs.readFile(configPath, 'utf-8')
const deviceConfig = JSON.parse(content)
return {
id: deviceConfig.id || name,
deviceName: name,
serverHost: deviceConfig.serverHost || '',
desktopPort: deviceConfig.desktopPort || 3000,
gitPort: deviceConfig.gitPort || 3001,
}
} catch {
return {
id: name,
deviceName: name,
serverHost: '',
desktopPort: 3000,
gitPort: 3001,
}
}
})
)
return { devices }
}
async saveConfig(config: RemoteConfig): Promise<void> {
const { fullPath: remoteDirFullPath } = this.getRemoteDir()
await this.ensureDir(remoteDirFullPath)
const existingDevices = await this.getDeviceNames()
const newDeviceNames = config.devices.map(d => this.sanitizeFileName(d.deviceName))
for (const oldDevice of existingDevices) {
if (!newDeviceNames.includes(oldDevice)) {
try {
const oldDir = path.join(remoteDirFullPath, oldDevice)
await fs.rm(oldDir, { recursive: true, force: true })
} catch { }
}
}
for (const device of config.devices) {
const deviceDir = this.getDeviceDir(device.deviceName)
await this.ensureDir(deviceDir.fullPath)
const deviceConfigPath = this.getDeviceConfigPath(device.deviceName)
const deviceConfig = {
id: device.id,
serverHost: device.serverHost,
desktopPort: device.desktopPort,
gitPort: device.gitPort,
}
await fs.writeFile(deviceConfigPath, JSON.stringify(deviceConfig, null, 2), 'utf-8')
}
}
async getScreenshot(deviceName?: string): Promise<Buffer | null> {
if (!deviceName) {
return null
}
const screenshotPath = this.getDeviceScreenshotPath(deviceName)
try {
return await fs.readFile(screenshotPath)
} catch {
return null
}
}
async saveScreenshot(dataUrl: string, deviceName?: string): Promise<void> {
console.log('[RemoteService] saveScreenshot:', { deviceName, dataUrlLength: dataUrl?.length })
if (!deviceName || deviceName.trim() === '') {
console.warn('[RemoteService] saveScreenshot skipped: no deviceName')
return
}
const deviceDir = this.getDeviceDir(deviceName)
await this.ensureDir(deviceDir.fullPath)
const base64Data = dataUrl.replace(/^data:image\/png;base64,/, '')
const buffer = Buffer.from(base64Data, 'base64')
const screenshotPath = this.getDeviceScreenshotPath(deviceName)
await fs.writeFile(screenshotPath, buffer)
}
async getData(deviceName?: string): Promise<DeviceData | null> {
if (!deviceName || deviceName.trim() === '') {
return null
}
const dataPath = this.getDeviceDataPath(deviceName)
try {
const content = await fs.readFile(dataPath, 'utf-8')
return JSON.parse(content)
} catch {
return null
}
}
async saveData(data: DeviceData, deviceName?: string): Promise<void> {
if (!deviceName || deviceName.trim() === '') {
console.warn('[RemoteService] saveData skipped: no deviceName')
return
}
const deviceDir = this.getDeviceDir(deviceName)
await this.ensureDir(deviceDir.fullPath)
const dataPath = this.getDeviceDataPath(deviceName)
await fs.writeFile(dataPath, JSON.stringify(data, null, 2), 'utf-8')
}
}
export const createRemoteService = (deps?: RemoteServiceDependencies): RemoteService => {
return new RemoteService(deps)
}

View File

@@ -0,0 +1,80 @@
import { logger } from '../../utils/logger.js'
export interface HeartbeatCallback {
(): Promise<void>
}
export interface HeartbeatState {
lastHeartbeat: Date
isRunning: boolean
}
const DEFAULT_HEARTBEAT_INTERVAL = 60000
export class HeartbeatService {
private interval: NodeJS.Timeout | null = null
private lastHeartbeat: Date = new Date()
private readonly intervalMs: number
private callback: HeartbeatCallback | null = null
constructor(intervalMs: number = DEFAULT_HEARTBEAT_INTERVAL) {
this.intervalMs = intervalMs
}
setCallback(callback: HeartbeatCallback): void {
this.callback = callback
}
start(): void {
if (this.interval) {
this.stop()
}
this.interval = setInterval(async () => {
if (this.callback) {
try {
this.lastHeartbeat = new Date()
await this.callback()
} catch (err) {
logger.error('Heartbeat callback failed:', err)
}
}
}, this.intervalMs)
this.lastHeartbeat = new Date()
}
stop(): void {
if (this.interval) {
clearInterval(this.interval)
this.interval = null
}
}
isRunning(): boolean {
return this.interval !== null
}
getLastHeartbeat(): Date {
return this.lastHeartbeat
}
updateHeartbeat(): void {
this.lastHeartbeat = new Date()
}
getState(): HeartbeatState {
return {
lastHeartbeat: this.lastHeartbeat,
isRunning: this.isRunning()
}
}
restoreState(state: { lastHeartbeat: string }): void {
this.lastHeartbeat = new Date(state.lastHeartbeat)
}
}
export const createHeartbeatService = (intervalMs?: number): HeartbeatService => {
return new HeartbeatService(intervalMs)
}

View File

@@ -0,0 +1,38 @@
import type { Router } from 'express'
import type { ServiceContainer } from '../../infra/container.js'
import { createApiModule } from '../../infra/createModule.js'
import { TIME_TRACKING_MODULE } from '../../../shared/modules/time-tracking/index.js'
import {
TimeTrackerService,
initializeTimeTrackerService,
type TimeTrackerServiceConfig
} from './timeService.js'
import { createTimeTrackingRoutes } from './routes.js'
export * from './timeService.js'
export * from './heartbeatService.js'
export * from './sessionPersistence.js'
export * from './routes.js'
export interface TimeTrackingModuleConfig {
config?: TimeTrackerServiceConfig
}
export const createTimeTrackingModule = (moduleConfig: TimeTrackingModuleConfig = {}) => {
let serviceInstance: TimeTrackerService | undefined
return createApiModule(TIME_TRACKING_MODULE, {
routes: (container: ServiceContainer): Router => {
const timeTrackerService = container.getSync<TimeTrackerService>('timeTrackerService')
return createTimeTrackingRoutes({ timeTrackerService })
},
lifecycle: {
onLoad: async (container: ServiceContainer): Promise<void> => {
serviceInstance = await initializeTimeTrackerService(moduleConfig.config)
container.register('timeTrackerService', () => serviceInstance!)
},
},
})
}
export default createTimeTrackingModule

View File

@@ -0,0 +1,131 @@
import express, { type Request, type Response, type Router } from 'express'
import { asyncHandler } from '../../utils/asyncHandler.js'
import { successResponse } from '../../utils/response.js'
import { TimeTrackerService } from './timeService.js'
import type { TimeTrackingEvent } from '../../../shared/types.js'
export interface TimeTrackingRoutesDependencies {
timeTrackerService: TimeTrackerService
}
export const createTimeTrackingRoutes = (deps: TimeTrackingRoutesDependencies): Router => {
const router = express.Router()
const { timeTrackerService } = deps
router.get(
'/current',
asyncHandler(async (_req: Request, res: Response) => {
const state = timeTrackerService.getCurrentState()
successResponse(res, {
isRunning: state.isRunning,
isPaused: state.isPaused,
currentSession: state.currentSession ? {
id: state.currentSession.id,
startTime: state.currentSession.startTime,
duration: state.currentSession.duration,
currentTab: state.currentTabRecord ? {
tabId: state.currentTabRecord.tabId,
fileName: state.currentTabRecord.fileName,
tabType: state.currentTabRecord.tabType
} : null
} : null,
todayDuration: state.todayDuration
})
})
)
router.post(
'/event',
asyncHandler(async (req: Request, res: Response) => {
const event = req.body as TimeTrackingEvent
await timeTrackerService.handleEvent(event)
successResponse(res, null)
})
)
router.get(
'/day/:date',
asyncHandler(async (req: Request, res: Response) => {
const { date } = req.params
const [year, month, day] = date.split('-').map(Number)
const data = await timeTrackerService.getDayData(year, month, day)
const sessionsCount = data.sessions.length
const averageSessionDuration = sessionsCount > 0
? Math.floor(data.totalDuration / sessionsCount)
: 0
const longestSession = data.sessions.reduce((max, s) =>
s.duration > max ? s.duration : max, 0)
const topTabs = Object.entries(data.tabSummary)
.map(([_, summary]) => ({
fileName: summary.fileName,
duration: summary.totalDuration
}))
.sort((a, b) => b.duration - a.duration)
.slice(0, 5)
successResponse(res, {
...data,
stats: {
sessionsCount,
averageSessionDuration,
longestSession,
topTabs
}
})
})
)
router.get(
'/week/:startDate',
asyncHandler(async (req: Request, res: Response) => {
const { startDate } = req.params
const [year, month, day] = startDate.split('-').map(Number)
const start = new Date(year, month - 1, day)
const data = await timeTrackerService.getWeekData(start)
const totalDuration = data.reduce((sum, d) => sum + d.totalDuration, 0)
const activeDays = data.filter(d => d.totalDuration > 0).length
successResponse(res, {
days: data,
totalDuration,
activeDays,
averageDaily: activeDays > 0 ? Math.floor(totalDuration / activeDays) : 0
})
})
)
router.get(
'/month/:yearMonth',
asyncHandler(async (req: Request, res: Response) => {
const { yearMonth } = req.params
const [year, month] = yearMonth.split('-').map(Number)
const data = await timeTrackerService.getMonthData(year, month)
successResponse(res, data)
})
)
router.get(
'/year/:year',
asyncHandler(async (req: Request, res: Response) => {
const { year } = req.params
const data = await timeTrackerService.getYearData(parseInt(year))
successResponse(res, data)
})
)
router.get(
'/stats',
asyncHandler(async (req: Request, res: Response) => {
const year = req.query.year ? parseInt(req.query.year as string) : undefined
const month = req.query.month ? parseInt(req.query.month as string) : undefined
const stats = await timeTrackerService.getStats(year, month)
successResponse(res, stats)
})
)
return router
}

View File

@@ -0,0 +1,367 @@
import fs from 'fs/promises'
import path from 'path'
import { NOTEBOOK_ROOT } from '../../config/paths.js'
import type {
TimingSession,
TabRecord,
DayTimeData,
MonthTimeData,
YearTimeData
} from '../../../shared/types.js'
import { logger } from '../../utils/logger.js'
const TIME_ROOT = path.join(NOTEBOOK_ROOT, 'time')
export interface PersistedSessionState {
session: TimingSession | null
currentTabRecord: TabRecord | null
isPaused: boolean
lastHeartbeat: string
}
export interface SessionPersistence {
loadCurrentState(): Promise<PersistedSessionState>
saveCurrentState(state: PersistedSessionState): Promise<void>
clearCurrentState(): Promise<void>
saveSessionToDay(session: TimingSession): Promise<void>
getDayData(year: number, month: number, day: number): Promise<DayTimeData>
getMonthData(year: number, month: number): Promise<MonthTimeData>
getYearData(year: number): Promise<YearTimeData>
updateDayDataRealtime(
year: number,
month: number,
day: number,
session: TimingSession,
currentTabRecord: TabRecord | null
): Promise<DayTimeData>
updateMonthSummary(year: number, month: number, day: number, duration: number): Promise<void>
updateYearSummary(year: number, month: number, duration: number): Promise<void>
recalculateMonthSummary(year: number, month: number, todayDuration: number): Promise<void>
recalculateYearSummary(year: number): Promise<void>
}
const getDayFilePath = (year: number, month: number, day: number): string => {
const monthStr = month.toString().padStart(2, '0')
const dayStr = day.toString().padStart(2, '0')
return path.join(TIME_ROOT, year.toString(), `${year}${monthStr}${dayStr}.json`)
}
const getMonthFilePath = (year: number, month: number): string => {
const monthStr = month.toString().padStart(2, '0')
return path.join(TIME_ROOT, year.toString(), `${year}${monthStr}.json`)
}
const getYearFilePath = (year: number): string => {
return path.join(TIME_ROOT, 'summary', `${year}.json`)
}
const ensureDirExists = async (filePath: string): Promise<void> => {
const dir = path.dirname(filePath)
await fs.mkdir(dir, { recursive: true })
}
const createEmptyDayData = (year: number, month: number, day: number): DayTimeData => ({
date: `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`,
totalDuration: 0,
sessions: [],
tabSummary: {},
lastUpdated: new Date().toISOString()
})
const createEmptyMonthData = (year: number, month: number): MonthTimeData => ({
year,
month,
days: {},
monthlyTotal: 0,
averageDaily: 0,
activeDays: 0,
lastUpdated: new Date().toISOString()
})
const createEmptyYearData = (year: number): YearTimeData => ({
year,
months: {},
yearlyTotal: 0,
averageMonthly: 0,
averageDaily: 0,
totalActiveDays: 0
})
class SessionPersistenceService implements SessionPersistence {
private readonly stateFilePath: string
constructor() {
this.stateFilePath = path.join(TIME_ROOT, '.current-session.json')
}
async loadCurrentState(): Promise<PersistedSessionState> {
try {
const content = await fs.readFile(this.stateFilePath, 'utf-8')
const state = JSON.parse(content)
return {
session: state.session || null,
currentTabRecord: state.currentTabRecord || null,
isPaused: state.isPaused || false,
lastHeartbeat: state.lastHeartbeat || new Date().toISOString()
}
} catch (err) {
logger.debug('No existing session to load or session file corrupted')
return {
session: null,
currentTabRecord: null,
isPaused: false,
lastHeartbeat: new Date().toISOString()
}
}
}
async saveCurrentState(state: PersistedSessionState): Promise<void> {
await ensureDirExists(this.stateFilePath)
await fs.writeFile(this.stateFilePath, JSON.stringify({
session: state.session,
currentTabRecord: state.currentTabRecord,
isPaused: state.isPaused,
lastHeartbeat: state.lastHeartbeat
}), 'utf-8')
}
async clearCurrentState(): Promise<void> {
try {
await fs.unlink(this.stateFilePath)
} catch (err) {
logger.debug('Session state file already removed or does not exist')
}
}
async saveSessionToDay(session: TimingSession): Promise<void> {
const startTime = new Date(session.startTime)
const year = startTime.getFullYear()
const month = startTime.getMonth() + 1
const day = startTime.getDate()
const filePath = getDayFilePath(year, month, day)
await ensureDirExists(filePath)
let dayData = await this.getDayData(year, month, day)
dayData.sessions.push(session)
dayData.totalDuration += session.duration
for (const record of session.tabRecords) {
const key = record.filePath || record.fileName
if (!dayData.tabSummary[key]) {
dayData.tabSummary[key] = {
fileName: record.fileName,
tabType: record.tabType,
totalDuration: 0,
focusCount: 0
}
}
dayData.tabSummary[key].totalDuration += record.duration
dayData.tabSummary[key].focusCount += record.focusedPeriods.length
}
dayData.lastUpdated = new Date().toISOString()
await fs.writeFile(filePath, JSON.stringify(dayData, null, 2), 'utf-8')
await this.updateMonthSummary(year, month, day, session.duration)
await this.updateYearSummary(year, month, session.duration)
}
async getDayData(year: number, month: number, day: number): Promise<DayTimeData> {
const filePath = getDayFilePath(year, month, day)
try {
const content = await fs.readFile(filePath, 'utf-8')
return JSON.parse(content)
} catch (err) {
return createEmptyDayData(year, month, day)
}
}
async getMonthData(year: number, month: number): Promise<MonthTimeData> {
const filePath = getMonthFilePath(year, month)
try {
const content = await fs.readFile(filePath, 'utf-8')
return JSON.parse(content)
} catch (err) {
return createEmptyMonthData(year, month)
}
}
async getYearData(year: number): Promise<YearTimeData> {
const filePath = getYearFilePath(year)
try {
const content = await fs.readFile(filePath, 'utf-8')
return JSON.parse(content)
} catch (err) {
return createEmptyYearData(year)
}
}
async updateDayDataRealtime(
year: number,
month: number,
day: number,
session: TimingSession,
currentTabRecord: TabRecord | null
): Promise<DayTimeData> {
const filePath = getDayFilePath(year, month, day)
await ensureDirExists(filePath)
let dayData = await this.getDayData(year, month, day)
const currentSessionDuration = session.tabRecords.reduce((sum, r) => sum + r.duration, 0) +
(currentTabRecord?.duration || 0)
const existingSessionIndex = dayData.sessions.findIndex(s => s.id === session.id)
const realtimeSession: TimingSession = {
...session,
duration: currentSessionDuration,
tabRecords: currentTabRecord
? [...session.tabRecords, currentTabRecord]
: session.tabRecords
}
if (existingSessionIndex >= 0) {
const oldDuration = dayData.sessions[existingSessionIndex].duration
dayData.sessions[existingSessionIndex] = realtimeSession
dayData.totalDuration += currentSessionDuration - oldDuration
} else {
dayData.sessions.push(realtimeSession)
dayData.totalDuration += currentSessionDuration
}
dayData.tabSummary = {}
for (const s of dayData.sessions) {
for (const record of s.tabRecords) {
const key = record.filePath || record.fileName
if (!dayData.tabSummary[key]) {
dayData.tabSummary[key] = {
fileName: record.fileName,
tabType: record.tabType,
totalDuration: 0,
focusCount: 0
}
}
dayData.tabSummary[key].totalDuration += record.duration
dayData.tabSummary[key].focusCount += record.focusedPeriods.length
}
}
dayData.lastUpdated = new Date().toISOString()
await fs.writeFile(filePath, JSON.stringify(dayData, null, 2), 'utf-8')
return dayData
}
async updateMonthSummary(year: number, month: number, day: number, duration: number): Promise<void> {
const filePath = getMonthFilePath(year, month)
await ensureDirExists(filePath)
let monthData = await this.getMonthData(year, month)
const dayStr = day.toString().padStart(2, '0')
if (!monthData.days[dayStr]) {
monthData.days[dayStr] = { totalDuration: 0, sessions: 0, topTabs: [] }
}
monthData.days[dayStr].totalDuration += duration
monthData.days[dayStr].sessions += 1
monthData.monthlyTotal += duration
monthData.activeDays = Object.keys(monthData.days).length
monthData.averageDaily = Math.floor(monthData.monthlyTotal / monthData.activeDays)
monthData.lastUpdated = new Date().toISOString()
await fs.writeFile(filePath, JSON.stringify(monthData, null, 2), 'utf-8')
}
async updateYearSummary(year: number, month: number, duration: number): Promise<void> {
const filePath = getYearFilePath(year)
await ensureDirExists(filePath)
let yearData = await this.getYearData(year)
const monthStr = month.toString().padStart(2, '0')
if (!yearData.months[monthStr]) {
yearData.months[monthStr] = { totalDuration: 0, activeDays: 0 }
}
yearData.months[monthStr].totalDuration += duration
yearData.yearlyTotal += duration
yearData.totalActiveDays = Object.values(yearData.months).reduce((sum, m) => sum + m.activeDays, 0)
const monthCount = Object.keys(yearData.months).length
yearData.averageMonthly = Math.floor(yearData.yearlyTotal / monthCount)
yearData.averageDaily = yearData.totalActiveDays > 0
? Math.floor(yearData.yearlyTotal / yearData.totalActiveDays)
: 0
await fs.writeFile(filePath, JSON.stringify(yearData, null, 2), 'utf-8')
}
async recalculateMonthSummary(year: number, month: number, todayDuration: number): Promise<void> {
const monthFilePath = getMonthFilePath(year, month)
await ensureDirExists(monthFilePath)
let monthData = await this.getMonthData(year, month)
const dayStr = new Date().getDate().toString().padStart(2, '0')
if (!monthData.days[dayStr]) {
monthData.days[dayStr] = { totalDuration: 0, sessions: 0, topTabs: [] }
}
const oldDayDuration = monthData.days[dayStr].totalDuration
monthData.days[dayStr].totalDuration = todayDuration
monthData.monthlyTotal = monthData.monthlyTotal - oldDayDuration + todayDuration
monthData.activeDays = Object.keys(monthData.days).length
monthData.averageDaily = monthData.activeDays > 0
? Math.floor(monthData.monthlyTotal / monthData.activeDays)
: 0
monthData.lastUpdated = new Date().toISOString()
await fs.writeFile(monthFilePath, JSON.stringify(monthData, null, 2), 'utf-8')
}
async recalculateYearSummary(year: number): Promise<void> {
const yearFilePath = getYearFilePath(year)
await ensureDirExists(yearFilePath)
let yearData = await this.getYearData(year)
const monthStr = (new Date().getMonth() + 1).toString().padStart(2, '0')
const monthFilePath = getMonthFilePath(year, new Date().getMonth() + 1)
try {
const monthContent = await fs.readFile(monthFilePath, 'utf-8')
const monthData = JSON.parse(monthContent)
if (!yearData.months[monthStr]) {
yearData.months[monthStr] = { totalDuration: 0, activeDays: 0 }
}
const oldMonthTotal = yearData.months[monthStr].totalDuration
yearData.months[monthStr].totalDuration = monthData.monthlyTotal
yearData.months[monthStr].activeDays = monthData.activeDays
yearData.yearlyTotal = yearData.yearlyTotal - oldMonthTotal + monthData.monthlyTotal
yearData.totalActiveDays = Object.values(yearData.months).reduce((sum, m) => sum + m.activeDays, 0)
const monthCount = Object.keys(yearData.months).length
yearData.averageMonthly = monthCount > 0 ? Math.floor(yearData.yearlyTotal / monthCount) : 0
yearData.averageDaily = yearData.totalActiveDays > 0
? Math.floor(yearData.yearlyTotal / yearData.totalActiveDays)
: 0
} catch (err) {
logger.debug('Month file not found for year summary calculation')
}
await fs.writeFile(yearFilePath, JSON.stringify(yearData, null, 2), 'utf-8')
}
}
export const createSessionPersistence = (): SessionPersistence => {
return new SessionPersistenceService()
}
export { SessionPersistenceService }

View File

@@ -0,0 +1,442 @@
import type {
DayTimeData,
MonthTimeData,
YearTimeData,
TimingSession,
TabRecord,
TabType,
TimeTrackingEvent,
} from '../../../shared/types.js'
import { getTabTypeFromPath, getFileNameFromPath } from '../../../shared/utils/tabType.js'
import { logger } from '../../utils/logger.js'
import { HeartbeatService, createHeartbeatService } from './heartbeatService.js'
import { SessionPersistence, createSessionPersistence } from './sessionPersistence.js'
const generateId = (): string => {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
}
export interface TimeTrackerServiceDependencies {
heartbeatService: HeartbeatService
persistence: SessionPersistence
}
export interface TimeTrackerServiceConfig {
heartbeatIntervalMs?: number
}
class TimeTrackerService {
private currentSession: TimingSession | null = null
private currentTabRecord: TabRecord | null = null
private isPaused: boolean = false
private todayDuration: number = 0
private _initialized: boolean = false
private static _initializationPromise: Promise<void> | null = null
private readonly heartbeatService: HeartbeatService
private readonly persistence: SessionPersistence
private constructor(
dependencies: TimeTrackerServiceDependencies
) {
this.heartbeatService = dependencies.heartbeatService
this.persistence = dependencies.persistence
}
static async create(
config?: TimeTrackerServiceConfig
): Promise<TimeTrackerService> {
const heartbeatService = createHeartbeatService(config?.heartbeatIntervalMs)
const persistence = createSessionPersistence()
const instance = new TimeTrackerService({
heartbeatService,
persistence
})
await instance.initialize()
return instance
}
static async createWithDependencies(
dependencies: TimeTrackerServiceDependencies
): Promise<TimeTrackerService> {
const instance = new TimeTrackerService(dependencies)
await instance.initialize()
return instance
}
private async initialize(): Promise<void> {
if (this._initialized) {
return
}
if (TimeTrackerService._initializationPromise) {
await TimeTrackerService._initializationPromise
return
}
TimeTrackerService._initializationPromise = this.loadCurrentState()
await TimeTrackerService._initializationPromise
this._initialized = true
TimeTrackerService._initializationPromise = null
this.heartbeatService.setCallback(async () => {
if (this.currentSession && !this.isPaused) {
try {
this.heartbeatService.updateHeartbeat()
await this.updateCurrentTabDuration()
await this.saveCurrentState()
await this.updateTodayDataRealtime()
} catch (err) {
logger.error('Heartbeat update failed:', err)
}
}
})
}
ensureInitialized(): void {
if (!this._initialized) {
throw new Error('TimeTrackerService 未初始化,请使用 TimeTrackerService.create() 创建实例')
}
}
private async loadCurrentState(): Promise<void> {
const now = new Date()
const todayData = await this.persistence.getDayData(now.getFullYear(), now.getMonth() + 1, now.getDate())
this.todayDuration = todayData.totalDuration
const state = await this.persistence.loadCurrentState()
if (state.session && state.session.status === 'active') {
const sessionStart = new Date(state.session.startTime)
const now = new Date()
if (now.getTime() - sessionStart.getTime() < 24 * 60 * 60 * 1000) {
this.currentSession = state.session
this.isPaused = state.isPaused
if (state.currentTabRecord) {
this.currentTabRecord = state.currentTabRecord
}
this.heartbeatService.restoreState({ lastHeartbeat: state.lastHeartbeat })
} else {
await this.endSession()
}
}
}
private async saveCurrentState(): Promise<void> {
await this.persistence.saveCurrentState({
session: this.currentSession,
currentTabRecord: this.currentTabRecord,
isPaused: this.isPaused,
lastHeartbeat: this.heartbeatService.getLastHeartbeat().toISOString()
})
}
async startSession(): Promise<TimingSession> {
if (this.currentSession && this.currentSession.status === 'active') {
return this.currentSession
}
const now = new Date()
this.currentSession = {
id: generateId(),
startTime: now.toISOString(),
duration: 0,
status: 'active',
tabRecords: []
}
this.isPaused = false
this.heartbeatService.updateHeartbeat()
this.heartbeatService.start()
await this.saveCurrentState()
return this.currentSession
}
async pauseSession(): Promise<void> {
if (!this.currentSession || this.isPaused) return
this.isPaused = true
await this.updateCurrentTabDuration()
await this.saveCurrentState()
}
async resumeSession(): Promise<void> {
if (!this.currentSession || !this.isPaused) return
this.isPaused = false
this.heartbeatService.updateHeartbeat()
if (this.currentTabRecord) {
const now = new Date()
const timeStr = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
this.currentTabRecord.focusedPeriods.push({ start: timeStr, end: timeStr })
}
await this.saveCurrentState()
}
async endSession(): Promise<void> {
if (!this.currentSession) return
this.heartbeatService.stop()
await this.updateCurrentTabDuration()
const now = new Date()
this.currentSession.endTime = now.toISOString()
this.currentSession.status = 'ended'
const startTime = new Date(this.currentSession.startTime)
this.currentSession.duration = Math.floor((now.getTime() - startTime.getTime()) / 1000)
await this.persistence.saveSessionToDay(this.currentSession)
this.todayDuration += this.currentSession.duration
this.currentSession = null
this.currentTabRecord = null
this.isPaused = false
await this.persistence.clearCurrentState()
}
private async updateCurrentTabDuration(): Promise<void> {
if (!this.currentSession || !this.currentTabRecord) return
const now = new Date()
const periods = this.currentTabRecord.focusedPeriods
if (periods.length > 0) {
const lastPeriod = periods[periods.length - 1]
const [h, m, s] = lastPeriod.start.split(':').map(Number)
const startSeconds = h * 3600 + m * 60 + s
const currentSeconds = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds()
this.currentTabRecord.duration = currentSeconds - startSeconds
lastPeriod.end = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
}
}
private async updateTodayDataRealtime(): Promise<void> {
if (!this.currentSession) return
const now = new Date()
const year = now.getFullYear()
const month = now.getMonth() + 1
const day = now.getDate()
const dayData = await this.persistence.updateDayDataRealtime(
year,
month,
day,
this.currentSession,
this.currentTabRecord
)
this.todayDuration = dayData.totalDuration
await this.persistence.recalculateMonthSummary(year, month, this.todayDuration)
await this.persistence.recalculateYearSummary(year)
}
async handleTabSwitch(tabInfo: { tabId: string; filePath: string | null }): Promise<void> {
if (!this.currentSession || this.isPaused) return
await this.updateCurrentTabDuration()
if (this.currentTabRecord && this.currentTabRecord.duration > 0) {
this.currentSession.tabRecords.push({ ...this.currentTabRecord })
}
const now = new Date()
const timeStr = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
this.currentTabRecord = {
tabId: tabInfo.tabId,
filePath: tabInfo.filePath,
fileName: getFileNameFromPath(tabInfo.filePath),
tabType: getTabTypeFromPath(tabInfo.filePath),
duration: 0,
focusedPeriods: [{ start: timeStr, end: timeStr }]
}
await this.saveCurrentState()
}
async handleEvent(event: TimeTrackingEvent): Promise<void> {
switch (event.type) {
case 'window-focus':
if (!this.currentSession) {
await this.startSession()
if (event.tabInfo) {
await this.handleTabSwitch(event.tabInfo)
}
} else {
await this.resumeSession()
await this.updateTodayDataRealtime()
}
break
case 'window-blur':
await this.pauseSession()
await this.updateTodayDataRealtime()
break
case 'app-quit':
await this.endSession()
break
case 'tab-switch':
case 'tab-open':
if (!this.currentSession) {
await this.startSession()
}
if (event.tabInfo) {
await this.handleTabSwitch(event.tabInfo)
}
await this.updateTodayDataRealtime()
break
case 'tab-close':
await this.updateCurrentTabDuration()
await this.updateTodayDataRealtime()
break
case 'heartbeat':
if (this.currentSession && !this.isPaused) {
this.heartbeatService.updateHeartbeat()
await this.updateCurrentTabDuration()
await this.saveCurrentState()
await this.updateTodayDataRealtime()
}
break
}
}
async getDayData(year: number, month: number, day: number): Promise<DayTimeData> {
return this.persistence.getDayData(year, month, day)
}
async getWeekData(startDate: Date): Promise<DayTimeData[]> {
const result: DayTimeData[] = []
for (let i = 0; i < 7; i++) {
const date = new Date(startDate)
date.setDate(date.getDate() + i)
const data = await this.persistence.getDayData(date.getFullYear(), date.getMonth() + 1, date.getDate())
result.push(data)
}
return result
}
async getMonthData(year: number, month: number): Promise<MonthTimeData> {
return this.persistence.getMonthData(year, month)
}
async getYearData(year: number): Promise<YearTimeData> {
return this.persistence.getYearData(year)
}
getCurrentState(): { isRunning: boolean; isPaused: boolean; currentSession: TimingSession | null; todayDuration: number; currentTabRecord: TabRecord | null } {
return {
isRunning: this.currentSession !== null,
isPaused: this.isPaused,
currentSession: this.currentSession,
todayDuration: this.todayDuration,
currentTabRecord: this.currentTabRecord
}
}
async getStats(year?: number, month?: number): Promise<{
totalDuration: number
activeDays: number
averageDaily: number
longestDay: { date: string; duration: number } | null
longestSession: { date: string; duration: number } | null
topTabs: Array<{ fileName: string; duration: number; percentage: number }>
tabTypeDistribution: Array<{ tabType: TabType; duration: number; percentage: number }>
}> {
const now = new Date()
const targetYear = year || now.getFullYear()
const targetMonth = month
let totalDuration = 0
let activeDays = 0
let longestDay: { date: string; duration: number } | null = null
let longestSession: { date: string; duration: number } | null = null
const tabDurations: Record<string, number> = {}
const tabTypeDurations: Record<TabType, number> = {} as Record<TabType, number>
if (targetMonth) {
const monthData = await this.persistence.getMonthData(targetYear, targetMonth)
totalDuration = monthData.monthlyTotal
activeDays = monthData.activeDays
for (const [day, summary] of Object.entries(monthData.days)) {
if (!longestDay || summary.totalDuration > longestDay.duration) {
longestDay = { date: `${targetYear}-${targetMonth.toString().padStart(2, '0')}-${day}`, duration: summary.totalDuration }
}
}
} else {
const yearData = await this.persistence.getYearData(targetYear)
totalDuration = yearData.yearlyTotal
activeDays = yearData.totalActiveDays
for (const [month, summary] of Object.entries(yearData.months)) {
if (!longestDay || summary.totalDuration > longestDay.duration) {
longestDay = { date: `${targetYear}-${month}`, duration: summary.totalDuration }
}
}
}
return {
totalDuration,
activeDays,
averageDaily: activeDays > 0 ? Math.floor(totalDuration / activeDays) : 0,
longestDay,
longestSession,
topTabs: Object.entries(tabDurations)
.map(([fileName, duration]) => ({
fileName,
duration,
percentage: totalDuration > 0 ? Math.round((duration / totalDuration) * 100) : 0
}))
.sort((a, b) => b.duration - a.duration)
.slice(0, 10),
tabTypeDistribution: Object.entries(tabTypeDurations)
.map(([tabType, duration]) => ({
tabType: tabType as TabType,
duration,
percentage: totalDuration > 0 ? Math.round((duration / totalDuration) * 100) : 0
}))
.sort((a, b) => b.duration - a.duration)
}
}
}
let _timeTrackerService: TimeTrackerService | null = null
export const getTimeTrackerService = (): TimeTrackerService => {
if (!_timeTrackerService) {
throw new Error('TimeTrackerService 未初始化,请先调用 initializeTimeTrackerService()')
}
return _timeTrackerService
}
export const initializeTimeTrackerService = async (
config?: TimeTrackerServiceConfig
): Promise<TimeTrackerService> => {
if (_timeTrackerService) {
return _timeTrackerService
}
_timeTrackerService = await TimeTrackerService.create(config)
return _timeTrackerService
}
export const initializeTimeTrackerServiceWithDependencies = async (
dependencies: TimeTrackerServiceDependencies
): Promise<TimeTrackerService> => {
if (_timeTrackerService) {
return _timeTrackerService
}
_timeTrackerService = await TimeTrackerService.createWithDependencies(dependencies)
return _timeTrackerService
}
export { TimeTrackerService }

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'