Initial commit
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user