4d1d3e316f
* build: add eslint-plugin-oxlint dependency Add new eslint plugin to enhance linting capabilities with oxlint rules * build(eslint): add oxlint plugin to eslint config Add oxlint plugin as recommended in the documentation to enhance linting capabilities * build: add oxlint v1.15.0 as a dependency * build: add oxlint to linting commands Add oxlint alongside eslint in test:lint and lint scripts for enhanced static analysis * build: add oxlint configuration file Configure oxlint with a comprehensive set of rules for JavaScript/TypeScript code quality checks * chore: update oxlint configuration and related settings - Add oxc to editor code actions on save - Update oxlint configs to use eslint, typescript, and unicorn presets - Extend ignore patterns in oxlint configuration - Simplify oxlint command in package.json scripts - Add oxlint-tsgolint dependency * fix: lint warning * chore: update oxlintrc from eslint.recommended * refactor(lint): update eslint and oxlint configurations - Add src/preload to eslint ignore patterns - Update oxlint env to es2022 and add environment overrides - Adjust several lint rule severities and configurations * fix: lint error * fix(file): replace eslint-disable with oxlint-disable in sanitizeFilename The linter was changed from ESLint to oxlint, so the directive needs to be updated accordingly. * fix: enforce stricter linting by failing on warnings in test:lint script * feat: add recommended ts-eslint rules into exlint * docs: remove outdated comment in oxlint config file * style: disable typescript/no-require-imports rule in oxlint config * docs(utils): fix comment typo from NODE to NOTE * fix(MessageErrorBoundary): correct error description display condition The error description was incorrectly showing in production and hiding in development. Fix the logic to show detailed errors only in development mode * chore: add oxc-vscode extension to recommended list * ci(workflows): reorder format check step in pr-ci.yml * chore: update yarn.lock
436 lines
13 KiB
TypeScript
436 lines
13 KiB
TypeScript
import * as fs from 'node:fs'
|
|
import { readFile } from 'node:fs/promises'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
|
|
import { loggerService } from '@logger'
|
|
import { audioExts, documentExts, imageExts, MB, textExts, videoExts } from '@shared/config/constant'
|
|
import { FileMetadata, FileTypes, NotesTreeNode } from '@types'
|
|
import chardet from 'chardet'
|
|
import { app } from 'electron'
|
|
import iconv from 'iconv-lite'
|
|
import { v4 as uuidv4 } from 'uuid'
|
|
|
|
const logger = loggerService.withContext('Utils:File')
|
|
|
|
// 创建文件类型映射表,提高查找效率
|
|
const fileTypeMap = new Map<string, FileTypes>()
|
|
|
|
// 初始化映射表
|
|
function initFileTypeMap() {
|
|
imageExts.forEach((ext) => fileTypeMap.set(ext, FileTypes.IMAGE))
|
|
videoExts.forEach((ext) => fileTypeMap.set(ext, FileTypes.VIDEO))
|
|
audioExts.forEach((ext) => fileTypeMap.set(ext, FileTypes.AUDIO))
|
|
textExts.forEach((ext) => fileTypeMap.set(ext, FileTypes.TEXT))
|
|
documentExts.forEach((ext) => fileTypeMap.set(ext, FileTypes.DOCUMENT))
|
|
}
|
|
|
|
// 初始化映射表
|
|
initFileTypeMap()
|
|
|
|
export function untildify(pathWithTilde: string) {
|
|
if (pathWithTilde.startsWith('~')) {
|
|
const homeDirectory = os.homedir()
|
|
return pathWithTilde.replace(/^~(?=$|\/|\\)/, homeDirectory)
|
|
}
|
|
return pathWithTilde
|
|
}
|
|
|
|
export async function hasWritePermission(dir: string) {
|
|
try {
|
|
logger.info(`Checking write permission for ${dir}`)
|
|
await fs.promises.access(dir, fs.constants.W_OK)
|
|
return true
|
|
} catch (error) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a path is inside another path (proper parent-child relationship)
|
|
* This function correctly handles edge cases that string.startsWith() cannot handle,
|
|
* such as distinguishing between '/root/test' and '/root/test aaa'
|
|
*
|
|
* @param childPath - The path that might be inside the parent path
|
|
* @param parentPath - The path that might contain the child path
|
|
* @returns true if childPath is inside parentPath, false otherwise
|
|
*/
|
|
export function isPathInside(childPath: string, parentPath: string): boolean {
|
|
try {
|
|
const resolvedChild = path.resolve(childPath)
|
|
const resolvedParent = path.resolve(parentPath)
|
|
|
|
// Normalize paths to handle different separators
|
|
const normalizedChild = path.normalize(resolvedChild)
|
|
const normalizedParent = path.normalize(resolvedParent)
|
|
|
|
// Check if they are the same path
|
|
if (normalizedChild === normalizedParent) {
|
|
return true
|
|
}
|
|
|
|
// Get relative path from parent to child
|
|
const relativePath = path.relative(normalizedParent, normalizedChild)
|
|
|
|
// If relative path is empty, they are the same
|
|
// If relative path starts with '..', child is not inside parent
|
|
// If relative path is absolute, child is not inside parent
|
|
return relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
|
|
} catch (error) {
|
|
logger.error('Failed to check path relationship:', error as Error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
export function getFileType(ext: string): FileTypes {
|
|
ext = ext.toLowerCase()
|
|
return fileTypeMap.get(ext) || FileTypes.OTHER
|
|
}
|
|
|
|
export function getFileDir(filePath: string) {
|
|
return path.dirname(filePath)
|
|
}
|
|
|
|
export function getFileName(filePath: string) {
|
|
return path.basename(filePath)
|
|
}
|
|
|
|
export function getFileExt(filePath: string) {
|
|
return path.extname(filePath)
|
|
}
|
|
|
|
export function getAllFiles(dirPath: string, arrayOfFiles: FileMetadata[] = []): FileMetadata[] {
|
|
const files = fs.readdirSync(dirPath)
|
|
|
|
files.forEach((file) => {
|
|
if (file.startsWith('.')) {
|
|
return
|
|
}
|
|
|
|
const fullPath = path.join(dirPath, file)
|
|
if (fs.statSync(fullPath).isDirectory()) {
|
|
arrayOfFiles = getAllFiles(fullPath, arrayOfFiles)
|
|
} else {
|
|
const ext = path.extname(file)
|
|
const fileType = getFileType(ext)
|
|
|
|
if ([FileTypes.OTHER, FileTypes.IMAGE, FileTypes.VIDEO, FileTypes.AUDIO].includes(fileType)) {
|
|
return
|
|
}
|
|
|
|
const name = path.basename(file)
|
|
const size = fs.statSync(fullPath).size
|
|
|
|
const fileItem: FileMetadata = {
|
|
id: uuidv4(),
|
|
name,
|
|
path: fullPath,
|
|
size,
|
|
ext,
|
|
count: 1,
|
|
origin_name: name,
|
|
type: fileType,
|
|
created_at: new Date().toISOString()
|
|
}
|
|
|
|
arrayOfFiles.push(fileItem)
|
|
}
|
|
})
|
|
|
|
return arrayOfFiles
|
|
}
|
|
|
|
export function getTempDir() {
|
|
return path.join(app.getPath('temp'), 'CherryStudio')
|
|
}
|
|
|
|
export function getFilesDir() {
|
|
return path.join(app.getPath('userData'), 'Data', 'Files')
|
|
}
|
|
|
|
export function getNotesDir() {
|
|
const notesDir = path.join(app.getPath('userData'), 'Data', 'Notes')
|
|
if (!fs.existsSync(notesDir)) {
|
|
fs.mkdirSync(notesDir, { recursive: true })
|
|
logger.info(`Notes directory created at: ${notesDir}`)
|
|
}
|
|
return notesDir
|
|
}
|
|
|
|
export function getConfigDir() {
|
|
return path.join(os.homedir(), '.cherrystudio', 'config')
|
|
}
|
|
|
|
export function getCacheDir() {
|
|
return path.join(app.getPath('userData'), 'Cache')
|
|
}
|
|
|
|
export function getAppConfigDir(name: string) {
|
|
return path.join(getConfigDir(), name)
|
|
}
|
|
|
|
export function getMcpDir() {
|
|
return path.join(os.homedir(), '.cherrystudio', 'mcp')
|
|
}
|
|
|
|
/**
|
|
* 读取文件内容并自动检测编码格式进行解码
|
|
* @param filePath - 文件路径
|
|
* @returns 解码后的文件内容
|
|
* @throws 如果路径不存在抛出错误
|
|
*/
|
|
export async function readTextFileWithAutoEncoding(filePath: string): Promise<string> {
|
|
const encoding = (await chardet.detectFile(filePath, { sampleSize: MB })) || 'UTF-8'
|
|
logger.debug(`File ${filePath} detected encoding: ${encoding}`)
|
|
|
|
const encodings = [encoding, 'UTF-8']
|
|
const data = await readFile(filePath)
|
|
|
|
for (const encoding of encodings) {
|
|
try {
|
|
const content = iconv.decode(data, encoding)
|
|
if (!content.includes('\uFFFD')) {
|
|
return content
|
|
} else {
|
|
logger.warn(
|
|
`File ${filePath} was auto-detected as ${encoding} encoding, but contains invalid characters. Trying other encodings`
|
|
)
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Failed to decode file ${filePath} with encoding ${encoding}: ${error}`)
|
|
}
|
|
}
|
|
|
|
logger.error(`File ${filePath} failed to decode with all possible encodings, trying UTF-8 encoding`)
|
|
return iconv.decode(data, 'UTF-8')
|
|
}
|
|
|
|
export async function base64Image(file: FileMetadata): Promise<{ mime: string; base64: string; data: string }> {
|
|
const filePath = path.join(getFilesDir(), `${file.id}${file.ext}`)
|
|
const data = await fs.promises.readFile(filePath)
|
|
const base64 = data.toString('base64')
|
|
const ext = path.extname(filePath).slice(1) == 'jpg' ? 'jpeg' : path.extname(filePath).slice(1)
|
|
const mime = `image/${ext}`
|
|
return {
|
|
mime,
|
|
base64,
|
|
data: `data:${mime};base64,${base64}`
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 递归扫描目录,获取符合条件的文件和目录结构
|
|
* @param dirPath 当前要扫描的路径
|
|
* @param depth 当前深度
|
|
* @param basePath
|
|
* @returns 文件元数据数组
|
|
*/
|
|
export async function scanDir(dirPath: string, depth = 0, basePath?: string): Promise<NotesTreeNode[]> {
|
|
const options = {
|
|
includeFiles: true,
|
|
includeDirectories: true,
|
|
fileExtensions: ['.md'],
|
|
ignoreHiddenFiles: true,
|
|
recursive: true,
|
|
maxDepth: 10
|
|
}
|
|
|
|
// 如果是第一次调用,设置basePath为当前目录
|
|
if (!basePath) {
|
|
basePath = dirPath
|
|
}
|
|
|
|
if (options.maxDepth !== undefined && depth > options.maxDepth) {
|
|
return []
|
|
}
|
|
|
|
if (!fs.existsSync(dirPath)) {
|
|
loggerService.withContext('Utils:File').warn(`Dir not exist: ${dirPath}`)
|
|
return []
|
|
}
|
|
|
|
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true })
|
|
const result: NotesTreeNode[] = []
|
|
|
|
for (const entry of entries) {
|
|
if (options.ignoreHiddenFiles && entry.name.startsWith('.')) {
|
|
continue
|
|
}
|
|
|
|
const entryPath = path.join(dirPath, entry.name)
|
|
|
|
const relativePath = path.relative(basePath, entryPath)
|
|
const treePath = '/' + relativePath.replace(/\\/g, '/')
|
|
|
|
if (entry.isDirectory() && options.includeDirectories) {
|
|
const stats = await fs.promises.stat(entryPath)
|
|
const dirTreeNode: NotesTreeNode = {
|
|
id: uuidv4(),
|
|
name: entry.name,
|
|
treePath: treePath,
|
|
externalPath: entryPath,
|
|
createdAt: stats.birthtime.toISOString(),
|
|
updatedAt: stats.mtime.toISOString(),
|
|
type: 'folder',
|
|
children: [] // 添加 children 属性
|
|
}
|
|
|
|
// 如果启用了递归扫描,则递归调用 scanDir
|
|
if (options.recursive) {
|
|
dirTreeNode.children = await scanDir(entryPath, depth + 1, basePath)
|
|
}
|
|
|
|
result.push(dirTreeNode)
|
|
} else if (entry.isFile() && options.includeFiles) {
|
|
const ext = path.extname(entry.name).toLowerCase()
|
|
if (options.fileExtensions.length > 0 && !options.fileExtensions.includes(ext)) {
|
|
continue
|
|
}
|
|
|
|
const stats = await fs.promises.stat(entryPath)
|
|
const name = entry.name.endsWith(options.fileExtensions[0])
|
|
? entry.name.slice(0, -options.fileExtensions[0].length)
|
|
: entry.name
|
|
|
|
// 对于文件,treePath应该使用不带扩展名的路径
|
|
const nameWithoutExt = path.basename(entryPath, path.extname(entryPath))
|
|
const dirRelativePath = path.relative(basePath, path.dirname(entryPath))
|
|
const fileTreePath = dirRelativePath
|
|
? `/${dirRelativePath.replace(/\\/g, '/')}/${nameWithoutExt}`
|
|
: `/${nameWithoutExt}`
|
|
|
|
const fileTreeNode: NotesTreeNode = {
|
|
id: uuidv4(),
|
|
name: name,
|
|
treePath: fileTreePath,
|
|
externalPath: entryPath,
|
|
createdAt: stats.birthtime.toISOString(),
|
|
updatedAt: stats.mtime.toISOString(),
|
|
type: 'file'
|
|
}
|
|
result.push(fileTreeNode)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* 文件名唯一性约束
|
|
* @param baseDir 基础目录
|
|
* @param fileName 文件名
|
|
* @param isFile 是否为文件
|
|
* @returns 唯一的文件名
|
|
*/
|
|
export function getName(baseDir: string, fileName: string, isFile: boolean): string {
|
|
// 首先清理文件名
|
|
const baseName = sanitizeFilename(fileName)
|
|
let candidate = isFile ? baseName + '.md' : baseName
|
|
let counter = 1
|
|
|
|
while (fs.existsSync(path.join(baseDir, candidate))) {
|
|
candidate = isFile ? `${baseName}${counter}.md` : `${baseName}${counter}`
|
|
counter++
|
|
}
|
|
|
|
return isFile ? candidate.slice(0, -3) : candidate
|
|
}
|
|
|
|
/**
|
|
* 文件名合法性校验
|
|
* @param fileName 文件名
|
|
* @param platform 平台,默认为当前运行平台
|
|
* @returns 验证结果
|
|
*/
|
|
export function validateFileName(fileName: string, platform = process.platform): { valid: boolean; error?: string } {
|
|
if (!fileName) {
|
|
return { valid: false, error: 'File name cannot be empty' }
|
|
}
|
|
|
|
// 通用检查
|
|
if (fileName.length === 0 || fileName.length > 255) {
|
|
return { valid: false, error: 'File name length must be between 1 and 255 characters' }
|
|
}
|
|
|
|
// 检查 null 字符(所有系统都不允许)
|
|
if (fileName.includes('\0')) {
|
|
return { valid: false, error: 'File name cannot contain null characters.' }
|
|
}
|
|
|
|
// Windows 特殊限制
|
|
if (platform === 'win32') {
|
|
const winInvalidChars = /[<>:"/\\|?*]/
|
|
if (winInvalidChars.test(fileName)) {
|
|
return { valid: false, error: 'File name contains characters not supported by Windows: < > : " / \\ | ? *' }
|
|
}
|
|
|
|
const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i
|
|
if (reservedNames.test(fileName)) {
|
|
return { valid: false, error: 'File name is a Windows reserved name.' }
|
|
}
|
|
|
|
if (fileName.endsWith('.') || fileName.endsWith(' ')) {
|
|
return { valid: false, error: 'File name cannot end with a dot or a space' }
|
|
}
|
|
}
|
|
|
|
// Unix/Linux/macOS 限制
|
|
if (platform !== 'win32') {
|
|
if (fileName.includes('/')) {
|
|
return { valid: false, error: 'File name cannot contain slashes /' }
|
|
}
|
|
}
|
|
|
|
// macOS 额外限制
|
|
if (platform === 'darwin') {
|
|
if (fileName.includes(':')) {
|
|
return { valid: false, error: 'macOS filenames cannot contain a colon :' }
|
|
}
|
|
}
|
|
|
|
return { valid: true }
|
|
}
|
|
|
|
/**
|
|
* 文件名合法性检查
|
|
* @param fileName 文件名
|
|
* @throws 如果文件名不合法则抛出异常
|
|
* @returns 合法的文件名
|
|
*/
|
|
export function checkName(fileName: string): string {
|
|
const baseName = path.basename(fileName)
|
|
const validation = validateFileName(baseName)
|
|
if (!validation.valid) {
|
|
// 自动清理非法字符,而不是抛出错误
|
|
const sanitized = sanitizeFilename(baseName)
|
|
logger.warn(`File name contains invalid characters, auto-sanitized: "${baseName}" -> "${sanitized}"`)
|
|
return sanitized
|
|
}
|
|
return baseName
|
|
}
|
|
|
|
/**
|
|
* 清理文件名,替换不合法字符
|
|
* @param fileName 原始文件名
|
|
* @param replacement 替换字符,默认为下划线
|
|
* @returns 清理后的文件名
|
|
*/
|
|
export function sanitizeFilename(fileName: string, replacement = '_'): string {
|
|
if (!fileName) return ''
|
|
|
|
// 移除或替换非法字符
|
|
let sanitized = fileName
|
|
// oxlint-disable-next-line no-control-regex
|
|
.replace(/[<>:"/\\|?*\x00-\x1f]/g, replacement) // Windows 非法字符
|
|
.replace(/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i, replacement + '$2') // Windows 保留名
|
|
.replace(/[\s.]+$/, '') // 移除末尾的空格和点
|
|
.substring(0, 255) // 限制长度
|
|
|
|
// 确保不为空
|
|
if (!sanitized) {
|
|
sanitized = 'untitled'
|
|
}
|
|
|
|
return sanitized
|
|
}
|