Initial commit
This commit is contained in:
101
api/app.ts
Normal file
101
api/app.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* API 服务器
|
||||
*/
|
||||
|
||||
import express, {
|
||||
type Request,
|
||||
type Response,
|
||||
type NextFunction,
|
||||
} from 'express'
|
||||
import cors from 'cors'
|
||||
import dotenv from 'dotenv'
|
||||
import filesRoutes from './core/files/routes.js'
|
||||
import eventsRoutes from './core/events/routes.js'
|
||||
import settingsRoutes from './core/settings/routes.js'
|
||||
import uploadRoutes from './core/upload/routes.js'
|
||||
import searchRoutes from './core/search/routes.js'
|
||||
import type { ApiResponse } from '../shared/types.js'
|
||||
import { errorHandler } from './middlewares/errorHandler.js'
|
||||
import { NOTEBOOK_ROOT } from './config/paths.js'
|
||||
import { ModuleManager } from './infra/moduleManager.js'
|
||||
import { ServiceContainer } from './infra/container.js'
|
||||
import { apiModules } from './modules/index.js'
|
||||
import { validateModuleConsistency } from './infra/moduleValidator.js'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
const app: express.Application = express()
|
||||
export const container = new ServiceContainer()
|
||||
export const moduleManager = new ModuleManager(container)
|
||||
|
||||
app.use(cors())
|
||||
app.use(express.json({ limit: '200mb' }))
|
||||
app.use(express.urlencoded({ extended: true, limit: '200mb' }))
|
||||
|
||||
/**
|
||||
* Core Routes
|
||||
*/
|
||||
app.use('/api/files', filesRoutes)
|
||||
app.use('/api/events', eventsRoutes)
|
||||
app.use('/api/settings', settingsRoutes)
|
||||
app.use('/api/upload', uploadRoutes)
|
||||
app.use('/api/search', searchRoutes)
|
||||
|
||||
/**
|
||||
* Module Routes (loaded dynamically via ModuleManager)
|
||||
*/
|
||||
for (const module of apiModules) {
|
||||
await moduleManager.register(module)
|
||||
}
|
||||
|
||||
await validateModuleConsistency(apiModules)
|
||||
|
||||
for (const module of moduleManager.getAllModules()) {
|
||||
await moduleManager.activate(module.metadata.id)
|
||||
const router = await module.createRouter(container)
|
||||
app.use('/api' + module.metadata.basePath, router)
|
||||
}
|
||||
|
||||
app.get('/background.png', (req, res, next) => {
|
||||
const customBgPath = path.join(NOTEBOOK_ROOT, '.config', 'background.png')
|
||||
if (fs.existsSync(customBgPath)) {
|
||||
res.sendFile(customBgPath)
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* health
|
||||
*/
|
||||
app.use(
|
||||
'/api/health',
|
||||
(_req: Request, res: Response): void => {
|
||||
const response: ApiResponse<{ message: string }> = {
|
||||
success: true,
|
||||
data: { message: 'ok' },
|
||||
}
|
||||
res.status(200).json(response)
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 404 handler
|
||||
*/
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.path.startsWith('/api')) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
error: { code: 'NOT_FOUND', message: 'API不存在' },
|
||||
}
|
||||
res.status(404).json(response)
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
app.use(errorHandler)
|
||||
|
||||
export default app
|
||||
47
api/config/index.ts
Normal file
47
api/config/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import os from 'os'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
export const config = {
|
||||
get projectRoot(): string {
|
||||
if (__dirname.includes('app.asar')) {
|
||||
return path.resolve(__dirname, '..').replace('app.asar', 'app.asar.unpacked')
|
||||
}
|
||||
return path.resolve(__dirname, '../../')
|
||||
},
|
||||
|
||||
get notebookRoot(): string {
|
||||
return process.env.NOTEBOOK_ROOT
|
||||
? path.resolve(process.env.NOTEBOOK_ROOT)
|
||||
: path.join(this.projectRoot, 'notebook')
|
||||
},
|
||||
|
||||
get tempRoot(): string {
|
||||
return path.join(os.tmpdir(), 'xcnote_uploads')
|
||||
},
|
||||
|
||||
get serverPort(): number {
|
||||
return parseInt(process.env.PORT || '3001', 10)
|
||||
},
|
||||
|
||||
get isVercel(): boolean {
|
||||
return !!process.env.VERCEL
|
||||
},
|
||||
|
||||
get isElectron(): boolean {
|
||||
return __dirname.includes('app.asar')
|
||||
},
|
||||
|
||||
get isDev(): boolean {
|
||||
return !this.isElectron && !this.isVercel
|
||||
},
|
||||
}
|
||||
|
||||
export const PATHS = {
|
||||
get PROJECT_ROOT() { return config.projectRoot },
|
||||
get NOTEBOOK_ROOT() { return config.notebookRoot },
|
||||
get TEMP_ROOT() { return config.tempRoot },
|
||||
}
|
||||
7
api/config/paths.ts
Normal file
7
api/config/paths.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { PATHS, config } from './index.js'
|
||||
|
||||
export { PATHS, config }
|
||||
|
||||
export const PROJECT_ROOT = PATHS.PROJECT_ROOT
|
||||
export const NOTEBOOK_ROOT = PATHS.NOTEBOOK_ROOT
|
||||
export const TEMP_ROOT = PATHS.TEMP_ROOT
|
||||
0
api/core/.gitkeep
Normal file
0
api/core/.gitkeep
Normal file
32
api/core/events/routes.ts
Normal file
32
api/core/events/routes.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import express, { Request, Response } from 'express'
|
||||
import type { ApiResponse } from '../../../shared/types.js'
|
||||
import { eventBus } from '../../events/eventBus.js'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.get('/', (req: Request, res: Response) => {
|
||||
if (process.env.VERCEL) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
error: { code: 'SSE_UNSUPPORTED', message: 'SSE在无服务器运行时中不受支持' },
|
||||
}
|
||||
return res.status(501).json(response)
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Connection': 'keep-alive',
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
res.writeHead(200, headers)
|
||||
|
||||
res.write(`data: ${JSON.stringify({ event: 'connected' })}\n\n`)
|
||||
|
||||
eventBus.addClient(res)
|
||||
|
||||
req.on('close', () => {
|
||||
eventBus.removeClient(res)
|
||||
})
|
||||
})
|
||||
|
||||
export default router
|
||||
280
api/core/files/routes.ts
Normal file
280
api/core/files/routes.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
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 type { FileItemDTO, PathExistsDTO } from '../../../shared/types.js'
|
||||
import { toPosixPath } from '../../../shared/utils/path.js'
|
||||
import { pad2 } from '../../../shared/utils/date.js'
|
||||
import { validateBody, validateQuery } from '../../middlewares/validate.js'
|
||||
import {
|
||||
listFilesQuerySchema,
|
||||
contentQuerySchema,
|
||||
rawQuerySchema,
|
||||
saveFileSchema,
|
||||
pathSchema,
|
||||
renameSchema,
|
||||
createDirSchema,
|
||||
createFileSchema,
|
||||
} from '../../schemas/index.js'
|
||||
import {
|
||||
NotFoundError,
|
||||
NotADirectoryError,
|
||||
BadRequestError,
|
||||
AlreadyExistsError,
|
||||
ForbiddenError,
|
||||
ResourceLockedError,
|
||||
InternalError,
|
||||
isNodeError,
|
||||
} from '../../../shared/errors/index.js'
|
||||
import { logger } from '../../utils/logger.js'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
validateQuery(listFilesQuerySchema),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const relPath = req.query.path as string
|
||||
const { safeRelPath, fullPath } = resolveNotebookPath(relPath)
|
||||
|
||||
try {
|
||||
await fs.access(fullPath)
|
||||
} catch {
|
||||
throw new NotFoundError('路径不存在')
|
||||
}
|
||||
|
||||
const stats = await fs.stat(fullPath)
|
||||
if (!stats.isDirectory()) {
|
||||
throw new NotADirectoryError()
|
||||
}
|
||||
|
||||
const files = await fs.readdir(fullPath)
|
||||
const items = await Promise.all(
|
||||
files.map(async (name): Promise<FileItemDTO | null> => {
|
||||
const filePath = path.join(fullPath, name)
|
||||
try {
|
||||
const fileStats = await fs.stat(filePath)
|
||||
return {
|
||||
name,
|
||||
type: fileStats.isDirectory() ? 'dir' : 'file',
|
||||
size: fileStats.size,
|
||||
modified: fileStats.mtime.toISOString(),
|
||||
path: toPosixPath(path.join(safeRelPath, name)),
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const visibleItems = items.filter((i): i is FileItemDTO => i !== null && !i.name.startsWith('.'))
|
||||
visibleItems.sort((a, b) => {
|
||||
if (a.type === b.type) return a.name.localeCompare(b.name)
|
||||
return a.type === 'dir' ? -1 : 1
|
||||
})
|
||||
|
||||
successResponse(res, { items: visibleItems })
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/content',
|
||||
validateQuery(contentQuerySchema),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const relPath = req.query.path as string
|
||||
const { fullPath } = resolveNotebookPath(relPath)
|
||||
const stats = await fs.stat(fullPath).catch(() => {
|
||||
throw new NotFoundError('文件不存在')
|
||||
})
|
||||
|
||||
if (!stats.isFile()) throw new BadRequestError('不是文件')
|
||||
|
||||
const content = await fs.readFile(fullPath, 'utf-8')
|
||||
successResponse(res, {
|
||||
content,
|
||||
metadata: {
|
||||
size: stats.size,
|
||||
modified: stats.mtime.toISOString(),
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/raw',
|
||||
validateQuery(rawQuerySchema),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const relPath = req.query.path as string
|
||||
const { fullPath } = resolveNotebookPath(relPath)
|
||||
const stats = await fs.stat(fullPath).catch(() => {
|
||||
throw new NotFoundError('文件不存在')
|
||||
})
|
||||
|
||||
if (!stats.isFile()) throw new BadRequestError('不是文件')
|
||||
|
||||
const ext = path.extname(fullPath).toLowerCase()
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.pdf': 'application/pdf',
|
||||
}
|
||||
|
||||
const mimeType = mimeTypes[ext]
|
||||
if (mimeType) res.setHeader('Content-Type', mimeType)
|
||||
res.sendFile(fullPath)
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/save',
|
||||
validateBody(saveFileSchema),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { path: relPath, content } = req.body
|
||||
const { fullPath } = resolveNotebookPath(relPath)
|
||||
await fs.mkdir(path.dirname(fullPath), { recursive: true })
|
||||
await fs.writeFile(fullPath, content, 'utf-8')
|
||||
|
||||
successResponse(res, null)
|
||||
}),
|
||||
)
|
||||
|
||||
router.delete(
|
||||
'/delete',
|
||||
validateBody(pathSchema),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { path: relPath } = req.body
|
||||
const { fullPath } = resolveNotebookPath(relPath)
|
||||
|
||||
await fs.stat(fullPath).catch(() => {
|
||||
throw new NotFoundError('文件或目录不存在')
|
||||
})
|
||||
|
||||
const { fullPath: rbDir } = resolveNotebookPath('RB')
|
||||
|
||||
await fs.mkdir(rbDir, { recursive: true })
|
||||
|
||||
const originalName = path.basename(fullPath)
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const month = pad2(now.getMonth() + 1)
|
||||
const day = pad2(now.getDate())
|
||||
const timestamp = `${year}${month}${day}`
|
||||
const newName = `${timestamp}_${originalName}`
|
||||
const rbDestPath = path.join(rbDir, newName)
|
||||
|
||||
await fs.rename(fullPath, rbDestPath)
|
||||
|
||||
successResponse(res, null)
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/exists',
|
||||
validateBody(pathSchema),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { path: relPath } = req.body
|
||||
const { fullPath } = resolveNotebookPath(relPath)
|
||||
try {
|
||||
const stats = await fs.stat(fullPath)
|
||||
const type: PathExistsDTO['type'] = stats.isDirectory() ? 'dir' : stats.isFile() ? 'file' : null
|
||||
successResponse<PathExistsDTO>(res, { exists: true, type })
|
||||
} catch (err: unknown) {
|
||||
if (isNodeError(err) && err.code === 'ENOENT') {
|
||||
successResponse<PathExistsDTO>(res, { exists: false, type: null })
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/create/dir',
|
||||
validateBody(createDirSchema),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { path: relPath } = req.body
|
||||
const { fullPath } = resolveNotebookPath(relPath)
|
||||
|
||||
try {
|
||||
await fs.mkdir(fullPath, { recursive: true })
|
||||
} catch (err: unknown) {
|
||||
if (isNodeError(err)) {
|
||||
if (err.code === 'EEXIST') {
|
||||
throw new AlreadyExistsError('路径已存在')
|
||||
}
|
||||
if (err.code === 'EACCES') {
|
||||
throw new ForbiddenError('没有权限创建目录')
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
successResponse(res, null)
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/create/file',
|
||||
validateBody(createFileSchema),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { path: relPath } = req.body
|
||||
const { fullPath } = resolveNotebookPath(relPath)
|
||||
await fs.mkdir(path.dirname(fullPath), { recursive: true })
|
||||
|
||||
try {
|
||||
const fileName = path.basename(relPath, '.md')
|
||||
const content = `# ${fileName}`
|
||||
await fs.writeFile(fullPath, content, { encoding: 'utf-8', flag: 'wx' })
|
||||
} catch (err: unknown) {
|
||||
if (isNodeError(err)) {
|
||||
if (err.code === 'EEXIST') throw new AlreadyExistsError('路径已存在')
|
||||
if (err.code === 'EACCES') throw new ForbiddenError('没有权限创建文件')
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
successResponse(res, null)
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/rename',
|
||||
validateBody(renameSchema),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { oldPath, newPath } = req.body
|
||||
const { fullPath: oldFullPath } = resolveNotebookPath(oldPath)
|
||||
const { fullPath: newFullPath } = resolveNotebookPath(newPath)
|
||||
|
||||
await fs.mkdir(path.dirname(newFullPath), { recursive: true })
|
||||
|
||||
try {
|
||||
await fs.rename(oldFullPath, newFullPath)
|
||||
} catch (err: unknown) {
|
||||
if (isNodeError(err)) {
|
||||
if (err.code === 'ENOENT') {
|
||||
throw new NotFoundError('文件不存在')
|
||||
}
|
||||
if (err.code === 'EEXIST') {
|
||||
throw new AlreadyExistsError('路径已存在')
|
||||
}
|
||||
if (err.code === 'EPERM' || err.code === 'EACCES') {
|
||||
throw new ForbiddenError('没有权限重命名文件或目录')
|
||||
}
|
||||
if (err.code === 'EBUSY') {
|
||||
throw new ResourceLockedError('文件或目录正在使用中或被锁定')
|
||||
}
|
||||
}
|
||||
logger.error('重命名错误:', err)
|
||||
throw new InternalError('重命名文件或目录失败')
|
||||
}
|
||||
|
||||
successResponse(res, null)
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
97
api/core/search/routes.ts
Normal file
97
api/core/search/routes.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
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 type { FileItemDTO } from '../../../shared/types.js'
|
||||
import { toPosixPath } from '../../../shared/utils/path.js'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { keywords } = req.body as { keywords?: string[] }
|
||||
if (!keywords || !Array.isArray(keywords) || keywords.length === 0) {
|
||||
successResponse(res, { items: [] })
|
||||
return
|
||||
}
|
||||
|
||||
const searchTerms = keywords.map(k => k.trim().toLowerCase()).filter(k => k.length > 0)
|
||||
|
||||
if (searchTerms.length === 0) {
|
||||
successResponse(res, { items: [] })
|
||||
return
|
||||
}
|
||||
|
||||
const { fullPath: rootPath } = resolveNotebookPath('')
|
||||
const results: FileItemDTO[] = []
|
||||
const maxResults = 100
|
||||
|
||||
const searchDir = async (dir: string, relativeDir: string) => {
|
||||
if (results.length >= maxResults) return
|
||||
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
if (results.length >= maxResults) break
|
||||
|
||||
const entryPath = path.join(dir, entry.name)
|
||||
const entryRelativePath = path.join(relativeDir, entry.name)
|
||||
|
||||
if (entry.name.startsWith('.') || entry.name === 'RB' || entry.name === 'node_modules') continue
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await searchDir(entryPath, entryRelativePath)
|
||||
} else if (entry.isFile()) {
|
||||
const fileNameLower = entry.name.toLowerCase()
|
||||
let contentLower = ''
|
||||
let contentLoaded = false
|
||||
|
||||
const checkKeyword = async (term: string) => {
|
||||
if (fileNameLower.includes(term)) return true
|
||||
|
||||
if (entry.name.toLowerCase().endsWith('.md')) {
|
||||
if (!contentLoaded) {
|
||||
try {
|
||||
const content = await fs.readFile(entryPath, 'utf-8')
|
||||
contentLower = content.toLowerCase()
|
||||
contentLoaded = true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return contentLower.includes(term)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
let allMatched = true
|
||||
for (const term of searchTerms) {
|
||||
const matched = await checkKeyword(term)
|
||||
if (!matched) {
|
||||
allMatched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (allMatched) {
|
||||
results.push({
|
||||
name: entry.name,
|
||||
path: toPosixPath(entryRelativePath),
|
||||
type: 'file',
|
||||
size: 0,
|
||||
modified: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await searchDir(rootPath, '')
|
||||
successResponse(res, { items: results, limited: results.length >= maxResults })
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
54
api/core/settings/routes.ts
Normal file
54
api/core/settings/routes.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
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 { NOTEBOOK_ROOT } from '../../config/paths.js'
|
||||
import type { SettingsDTO } from '../../../shared/types.js'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
const getSettingsPath = () => path.join(NOTEBOOK_ROOT, '.config', 'settings.json')
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const settingsPath = getSettingsPath()
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf-8')
|
||||
const settings = JSON.parse(content)
|
||||
successResponse(res, settings)
|
||||
} catch (error) {
|
||||
successResponse(res, {})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const settings = req.body as SettingsDTO
|
||||
const settingsPath = getSettingsPath()
|
||||
const configDir = path.dirname(settingsPath)
|
||||
|
||||
try {
|
||||
await fs.mkdir(configDir, { recursive: true })
|
||||
|
||||
let existingSettings = {}
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf-8')
|
||||
existingSettings = JSON.parse(content)
|
||||
} catch {
|
||||
}
|
||||
|
||||
const newSettings = { ...existingSettings, ...settings }
|
||||
await fs.writeFile(settingsPath, JSON.stringify(newSettings, null, 2), 'utf-8')
|
||||
|
||||
successResponse(res, newSettings)
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
100
api/core/upload/routes.ts
Normal file
100
api/core/upload/routes.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
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 { getUniqueFilename, mimeToExt, validateImageBuffer, detectImageMimeType } from '../../utils/file.js'
|
||||
import { NOTEBOOK_ROOT } from '../../config/paths.js'
|
||||
import { toPosixPath } from '../../../shared/utils/path.js'
|
||||
import { pad2, formatTimestamp } from '../../../shared/utils/date.js'
|
||||
import { ValidationError, UnsupportedMediaTypeError } from '../../../shared/errors/index.js'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
const parseImageDataUrl = (dataUrl: string): { mimeType: string; base64Data: string } | null => {
|
||||
const match = dataUrl.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+)$/)
|
||||
if (!match) return null
|
||||
const [, mimeType, base64Data] = match
|
||||
return { mimeType, base64Data: base64Data.replace(/\s/g, '') }
|
||||
}
|
||||
|
||||
router.post(
|
||||
'/image',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { image } = req.body as { image?: string }
|
||||
if (!image) throw new ValidationError('需要图片数据')
|
||||
|
||||
const parsed = parseImageDataUrl(image)
|
||||
if (!parsed) {
|
||||
throw new ValidationError('无效的图片数据URL')
|
||||
}
|
||||
|
||||
const ext = mimeToExt[parsed.mimeType]
|
||||
if (!ext) {
|
||||
throw new UnsupportedMediaTypeError('不支持的图片类型')
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(parsed.base64Data, 'base64')
|
||||
|
||||
validateImageBuffer(buffer, parsed.mimeType)
|
||||
|
||||
const detectedMimeType = detectImageMimeType(buffer)
|
||||
if (!detectedMimeType || detectedMimeType !== parsed.mimeType) {
|
||||
throw new ValidationError('图片内容类型不匹配或图片已损坏')
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const month = pad2(now.getMonth() + 1)
|
||||
const day = pad2(now.getDate())
|
||||
|
||||
const imagesSubDir = `images/${year}/${month}/${day}`
|
||||
const { fullPath: imagesDirFullPath } = resolveNotebookPath(imagesSubDir)
|
||||
await fs.mkdir(imagesDirFullPath, { recursive: true })
|
||||
|
||||
const baseName = formatTimestamp(now)
|
||||
const filename = await getUniqueFilename(imagesDirFullPath, baseName, ext)
|
||||
const relPath = `${imagesSubDir}/${filename}`
|
||||
const { fullPath } = resolveNotebookPath(relPath)
|
||||
|
||||
await fs.writeFile(fullPath, buffer)
|
||||
successResponse(res, { name: toPosixPath(relPath), path: toPosixPath(relPath) })
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/wallpaper',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { image } = req.body as { image?: string }
|
||||
if (!image) throw new ValidationError('需要图片数据')
|
||||
|
||||
const parsed = parseImageDataUrl(image)
|
||||
if (!parsed) {
|
||||
throw new ValidationError('无效的图片数据URL')
|
||||
}
|
||||
|
||||
const allowedWallpaperTypes = ['image/png', 'image/jpeg', 'image/webp']
|
||||
if (!allowedWallpaperTypes.includes(parsed.mimeType)) {
|
||||
throw new UnsupportedMediaTypeError('壁纸只支持PNG、JPEG和WebP格式')
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(parsed.base64Data, 'base64')
|
||||
|
||||
validateImageBuffer(buffer, parsed.mimeType)
|
||||
|
||||
const detectedMimeType = detectImageMimeType(buffer)
|
||||
if (!detectedMimeType || detectedMimeType !== parsed.mimeType) {
|
||||
throw new ValidationError('图片内容类型不匹配或图片已损坏')
|
||||
}
|
||||
|
||||
const configDir = path.join(NOTEBOOK_ROOT, '.config')
|
||||
const backgroundPath = path.join(configDir, 'background.png')
|
||||
|
||||
await fs.mkdir(configDir, { recursive: true })
|
||||
await fs.writeFile(backgroundPath, buffer)
|
||||
successResponse(res, { message: '壁纸已更新' })
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
1
api/errors/errorCodes.ts
Normal file
1
api/errors/errorCodes.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ERROR_CODES as ErrorCodes, type ErrorCode } from '../../shared/constants/errors.js'
|
||||
35
api/events/eventBus.ts
Normal file
35
api/events/eventBus.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Response } from 'express'
|
||||
import { logger } from '../utils/logger.js'
|
||||
|
||||
type NotebookEvent = {
|
||||
event: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
let clients: Response[] = []
|
||||
|
||||
export const eventBus = {
|
||||
addClient: (res: Response) => {
|
||||
clients.push(res)
|
||||
logger.info(`SSE client connected. Total clients: ${clients.length}`)
|
||||
},
|
||||
removeClient: (res: Response) => {
|
||||
clients = clients.filter((c) => c !== res)
|
||||
logger.info(`SSE client disconnected. Total clients: ${clients.length}`)
|
||||
},
|
||||
broadcast: (payload: NotebookEvent) => {
|
||||
const data = `data: ${JSON.stringify(payload)}
|
||||
|
||||
`
|
||||
logger.info(`Broadcasting to ${clients.length} clients: ${payload.event} - ${payload.path || ''}`)
|
||||
clients = clients.filter((client) => {
|
||||
try {
|
||||
client.write(data)
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.warn('SSE client write failed, removing')
|
||||
return false
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
9
api/index.ts
Normal file
9
api/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Vercel deploy entry handler, for serverless deployment, please don't modify this file
|
||||
*/
|
||||
import type { VercelRequest, VercelResponse } from '@vercel/node';
|
||||
import app from './app.js';
|
||||
|
||||
export default function handler(req: VercelRequest, res: VercelResponse) {
|
||||
return app(req, res);
|
||||
}
|
||||
242
api/infra/container.ts
Normal file
242
api/infra/container.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
export enum ServiceLifetime {
|
||||
Singleton = 'singleton',
|
||||
Transient = 'transient',
|
||||
Scoped = 'scoped'
|
||||
}
|
||||
|
||||
export interface ServiceDescriptor<T = any> {
|
||||
name: string
|
||||
factory: () => T | Promise<T>
|
||||
lifetime: ServiceLifetime
|
||||
dependencies?: string[]
|
||||
onDispose?: (instance: T) => void | Promise<void>
|
||||
}
|
||||
|
||||
export type ServiceFactory<T> = () => T | Promise<T>
|
||||
|
||||
export class ServiceContainer {
|
||||
private descriptors = new Map<string, ServiceDescriptor>()
|
||||
private singletonInstances = new Map<string, unknown>()
|
||||
private scopedInstances = new Map<string, Map<string, unknown>>()
|
||||
private resolutionStack: string[] = []
|
||||
private disposed = false
|
||||
|
||||
register<T>(name: string, factory: ServiceFactory<T>): void
|
||||
register<T>(descriptor: ServiceDescriptor<T>): void
|
||||
register<T>(nameOrDescriptor: string | ServiceDescriptor<T>, factory?: ServiceFactory<T>): void {
|
||||
this.ensureNotDisposed()
|
||||
|
||||
if (typeof nameOrDescriptor === 'string') {
|
||||
const descriptor: ServiceDescriptor<T> = {
|
||||
name: nameOrDescriptor,
|
||||
factory: factory!,
|
||||
lifetime: ServiceLifetime.Singleton
|
||||
}
|
||||
this.descriptors.set(nameOrDescriptor, descriptor)
|
||||
} else {
|
||||
this.descriptors.set(nameOrDescriptor.name, nameOrDescriptor)
|
||||
}
|
||||
}
|
||||
|
||||
async get<T>(name: string): Promise<T> {
|
||||
this.ensureNotDisposed()
|
||||
return this.resolveInternal<T>(name, null)
|
||||
}
|
||||
|
||||
getSync<T>(name: string): T {
|
||||
this.ensureNotDisposed()
|
||||
|
||||
const descriptor = this.descriptors.get(name)
|
||||
if (!descriptor) {
|
||||
throw new Error(`Service '${name}' not registered`)
|
||||
}
|
||||
|
||||
if (descriptor.lifetime === ServiceLifetime.Singleton) {
|
||||
if (this.singletonInstances.has(name)) {
|
||||
return this.singletonInstances.get(name) as T
|
||||
}
|
||||
}
|
||||
|
||||
const result = descriptor.factory()
|
||||
if (result instanceof Promise) {
|
||||
throw new Error(
|
||||
`Service '${name}' has an async factory but getSync() was called. Use get() instead.`
|
||||
)
|
||||
}
|
||||
|
||||
if (descriptor.lifetime === ServiceLifetime.Singleton) {
|
||||
this.singletonInstances.set(name, result)
|
||||
}
|
||||
|
||||
return result as T
|
||||
}
|
||||
|
||||
createScope(scopeId: string): ServiceScope {
|
||||
this.ensureNotDisposed()
|
||||
return new ServiceScope(this, scopeId)
|
||||
}
|
||||
|
||||
has(name: string): boolean {
|
||||
return this.descriptors.has(name)
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const [name, instance] of this.singletonInstances) {
|
||||
const descriptor = this.descriptors.get(name)
|
||||
if (descriptor?.onDispose) {
|
||||
try {
|
||||
await descriptor.onDispose(instance)
|
||||
} catch (error) {
|
||||
console.error(`Error disposing service '${name}':`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [, scopeMap] of this.scopedInstances) {
|
||||
for (const [name, instance] of scopeMap) {
|
||||
const descriptor = this.descriptors.get(name)
|
||||
if (descriptor?.onDispose) {
|
||||
try {
|
||||
await descriptor.onDispose(instance)
|
||||
} catch (error) {
|
||||
console.error(`Error disposing scoped service '${name}':`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.singletonInstances.clear()
|
||||
this.scopedInstances.clear()
|
||||
this.descriptors.clear()
|
||||
this.resolutionStack = []
|
||||
this.disposed = true
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.singletonInstances.clear()
|
||||
this.scopedInstances.clear()
|
||||
this.descriptors.clear()
|
||||
this.resolutionStack = []
|
||||
}
|
||||
|
||||
isDisposed(): boolean {
|
||||
return this.disposed
|
||||
}
|
||||
|
||||
private async resolveInternal<T>(name: string, scopeId: string | null): Promise<T> {
|
||||
if (this.resolutionStack.includes(name)) {
|
||||
const cycle = [...this.resolutionStack, name].join(' -> ')
|
||||
throw new Error(`Circular dependency detected: ${cycle}`)
|
||||
}
|
||||
|
||||
const descriptor = this.descriptors.get(name)
|
||||
if (!descriptor) {
|
||||
throw new Error(`Service '${name}' not registered`)
|
||||
}
|
||||
|
||||
if (descriptor.lifetime === ServiceLifetime.Singleton) {
|
||||
if (this.singletonInstances.has(name)) {
|
||||
return this.singletonInstances.get(name) as T
|
||||
}
|
||||
|
||||
this.resolutionStack.push(name)
|
||||
try {
|
||||
const instance = await descriptor.factory()
|
||||
this.singletonInstances.set(name, instance)
|
||||
return instance as T
|
||||
} finally {
|
||||
this.resolutionStack.pop()
|
||||
}
|
||||
}
|
||||
|
||||
if (descriptor.lifetime === ServiceLifetime.Scoped) {
|
||||
if (!scopeId) {
|
||||
throw new Error(
|
||||
`Scoped service '${name}' cannot be resolved outside of a scope. Use createScope() first.`
|
||||
)
|
||||
}
|
||||
|
||||
let scopeMap = this.scopedInstances.get(scopeId)
|
||||
if (!scopeMap) {
|
||||
scopeMap = new Map()
|
||||
this.scopedInstances.set(scopeId, scopeMap)
|
||||
}
|
||||
|
||||
if (scopeMap.has(name)) {
|
||||
return scopeMap.get(name) as T
|
||||
}
|
||||
|
||||
this.resolutionStack.push(name)
|
||||
try {
|
||||
const instance = await descriptor.factory()
|
||||
scopeMap.set(name, instance)
|
||||
return instance as T
|
||||
} finally {
|
||||
this.resolutionStack.pop()
|
||||
}
|
||||
}
|
||||
|
||||
this.resolutionStack.push(name)
|
||||
try {
|
||||
const instance = await descriptor.factory()
|
||||
return instance as T
|
||||
} finally {
|
||||
this.resolutionStack.pop()
|
||||
}
|
||||
}
|
||||
|
||||
private ensureNotDisposed(): void {
|
||||
if (this.disposed) {
|
||||
throw new Error('ServiceContainer has been disposed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ServiceScope {
|
||||
private container: ServiceContainer
|
||||
private scopeId: string
|
||||
private disposed = false
|
||||
|
||||
constructor(container: ServiceContainer, scopeId: string) {
|
||||
this.container = container
|
||||
this.scopeId = scopeId
|
||||
}
|
||||
|
||||
async get<T>(name: string): Promise<T> {
|
||||
if (this.disposed) {
|
||||
throw new Error('ServiceScope has been disposed')
|
||||
}
|
||||
return this.container['resolveInternal']<T>(name, this.scopeId)
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
const scopeMap = this.container['scopedInstances'].get(this.scopeId)
|
||||
if (scopeMap) {
|
||||
for (const [name, instance] of scopeMap) {
|
||||
const descriptor = this.container['descriptors'].get(name)
|
||||
if (descriptor?.onDispose) {
|
||||
try {
|
||||
await descriptor.onDispose(instance)
|
||||
} catch (error) {
|
||||
console.error(`Error disposing scoped service '${name}':`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
this.container['scopedInstances'].delete(this.scopeId)
|
||||
}
|
||||
|
||||
this.disposed = true
|
||||
}
|
||||
|
||||
isDisposed(): boolean {
|
||||
return this.disposed
|
||||
}
|
||||
}
|
||||
41
api/infra/createModule.ts
Normal file
41
api/infra/createModule.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Router } from 'express'
|
||||
import type { ServiceContainer } from './container.js'
|
||||
import type { ApiModule, ModuleMetadata, ModuleLifecycle } from './types.js'
|
||||
import type { ApiModuleConfig, ModuleEndpoints } from '../../shared/modules/types.js'
|
||||
|
||||
export interface CreateModuleOptions<TEndpoints extends ModuleEndpoints> {
|
||||
routes: (container: ServiceContainer) => Router | Promise<Router>
|
||||
lifecycle?: ModuleLifecycle
|
||||
services?: (container: ServiceContainer) => void | Promise<void>
|
||||
}
|
||||
|
||||
export function createApiModule<
|
||||
TId extends string,
|
||||
TEndpoints extends ModuleEndpoints
|
||||
>(
|
||||
config: ApiModuleConfig<TId, TEndpoints>,
|
||||
options: CreateModuleOptions<TEndpoints>
|
||||
): ApiModule {
|
||||
const metadata: ModuleMetadata = {
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
version: config.version,
|
||||
basePath: config.basePath,
|
||||
order: config.order,
|
||||
dependencies: config.dependencies,
|
||||
}
|
||||
|
||||
const lifecycle: ModuleLifecycle | undefined = options.lifecycle
|
||||
? options.lifecycle
|
||||
: options.services
|
||||
? {
|
||||
onLoad: options.services,
|
||||
}
|
||||
: undefined
|
||||
|
||||
return {
|
||||
metadata,
|
||||
lifecycle,
|
||||
createRouter: options.routes,
|
||||
}
|
||||
}
|
||||
5
api/infra/index.ts
Normal file
5
api/infra/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { ServiceContainer } from './container.js'
|
||||
export type { ServiceFactory } from './container.js'
|
||||
export { loadModules } from './moduleLoader.js'
|
||||
export { ModuleManager } from './moduleManager.js'
|
||||
export type { ApiModule, ModuleMetadata, ModuleLifecycle } from './types.js'
|
||||
24
api/infra/moduleLoader.ts
Normal file
24
api/infra/moduleLoader.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Application } from 'express'
|
||||
import type { ServiceContainer } from './container.js'
|
||||
import type { ApiModule } from './types.js'
|
||||
import { ModuleManager } from './moduleManager.js'
|
||||
|
||||
export async function loadModules(
|
||||
app: Application,
|
||||
container: ServiceContainer,
|
||||
modules: ApiModule[]
|
||||
): Promise<ModuleManager> {
|
||||
const manager = new ModuleManager(container)
|
||||
|
||||
for (const module of modules) {
|
||||
await manager.register(module)
|
||||
}
|
||||
|
||||
for (const module of manager.getAllModules()) {
|
||||
await manager.activate(module.metadata.id)
|
||||
const router = await module.createRouter(container)
|
||||
app.use('/api' + module.metadata.basePath, router)
|
||||
}
|
||||
|
||||
return manager
|
||||
}
|
||||
76
api/infra/moduleManager.ts
Normal file
76
api/infra/moduleManager.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { ApiModule, ModuleMetadata } from './types.js'
|
||||
import { ServiceContainer } from './container.js'
|
||||
|
||||
export class ModuleManager {
|
||||
private modules = new Map<string, ApiModule>()
|
||||
private activeModules = new Set<string>()
|
||||
private container: ServiceContainer
|
||||
|
||||
constructor(container: ServiceContainer) {
|
||||
this.container = container
|
||||
}
|
||||
|
||||
async register(module: ApiModule): Promise<void> {
|
||||
const { id, dependencies = [] } = module.metadata
|
||||
|
||||
for (const dep of dependencies) {
|
||||
if (!this.modules.has(dep)) {
|
||||
throw new Error(`Module '${id}' depends on '${dep}' which is not registered`)
|
||||
}
|
||||
}
|
||||
|
||||
this.modules.set(id, module)
|
||||
|
||||
if (module.lifecycle?.onLoad) {
|
||||
await module.lifecycle.onLoad(this.container)
|
||||
}
|
||||
}
|
||||
|
||||
async activate(id: string): Promise<void> {
|
||||
const module = this.modules.get(id)
|
||||
if (!module) {
|
||||
throw new Error(`Module '${id}' not found`)
|
||||
}
|
||||
|
||||
if (this.activeModules.has(id)) {
|
||||
return
|
||||
}
|
||||
|
||||
const { dependencies = [] } = module.metadata
|
||||
for (const dep of dependencies) {
|
||||
await this.activate(dep)
|
||||
}
|
||||
|
||||
if (module.lifecycle?.onActivate) {
|
||||
await module.lifecycle.onActivate(this.container)
|
||||
}
|
||||
|
||||
this.activeModules.add(id)
|
||||
}
|
||||
|
||||
async deactivate(id: string): Promise<void> {
|
||||
const module = this.modules.get(id)
|
||||
if (!module) return
|
||||
|
||||
if (!this.activeModules.has(id)) return
|
||||
|
||||
if (module.lifecycle?.onDeactivate) {
|
||||
await module.lifecycle.onDeactivate(this.container)
|
||||
}
|
||||
|
||||
this.activeModules.delete(id)
|
||||
}
|
||||
|
||||
getModule(id: string): ApiModule | undefined {
|
||||
return this.modules.get(id)
|
||||
}
|
||||
|
||||
getAllModules(): ApiModule[] {
|
||||
return Array.from(this.modules.values())
|
||||
.sort((a, b) => (a.metadata.order || 0) - (b.metadata.order || 0))
|
||||
}
|
||||
|
||||
getActiveModules(): string[] {
|
||||
return Array.from(this.activeModules)
|
||||
}
|
||||
}
|
||||
113
api/infra/moduleValidator.ts
Normal file
113
api/infra/moduleValidator.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { readdirSync, statSync, existsSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import type { ApiModule } from './types.js'
|
||||
import type { ModuleDefinition } from '../../shared/modules/types.js'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
function getSharedModulesPath(): string | null {
|
||||
const possiblePaths = [
|
||||
join(__dirname, '../../shared/modules'),
|
||||
join(__dirname, '../../../shared/modules'),
|
||||
join((process as unknown as { resourcesPath?: string })?.resourcesPath || '', 'shared/modules'),
|
||||
]
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
if (existsSync(p)) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export interface SharedModuleDefinition {
|
||||
id: string
|
||||
name: string
|
||||
backend?: {
|
||||
enabled?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
function needsBackendImplementation(moduleDef: SharedModuleDefinition): boolean {
|
||||
return moduleDef.backend?.enabled !== false
|
||||
}
|
||||
|
||||
async function loadModuleDefinitions(): Promise<SharedModuleDefinition[]> {
|
||||
const modules: SharedModuleDefinition[] = []
|
||||
const sharedModulesPath = getSharedModulesPath()
|
||||
|
||||
if (!sharedModulesPath) {
|
||||
return modules
|
||||
}
|
||||
|
||||
const entries = readdirSync(sharedModulesPath)
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(sharedModulesPath, entry)
|
||||
const stat = statSync(entryPath)
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const moduleExports = await import(`../../shared/modules/${entry}/index.js`)
|
||||
|
||||
for (const key of Object.keys(moduleExports)) {
|
||||
if (key.endsWith('_MODULE')) {
|
||||
const moduleDef = moduleExports[key] as ModuleDefinition
|
||||
if (moduleDef && moduleDef.id) {
|
||||
modules.push({
|
||||
id: moduleDef.id,
|
||||
name: moduleDef.name,
|
||||
backend: moduleDef.backend,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 模块加载失败,跳过
|
||||
}
|
||||
}
|
||||
|
||||
return modules
|
||||
}
|
||||
|
||||
export async function validateModuleConsistency(apiModules: ApiModule[]): Promise<void> {
|
||||
const sharedModules = await loadModuleDefinitions()
|
||||
|
||||
if (sharedModules.length === 0) {
|
||||
console.log('[ModuleValidator] Skipping validation (shared modules not found, likely packaged mode)')
|
||||
return
|
||||
}
|
||||
|
||||
const apiModuleIds = new Set(apiModules.map((m) => m.metadata.id))
|
||||
const errors: string[] = []
|
||||
|
||||
for (const sharedModule of sharedModules) {
|
||||
const needsBackend = needsBackendImplementation(sharedModule)
|
||||
const hasApiModule = apiModuleIds.has(sharedModule.id)
|
||||
|
||||
if (needsBackend && !hasApiModule) {
|
||||
errors.push(
|
||||
`Module '${sharedModule.id}' is defined in shared but not registered in API modules`
|
||||
)
|
||||
}
|
||||
|
||||
if (!needsBackend && hasApiModule) {
|
||||
errors.push(
|
||||
`Module '${sharedModule.id}' has backend disabled but is registered in API modules`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Module consistency validation failed:\n - ${errors.join('\n - ')}`)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[ModuleValidator] ✓ Module consistency validated: ${sharedModules.length} shared, ${apiModules.length} API`
|
||||
)
|
||||
}
|
||||
31
api/infra/types.ts
Normal file
31
api/infra/types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Router, Application } from 'express'
|
||||
import type { ServiceContainer } from './container.js'
|
||||
|
||||
export interface ModuleMetadata {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
basePath: string
|
||||
dependencies?: string[]
|
||||
order?: number
|
||||
}
|
||||
|
||||
export interface ModuleLifecycle {
|
||||
onLoad?(container: ServiceContainer): void | Promise<void>
|
||||
onUnload?(container: ServiceContainer): void | Promise<void>
|
||||
onActivate?(container: ServiceContainer): void | Promise<void>
|
||||
onDeactivate?(container: ServiceContainer): void | Promise<void>
|
||||
}
|
||||
|
||||
export interface ApiModule {
|
||||
metadata: ModuleMetadata
|
||||
lifecycle?: ModuleLifecycle
|
||||
createRouter: (container: ServiceContainer) => Router | Promise<Router>
|
||||
}
|
||||
|
||||
export interface LegacyApiModule {
|
||||
name: string
|
||||
basePath: string
|
||||
init?: (app: Application, container: ServiceContainer) => void | Promise<void>
|
||||
createRouter: (container: ServiceContainer) => Router | Promise<Router>
|
||||
}
|
||||
199
api/middlewares/__tests__/errorHandler.test.ts
Normal file
199
api/middlewares/__tests__/errorHandler.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
|
||||
const { mockIsAppError, mockIsNodeError, mockLoggerError, MockAppError, MockValidationError } = vi.hoisted(() => {
|
||||
const mockFn = () => ({})
|
||||
return {
|
||||
mockIsAppError: vi.fn(),
|
||||
mockIsNodeError: vi.fn(),
|
||||
mockLoggerError: vi.fn(),
|
||||
MockAppError: class MockAppError extends Error {
|
||||
statusCode: number
|
||||
details?: Record<string, unknown>
|
||||
code: string
|
||||
constructor(
|
||||
code: string,
|
||||
message: string,
|
||||
statusCode: number = 500,
|
||||
details?: Record<string, unknown>
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'MockAppError'
|
||||
this.code = code
|
||||
this.statusCode = statusCode
|
||||
this.details = details
|
||||
}
|
||||
},
|
||||
MockValidationError: class MockValidationError extends Error {
|
||||
statusCode: number
|
||||
details?: Record<string, unknown>
|
||||
code: string
|
||||
constructor(message: string, details?: Record<string, unknown>) {
|
||||
super(message)
|
||||
this.name = 'MockValidationError'
|
||||
this.code = 'VALIDATION_ERROR'
|
||||
this.statusCode = 400
|
||||
this.details = details
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@shared/errors', () => ({
|
||||
isAppError: mockIsAppError,
|
||||
isNodeError: mockIsNodeError,
|
||||
AppError: MockAppError,
|
||||
ValidationError: MockValidationError,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/utils/logger', () => ({
|
||||
logger: {
|
||||
error: mockLoggerError,
|
||||
},
|
||||
}))
|
||||
|
||||
import { errorHandler } from '../errorHandler'
|
||||
|
||||
describe('errorHandler', () => {
|
||||
let mockReq: Request
|
||||
let mockRes: Response
|
||||
let mockNext: NextFunction
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockReq = {} as Request
|
||||
mockRes = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn().mockReturnThis(),
|
||||
} as unknown as Response
|
||||
mockNext = vi.fn()
|
||||
mockIsAppError.mockReturnValue(false)
|
||||
mockIsNodeError.mockReturnValue(false)
|
||||
})
|
||||
|
||||
describe('AppError 处理', () => {
|
||||
it('AppError 应发送自定义状态码和错误码', () => {
|
||||
const appError = new MockAppError('VALIDATION_ERROR', '验证失败', 400, { field: 'name' })
|
||||
mockIsAppError.mockReturnValue(true)
|
||||
|
||||
errorHandler(appError, mockReq, mockRes, mockNext)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: '验证失败',
|
||||
details: { field: 'name' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('ValidationError 应发送正确的错误信息', () => {
|
||||
const validationError = new MockValidationError('字段不能为空', { field: 'email' })
|
||||
mockIsAppError.mockReturnValue(true)
|
||||
|
||||
errorHandler(validationError, mockReq, mockRes, mockNext)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: '字段不能为空',
|
||||
details: { field: 'email' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('AppError 在生产环境不应包含 details', () => {
|
||||
const originalEnv = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'production'
|
||||
|
||||
try {
|
||||
const appError = new MockAppError('VALIDATION_ERROR', '验证失败', 400, { field: 'name' })
|
||||
mockIsAppError.mockReturnValue(true)
|
||||
|
||||
errorHandler(appError, mockReq, mockRes, mockNext)
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: '验证失败',
|
||||
details: undefined,
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
process.env.NODE_ENV = originalEnv
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node.js 系统错误处理', () => {
|
||||
it('Node.js 系统错误应包含 stack 信息(非生产环境)', () => {
|
||||
const nodeError = new Error('系统错误') as NodeJS.ErrnoException
|
||||
nodeError.code = 'ENOENT'
|
||||
nodeError.stack = 'Error: 系统错误\n at Test.<anonymous>'
|
||||
|
||||
mockIsAppError.mockReturnValue(false)
|
||||
mockIsNodeError.mockReturnValue(true)
|
||||
|
||||
errorHandler(nodeError, mockReq, mockRes, mockNext)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: '系统错误',
|
||||
details: {
|
||||
stack: nodeError.stack,
|
||||
nodeErrorCode: 'ENOENT',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('普通 Error 应包含 stack 信息(非生产环境)', () => {
|
||||
const error = new Error('普通错误')
|
||||
error.stack = 'Error: 普通错误\n at Test.<anonymous>'
|
||||
|
||||
errorHandler(error, mockReq, mockRes, mockNext)
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: '普通错误',
|
||||
details: {
|
||||
stack: error.stack,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('在生产环境不包含敏感信息', () => {
|
||||
const originalEnv = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'production'
|
||||
|
||||
try {
|
||||
const error = new Error('错误信息')
|
||||
error.stack = 'Error: 错误信息\n at Test.<anonymous>'
|
||||
|
||||
errorHandler(error, mockReq, mockRes, mockNext)
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: '错误信息',
|
||||
details: undefined,
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
process.env.NODE_ENV = originalEnv
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
42
api/middlewares/errorHandler.ts
Normal file
42
api/middlewares/errorHandler.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import type { ApiResponse } from '../../shared/types.js'
|
||||
import { ERROR_CODES } from '../../shared/constants/errors.js'
|
||||
import { isAppError, isNodeError } from '../../shared/errors/index.js'
|
||||
import { logger } from '../utils/logger.js'
|
||||
|
||||
export const errorHandler = (err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||
let statusCode: number = 500
|
||||
let code: string = ERROR_CODES.INTERNAL_ERROR
|
||||
let message: string = 'Server internal error'
|
||||
let details: unknown = undefined
|
||||
|
||||
if (isAppError(err)) {
|
||||
statusCode = err.statusCode
|
||||
code = err.code
|
||||
message = err.message
|
||||
details = err.details
|
||||
} else if (isNodeError(err)) {
|
||||
message = err.message
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
details = { stack: err.stack, nodeErrorCode: err.code }
|
||||
}
|
||||
} else if (err instanceof Error) {
|
||||
message = err.message
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
details = { stack: err.stack }
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(err)
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
details: process.env.NODE_ENV === 'production' ? undefined : details,
|
||||
},
|
||||
}
|
||||
|
||||
res.status(statusCode).json(response)
|
||||
}
|
||||
33
api/middlewares/validate.ts
Normal file
33
api/middlewares/validate.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
import { ZodSchema, ZodError } from 'zod'
|
||||
import { ValidationError } from '../../shared/errors/index.js'
|
||||
|
||||
export const validateBody = (schema: ZodSchema) => {
|
||||
return (req: Request, _res: Response, next: NextFunction) => {
|
||||
try {
|
||||
req.body = schema.parse(req.body)
|
||||
next()
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
next(new ValidationError('Request validation failed', { issues: error.issues }))
|
||||
} else {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const validateQuery = (schema: ZodSchema) => {
|
||||
return (req: Request, _res: Response, next: NextFunction) => {
|
||||
try {
|
||||
req.query = schema.parse(req.query) as typeof req.query
|
||||
next()
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
next(new ValidationError('Query validation failed', { issues: error.issues }))
|
||||
} else {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
0
api/modules/.gitkeep
Normal file
0
api/modules/.gitkeep
Normal file
17
api/modules/ai/index.ts
Normal file
17
api/modules/ai/index.ts
Normal 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
112
api/modules/ai/routes.ts
Normal 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
|
||||
217
api/modules/document-parser/blogRoutes.ts
Normal file
217
api/modules/document-parser/blogRoutes.ts
Normal 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
|
||||
184
api/modules/document-parser/documentParser.ts
Normal file
184
api/modules/document-parser/documentParser.ts
Normal 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)
|
||||
}
|
||||
23
api/modules/document-parser/index.ts
Normal file
23
api/modules/document-parser/index.ts
Normal 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
|
||||
158
api/modules/document-parser/mineruRoutes.ts
Normal file
158
api/modules/document-parser/mineruRoutes.ts
Normal 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
68
api/modules/index.ts
Normal 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'
|
||||
17
api/modules/pydemos/index.ts
Normal file
17
api/modules/pydemos/index.ts
Normal 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
|
||||
258
api/modules/pydemos/routes.ts
Normal file
258
api/modules/pydemos/routes.ts
Normal 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()
|
||||
17
api/modules/recycle-bin/index.ts
Normal file
17
api/modules/recycle-bin/index.ts
Normal 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
|
||||
78
api/modules/recycle-bin/recycleBinService.ts
Normal file
78
api/modules/recycle-bin/recycleBinService.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
175
api/modules/recycle-bin/routes.ts
Normal file
175
api/modules/recycle-bin/routes.ts
Normal 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
|
||||
25
api/modules/remote/index.ts
Normal file
25
api/modules/remote/index.ts
Normal 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
|
||||
80
api/modules/remote/routes.ts
Normal file
80
api/modules/remote/routes.ts
Normal 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 })
|
||||
178
api/modules/remote/service.ts
Normal file
178
api/modules/remote/service.ts
Normal 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)
|
||||
}
|
||||
80
api/modules/time-tracking/heartbeatService.ts
Normal file
80
api/modules/time-tracking/heartbeatService.ts
Normal 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)
|
||||
}
|
||||
38
api/modules/time-tracking/index.ts
Normal file
38
api/modules/time-tracking/index.ts
Normal 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
|
||||
131
api/modules/time-tracking/routes.ts
Normal file
131
api/modules/time-tracking/routes.ts
Normal 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
|
||||
}
|
||||
367
api/modules/time-tracking/sessionPersistence.ts
Normal file
367
api/modules/time-tracking/sessionPersistence.ts
Normal 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 }
|
||||
442
api/modules/time-tracking/timeService.ts
Normal file
442
api/modules/time-tracking/timeService.ts
Normal 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 }
|
||||
171
api/modules/todo/__tests__/parser.test.ts
Normal file
171
api/modules/todo/__tests__/parser.test.ts
Normal 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
28
api/modules/todo/index.ts
Normal 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
|
||||
51
api/modules/todo/parser.ts
Normal file
51
api/modules/todo/parser.ts
Normal 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()
|
||||
}
|
||||
99
api/modules/todo/routes.ts
Normal file
99
api/modules/todo/routes.ts
Normal 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 })
|
||||
53
api/modules/todo/schemas.ts
Normal file
53
api/modules/todo/schemas.ts
Normal 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
216
api/modules/todo/service.ts
Normal 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)
|
||||
}
|
||||
7
api/modules/todo/types.ts
Normal file
7
api/modules/todo/types.ts
Normal 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'
|
||||
43
api/schemas/files.ts
Normal file
43
api/schemas/files.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const listFilesQuerySchema = z.object({
|
||||
path: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
export const contentQuerySchema = z.object({
|
||||
path: z.string().min(1),
|
||||
})
|
||||
|
||||
export const rawQuerySchema = z.object({
|
||||
path: z.string().min(1),
|
||||
})
|
||||
|
||||
export const pathSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
})
|
||||
|
||||
export const saveFileSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
content: z.string(),
|
||||
})
|
||||
|
||||
export const renameSchema = z.object({
|
||||
oldPath: z.string().min(1),
|
||||
newPath: z.string().min(1),
|
||||
})
|
||||
|
||||
export const searchSchema = z.object({
|
||||
keywords: z.array(z.string()).min(1),
|
||||
})
|
||||
|
||||
export const existsSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
})
|
||||
|
||||
export const createDirSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
})
|
||||
|
||||
export const createFileSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
})
|
||||
2
api/schemas/index.ts
Normal file
2
api/schemas/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './files.js'
|
||||
export * from './pydemos.js'
|
||||
21
api/schemas/pydemos.ts
Normal file
21
api/schemas/pydemos.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const listPyDemosQuerySchema = z.object({
|
||||
year: z.string().optional(),
|
||||
})
|
||||
|
||||
export const createPyDemoSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
year: z.string().min(1),
|
||||
month: z.string().min(1),
|
||||
folderStructure: z.string().optional(),
|
||||
})
|
||||
|
||||
export const deletePyDemoSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
})
|
||||
|
||||
export const renamePyDemoSchema = z.object({
|
||||
oldPath: z.string().min(1),
|
||||
newName: z.string().min(1),
|
||||
})
|
||||
48
api/server.ts
Normal file
48
api/server.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import app, { moduleManager, container } from './app.js';
|
||||
import { startWatcher, stopWatcher } from './watcher/watcher.js';
|
||||
import { logger } from './utils/logger.js';
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
startWatcher();
|
||||
|
||||
const server = app.listen(PORT, () => {
|
||||
logger.info(`Server ready on port ${PORT}`);
|
||||
});
|
||||
|
||||
async function gracefulShutdown(signal: string) {
|
||||
logger.info(`${signal} signal received`);
|
||||
|
||||
try {
|
||||
await stopWatcher();
|
||||
} catch (error) {
|
||||
logger.error('Error stopping watcher:', error);
|
||||
}
|
||||
|
||||
const activeModules = moduleManager.getActiveModules();
|
||||
for (const moduleId of activeModules.reverse()) {
|
||||
try {
|
||||
await moduleManager.deactivate(moduleId);
|
||||
logger.info(`Module '${moduleId}' deactivated`);
|
||||
} catch (error) {
|
||||
logger.error(`Error deactivating module '${moduleId}':`, error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await container.dispose();
|
||||
logger.info('Service container disposed');
|
||||
} catch (error) {
|
||||
logger.error('Error disposing container:', error);
|
||||
}
|
||||
|
||||
server.close(() => {
|
||||
logger.info('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
|
||||
export default app;
|
||||
79
api/utils/__tests__/asyncHandler.test.ts
Normal file
79
api/utils/__tests__/asyncHandler.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { asyncHandler } from '../asyncHandler'
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
|
||||
describe('asyncHandler', () => {
|
||||
const mockReq = {} as Request
|
||||
const mockRes = {} as Response
|
||||
const mockNext = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('成功调用', () => {
|
||||
it('应正常执行函数并返回结果', async () => {
|
||||
const mockHandler = vi.fn().mockResolvedValue('操作成功')
|
||||
const wrappedHandler = asyncHandler(mockHandler)
|
||||
|
||||
await wrappedHandler(mockReq, mockRes, mockNext)
|
||||
|
||||
expect(mockHandler).toHaveBeenCalledWith(mockReq, mockRes, mockNext)
|
||||
expect(mockNext).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('应处理返回同步值的函数', async () => {
|
||||
const mockHandler = vi.fn().mockResolvedValue({ id: 1, name: 'test' })
|
||||
const wrappedHandler = asyncHandler(mockHandler)
|
||||
|
||||
await wrappedHandler(mockReq, mockRes, mockNext)
|
||||
|
||||
expect(mockHandler).toHaveBeenCalled()
|
||||
expect(mockNext).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('异常传播', () => {
|
||||
it('应正确传播异步错误', async () => {
|
||||
const asyncError = new Error('异步错误')
|
||||
const mockHandler = vi.fn().mockRejectedValue(asyncError)
|
||||
const wrappedHandler = asyncHandler(mockHandler)
|
||||
|
||||
await wrappedHandler(mockReq, mockRes, mockNext)
|
||||
|
||||
expect(mockHandler).toHaveBeenCalled()
|
||||
expect(mockNext).toHaveBeenCalledWith(asyncError)
|
||||
})
|
||||
|
||||
it('应处理 Promise.reject 的错误', async () => {
|
||||
const error = new Error('Promise rejected')
|
||||
const mockHandler = vi.fn().mockReturnValue(Promise.reject(error))
|
||||
const wrappedHandler = asyncHandler(mockHandler)
|
||||
|
||||
await wrappedHandler(mockReq, mockRes, mockNext)
|
||||
|
||||
expect(mockNext).toHaveBeenCalledWith(error)
|
||||
})
|
||||
|
||||
it('应处理非 Error 对象的异步错误', async () => {
|
||||
const error = '字符串错误'
|
||||
const mockHandler = vi.fn().mockRejectedValue(error)
|
||||
const wrappedHandler = asyncHandler(mockHandler)
|
||||
|
||||
await wrappedHandler(mockReq, mockRes, mockNext)
|
||||
|
||||
expect(mockNext).toHaveBeenCalledWith(error)
|
||||
})
|
||||
})
|
||||
|
||||
describe('函数调用时机', () => {
|
||||
it('应立即调用底层函数', () => {
|
||||
const mockHandler = vi.fn().mockResolvedValue('result')
|
||||
const wrappedHandler = asyncHandler(mockHandler)
|
||||
|
||||
wrappedHandler(mockReq, mockRes, mockNext)
|
||||
|
||||
expect(mockHandler).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
137
api/utils/__tests__/response.test.ts
Normal file
137
api/utils/__tests__/response.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { successResponse, errorResponse } from '../response'
|
||||
import type { Response } from 'express'
|
||||
|
||||
vi.mock('express', () => ({
|
||||
default: {},
|
||||
}))
|
||||
|
||||
describe('successResponse', () => {
|
||||
it('应返回正确格式的成功响应(默认状态码)', () => {
|
||||
const mockRes = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn().mockReturnThis(),
|
||||
} as unknown as Response
|
||||
|
||||
const data = { message: '操作成功' }
|
||||
successResponse(mockRes, data)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
data: { message: '操作成功' },
|
||||
})
|
||||
})
|
||||
|
||||
it('应使用自定义状态码', () => {
|
||||
const mockRes = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn().mockReturnThis(),
|
||||
} as unknown as Response
|
||||
|
||||
const data = { id: 123 }
|
||||
successResponse(mockRes, data, 201)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(201)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
data: { id: 123 },
|
||||
})
|
||||
})
|
||||
|
||||
it('应正确处理数组数据', () => {
|
||||
const mockRes = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn().mockReturnThis(),
|
||||
} as unknown as Response
|
||||
|
||||
const data = [1, 2, 3]
|
||||
successResponse(mockRes, data)
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
data: [1, 2, 3],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('errorResponse', () => {
|
||||
it('应返回正确格式的错误响应', () => {
|
||||
const mockRes = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn().mockReturnThis(),
|
||||
} as unknown as Response
|
||||
|
||||
errorResponse(mockRes, 400, 'BAD_REQUEST', '请求参数错误')
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'BAD_REQUEST',
|
||||
message: '请求参数错误',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('应在非生产环境包含 details', () => {
|
||||
const mockRes = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn().mockReturnThis(),
|
||||
} as unknown as Response
|
||||
|
||||
const details = { field: 'username', reason: 'required' }
|
||||
errorResponse(mockRes, 422, 'VALIDATION_ERROR', '验证失败', details)
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: '验证失败',
|
||||
details: { field: 'username', reason: 'required' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('应在生产环境不包含 details', () => {
|
||||
const originalEnv = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'production'
|
||||
|
||||
const mockRes = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn().mockReturnThis(),
|
||||
} as unknown as Response
|
||||
|
||||
const details = { field: 'username', reason: 'required' }
|
||||
errorResponse(mockRes, 422, 'VALIDATION_ERROR', '验证失败', details)
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: '验证失败',
|
||||
details: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
process.env.NODE_ENV = originalEnv
|
||||
})
|
||||
|
||||
it('应正确处理不带 details 的错误响应', () => {
|
||||
const mockRes = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn().mockReturnThis(),
|
||||
} as unknown as Response
|
||||
|
||||
errorResponse(mockRes, 404, 'NOT_FOUND', '资源不存在')
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(404)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'NOT_FOUND',
|
||||
message: '资源不存在',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
10
api/utils/asyncHandler.ts
Normal file
10
api/utils/asyncHandler.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
|
||||
export const asyncHandler =
|
||||
<TReq extends Request = Request, TRes extends Response = Response>(
|
||||
fn: (req: TReq, res: TRes, next: NextFunction) => unknown | Promise<unknown>,
|
||||
) =>
|
||||
(req: TReq, res: TRes, next: NextFunction) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next)
|
||||
}
|
||||
|
||||
126
api/utils/file.ts
Normal file
126
api/utils/file.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { InternalError, ValidationError } from '../../shared/errors/index.js'
|
||||
|
||||
export const getUniqueFilename = async (imagesDirFullPath: string, baseName: string, ext: string) => {
|
||||
const maxAttempts = 1000
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const suffix = i === 0 ? '' : `-${i + 1}`
|
||||
const filename = `${baseName}${suffix}${ext}`
|
||||
const fullPath = path.join(imagesDirFullPath, filename)
|
||||
try {
|
||||
await fs.access(fullPath)
|
||||
} catch {
|
||||
return filename
|
||||
}
|
||||
}
|
||||
throw new InternalError('Failed to generate unique filename')
|
||||
}
|
||||
|
||||
export const mimeToExt: Record<string, string> = {
|
||||
'image/png': '.png',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/jpg': '.jpg',
|
||||
'image/gif': '.gif',
|
||||
'image/webp': '.webp',
|
||||
}
|
||||
|
||||
const IMAGE_MAGIC_BYTES: Record<string, { bytes: number[]; offset?: number }> = {
|
||||
'image/png': { bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
|
||||
'image/jpeg': { bytes: [0xff, 0xd8, 0xff] },
|
||||
'image/gif': { bytes: [0x47, 0x49, 0x46, 0x38] },
|
||||
'image/webp': { bytes: [0x52, 0x49, 0x46, 0x46], offset: 0 },
|
||||
}
|
||||
|
||||
const WEBP_RIFF_HEADER = [0x52, 0x49, 0x46, 0x46]
|
||||
const WEBP_WEBP_MARKER = [0x57, 0x45, 0x42, 0x50]
|
||||
|
||||
const MIN_IMAGE_SIZE = 16
|
||||
const MAX_IMAGE_SIZE = 8 * 1024 * 1024
|
||||
|
||||
export const validateImageBuffer = (buffer: Buffer, claimedMimeType: string): void => {
|
||||
if (buffer.byteLength < MIN_IMAGE_SIZE) {
|
||||
throw new ValidationError('Image file is too small or corrupted')
|
||||
}
|
||||
|
||||
if (buffer.byteLength > MAX_IMAGE_SIZE) {
|
||||
throw new ValidationError('Image file is too large')
|
||||
}
|
||||
|
||||
const magicInfo = IMAGE_MAGIC_BYTES[claimedMimeType]
|
||||
if (!magicInfo) {
|
||||
throw new ValidationError('Unsupported image type for content validation')
|
||||
}
|
||||
|
||||
const offset = magicInfo.offset || 0
|
||||
const expectedBytes = magicInfo.bytes
|
||||
|
||||
for (let i = 0; i < expectedBytes.length; i++) {
|
||||
if (buffer[offset + i] !== expectedBytes[i]) {
|
||||
throw new ValidationError('Image content does not match the claimed file type')
|
||||
}
|
||||
}
|
||||
|
||||
if (claimedMimeType === 'image/webp') {
|
||||
if (buffer.byteLength < 12) {
|
||||
throw new ValidationError('WebP image is corrupted')
|
||||
}
|
||||
for (let i = 0; i < WEBP_WEBP_MARKER.length; i++) {
|
||||
if (buffer[8 + i] !== WEBP_WEBP_MARKER[i]) {
|
||||
throw new ValidationError('WebP image content is invalid')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const detectImageMimeType = (buffer: Buffer): string | null => {
|
||||
if (buffer.byteLength < 8) return null
|
||||
|
||||
if (
|
||||
buffer[0] === 0x89 &&
|
||||
buffer[1] === 0x50 &&
|
||||
buffer[2] === 0x4e &&
|
||||
buffer[3] === 0x47 &&
|
||||
buffer[4] === 0x0d &&
|
||||
buffer[5] === 0x0a &&
|
||||
buffer[6] === 0x1a &&
|
||||
buffer[7] === 0x0a
|
||||
) {
|
||||
return 'image/png'
|
||||
}
|
||||
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||
return 'image/jpeg'
|
||||
}
|
||||
|
||||
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) {
|
||||
return 'image/gif'
|
||||
}
|
||||
|
||||
if (
|
||||
buffer[0] === 0x52 &&
|
||||
buffer[1] === 0x49 &&
|
||||
buffer[2] === 0x46 &&
|
||||
buffer[3] === 0x46 &&
|
||||
buffer[8] === 0x57 &&
|
||||
buffer[9] === 0x45 &&
|
||||
buffer[10] === 0x42 &&
|
||||
buffer[11] === 0x50
|
||||
) {
|
||||
return 'image/webp'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const sanitizeFilename = (filename: string): string => {
|
||||
let sanitized = filename.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
|
||||
sanitized = sanitized.replace(/^\.+|\.+$/g, '')
|
||||
sanitized = sanitized.replace(/\.{2,}/g, '.')
|
||||
if (sanitized.length > 200) {
|
||||
const ext = path.extname(filename)
|
||||
const baseName = path.basename(filename, ext)
|
||||
sanitized = baseName.substring(0, 200 - ext.length) + ext
|
||||
}
|
||||
return sanitized || 'unnamed'
|
||||
}
|
||||
15
api/utils/logger.ts
Normal file
15
api/utils/logger.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
type LogFn = (...args: unknown[]) => void
|
||||
|
||||
const createLogger = () => {
|
||||
const isProd = process.env.NODE_ENV === 'production'
|
||||
|
||||
const debug: LogFn = isProd ? () => {} : console.debug.bind(console)
|
||||
const info: LogFn = console.info.bind(console)
|
||||
const warn: LogFn = console.warn.bind(console)
|
||||
const error: LogFn = console.error.bind(console)
|
||||
|
||||
return { debug, info, warn, error }
|
||||
}
|
||||
|
||||
export const logger = createLogger()
|
||||
|
||||
106
api/utils/pathSafety.ts
Normal file
106
api/utils/pathSafety.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs/promises'
|
||||
import { NOTEBOOK_ROOT } from '../config/paths.js'
|
||||
import { AccessDeniedError } from '../../shared/errors/index.js'
|
||||
|
||||
const DANGEROUS_PATTERNS = [
|
||||
/\.\./,
|
||||
/\0/,
|
||||
/%2e%2e[%/]/i,
|
||||
/%252e%252e[%/]/i,
|
||||
/\.\.%2f/i,
|
||||
/\.\.%5c/i,
|
||||
/%c0%ae/i,
|
||||
/%c1%9c/i,
|
||||
/%c0%ae%c0%ae/i,
|
||||
/%c1%9c%c1%9c/i,
|
||||
/\.\.%c0%af/i,
|
||||
/\.\.%c1%9c/i,
|
||||
/%252e/i,
|
||||
/%uff0e/i,
|
||||
/%u002e/i,
|
||||
]
|
||||
|
||||
const WINDOWS_RESERVED_NAMES = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]|CLOCK\$)$/i
|
||||
|
||||
const DOUBLE_ENCODE_PATTERNS = [
|
||||
/%25[0-9a-fA-F]{2}/,
|
||||
/%[0-9a-fA-F]{2}%[0-9a-fA-F]{2}/,
|
||||
]
|
||||
|
||||
export const normalizeRelPath = (input: string) => {
|
||||
const trimmed = input.replace(/\0/g, '').trim()
|
||||
return trimmed.replace(/^[/\\]+/, '')
|
||||
}
|
||||
|
||||
export const containsPathTraversal = (input: string): boolean => {
|
||||
const decoded = decodeURIComponentSafe(input)
|
||||
return DANGEROUS_PATTERNS.some(pattern => pattern.test(input) || pattern.test(decoded))
|
||||
}
|
||||
|
||||
export const containsDoubleEncoding = (input: string): boolean => {
|
||||
return DOUBLE_ENCODE_PATTERNS.some(pattern => pattern.test(input))
|
||||
}
|
||||
|
||||
export const hasPathSecurityIssues = (input: string): boolean => {
|
||||
return containsPathTraversal(input) || containsDoubleEncoding(input)
|
||||
}
|
||||
|
||||
const decodeURIComponentSafe = (input: string): string => {
|
||||
try {
|
||||
return decodeURIComponent(input)
|
||||
} catch {
|
||||
return input
|
||||
}
|
||||
}
|
||||
|
||||
export const resolveNotebookPath = (relPath: string) => {
|
||||
if (hasPathSecurityIssues(relPath)) {
|
||||
throw new AccessDeniedError('Path traversal detected')
|
||||
}
|
||||
|
||||
const safeRelPath = normalizeRelPath(relPath)
|
||||
const notebookRoot = path.resolve(NOTEBOOK_ROOT)
|
||||
const fullPath = path.resolve(notebookRoot, safeRelPath)
|
||||
|
||||
if (!fullPath.startsWith(notebookRoot)) {
|
||||
throw new AccessDeniedError('Access denied')
|
||||
}
|
||||
|
||||
return { safeRelPath, fullPath }
|
||||
}
|
||||
|
||||
export const resolveNotebookPathSafe = async (relPath: string) => {
|
||||
if (hasPathSecurityIssues(relPath)) {
|
||||
throw new AccessDeniedError('Path traversal detected')
|
||||
}
|
||||
|
||||
const safeRelPath = normalizeRelPath(relPath)
|
||||
const notebookRoot = path.resolve(NOTEBOOK_ROOT)
|
||||
const fullPath = path.resolve(notebookRoot, safeRelPath)
|
||||
|
||||
try {
|
||||
await fs.access(fullPath)
|
||||
} catch {
|
||||
return { safeRelPath, fullPath, realPath: null }
|
||||
}
|
||||
|
||||
const realFullPath = await fs.realpath(fullPath)
|
||||
const realRoot = await fs.realpath(notebookRoot)
|
||||
|
||||
if (!realFullPath.startsWith(realRoot)) {
|
||||
throw new AccessDeniedError('Symbolic link escapes notebook root')
|
||||
}
|
||||
|
||||
return { safeRelPath, fullPath, realPath: realFullPath }
|
||||
}
|
||||
|
||||
export const validateFileName = (name: string): boolean => {
|
||||
if (!name || name.length === 0) return false
|
||||
if (name.length > 255) return false
|
||||
const invalidChars = /[<>:"/\\|?*\x00-\x1f]/
|
||||
if (invalidChars.test(name)) return false
|
||||
if (WINDOWS_RESERVED_NAMES.test(name)) return false
|
||||
if (name.startsWith('.') || name.endsWith('.')) return false
|
||||
return true
|
||||
}
|
||||
34
api/utils/response.ts
Normal file
34
api/utils/response.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Response } from 'express'
|
||||
import type { ApiResponse } from '../../shared/types.js'
|
||||
|
||||
/**
|
||||
* Send a successful API response
|
||||
*/
|
||||
export const successResponse = <T>(res: Response, data: T, statusCode: number = 200): void => {
|
||||
const response: ApiResponse<T> = {
|
||||
success: true,
|
||||
data,
|
||||
}
|
||||
res.status(statusCode).json(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an error API response
|
||||
*/
|
||||
export const errorResponse = (
|
||||
res: Response,
|
||||
statusCode: number,
|
||||
code: string,
|
||||
message: string,
|
||||
details?: unknown
|
||||
): void => {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
details: process.env.NODE_ENV === 'production' ? undefined : details,
|
||||
},
|
||||
}
|
||||
res.status(statusCode).json(response)
|
||||
}
|
||||
23
api/utils/tempDir.ts
Normal file
23
api/utils/tempDir.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { existsSync, mkdirSync } from 'fs'
|
||||
import path from 'path'
|
||||
import { PATHS } from '../config/paths.js'
|
||||
|
||||
let tempDir: string | null = null
|
||||
|
||||
export const getTempDir = (): string => {
|
||||
if (!tempDir) {
|
||||
tempDir = PATHS.TEMP_ROOT
|
||||
if (!existsSync(tempDir)) {
|
||||
mkdirSync(tempDir, { recursive: true })
|
||||
}
|
||||
}
|
||||
return tempDir
|
||||
}
|
||||
|
||||
export const getTempFilePath = (filename: string): string => {
|
||||
return path.join(getTempDir(), filename)
|
||||
}
|
||||
|
||||
export const ensureTempDir = (): string => {
|
||||
return getTempDir()
|
||||
}
|
||||
43
api/watcher/watcher.ts
Normal file
43
api/watcher/watcher.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import chokidar, { FSWatcher } from 'chokidar';
|
||||
import path from 'path';
|
||||
import { NOTEBOOK_ROOT } from '../config/paths.js';
|
||||
import { eventBus } from '../events/eventBus.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { toPosixPath } from '../../shared/utils/path.js';
|
||||
|
||||
let watcher: FSWatcher | null = null;
|
||||
|
||||
export const startWatcher = (): void => {
|
||||
if (watcher) return;
|
||||
|
||||
logger.info(`Starting file watcher for: ${NOTEBOOK_ROOT}`);
|
||||
|
||||
watcher = chokidar.watch(NOTEBOOK_ROOT, {
|
||||
ignored: /(^|[\/\\])\../,
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
});
|
||||
|
||||
const broadcast = (event: string, changedPath: string) => {
|
||||
const rel = path.relative(NOTEBOOK_ROOT, changedPath);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return;
|
||||
logger.info(`File event: ${event} - ${rel}`);
|
||||
eventBus.broadcast({ event, path: toPosixPath(rel) });
|
||||
};
|
||||
|
||||
watcher
|
||||
.on('add', (p) => broadcast('add', p))
|
||||
.on('change', (p) => broadcast('change', p))
|
||||
.on('unlink', (p) => broadcast('unlink', p))
|
||||
.on('addDir', (p) => broadcast('addDir', p))
|
||||
.on('unlinkDir', (p) => broadcast('unlinkDir', p))
|
||||
.on('ready', () => logger.info('File watcher ready'))
|
||||
.on('error', (err) => logger.error('File watcher error:', err));
|
||||
};
|
||||
|
||||
export const stopWatcher = async (): Promise<void> => {
|
||||
if (watcher) {
|
||||
await watcher.close();
|
||||
watcher = null;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user