diff --git a/src/main/file.ts b/src/main/file.ts index 3d42490b..dc764f5c 100644 --- a/src/main/file.ts +++ b/src/main/file.ts @@ -1,4 +1,5 @@ -import { app, dialog } from 'electron' +import * as crypto from 'crypto' +import { app, dialog, OpenDialogOptions } from 'electron' import * as fs from 'fs' import * as path from 'path' import { v4 as uuidv4 } from 'uuid' @@ -6,9 +7,11 @@ import { v4 as uuidv4 } from 'uuid' interface FileMetadata { id: string name: string + fileName: string path: string - createdAt: Date size: number + ext: string + createdAt: Date } export class File { @@ -25,41 +28,103 @@ export class File { } } - async selectFile(): Promise { - const result = await dialog.showOpenDialog({ - properties: ['openFile'] + private async getFileHash(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('md5') + const stream = fs.createReadStream(filePath) + stream.on('data', (data) => hash.update(data)) + stream.on('end', () => resolve(hash.digest('hex'))) + stream.on('error', reject) }) + } + + async findDuplicateFile(filePath: string): Promise { + const stats = fs.statSync(filePath) + const fileSize = stats.size + + const files = await fs.promises.readdir(this.storageDir) + for (const file of files) { + const storedFilePath = path.join(this.storageDir, file) + const storedStats = fs.statSync(storedFilePath) + + if (storedStats.size === fileSize) { + const [originalHash, storedHash] = await Promise.all([ + this.getFileHash(filePath), + this.getFileHash(storedFilePath) + ]) + + if (originalHash === storedHash) { + const ext = path.extname(file) + return { + id: path.basename(file, ext), + name: path.basename(filePath), + fileName: file, + path: storedFilePath, + createdAt: storedStats.birthtime, + size: storedStats.size, + ext: ext + } + } + } + } + + return null + } + + async selectFile(options?: OpenDialogOptions): Promise { + const defaultOptions: OpenDialogOptions = { + properties: ['openFile'] + } + + const dialogOptions = { ...defaultOptions, ...options } + + const result = await dialog.showOpenDialog(dialogOptions) if (result.canceled || result.filePaths.length === 0) { return null } - const filePath = result.filePaths[0] - const stats = fs.statSync(filePath) + const fileMetadataPromises = result.filePaths.map(async (filePath) => { + const stats = fs.statSync(filePath) + const ext = path.extname(filePath) - return { - id: uuidv4(), - name: path.basename(filePath), - path: filePath, - createdAt: stats.birthtime, - size: stats.size - } + return { + id: uuidv4(), + name: path.basename(filePath), + fileName: path.basename(filePath), + path: filePath, + createdAt: stats.birthtime, + size: stats.size, + ext: ext + } + }) + + return Promise.all(fileMetadataPromises) } async uploadFile(filePath: string): Promise { - const id = uuidv4() + const duplicateFile = await this.findDuplicateFile(filePath) + + if (duplicateFile) { + return duplicateFile + } + + const uuid = uuidv4() const name = path.basename(filePath) - const destPath = path.join(this.storageDir, id) + const ext = path.extname(name) + const destPath = path.join(this.storageDir, uuid + ext) await fs.promises.copyFile(filePath, destPath) const stats = await fs.promises.stat(destPath) return { - id, + id: uuid, name, + fileName: uuid + ext, path: destPath, createdAt: stats.birthtime, - size: stats.size + size: stats.size, + ext: ext } } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index c605bd42..6b88d053 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,11 +1,14 @@ -import { BrowserWindow, ipcMain, session, shell } from 'electron' +import { BrowserWindow, ipcMain, OpenDialogOptions, session, shell } from 'electron' import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config' +import { File } from './file' import AppUpdater from './updater' import { openFile, saveFile } from './utils/file' import { compress, decompress } from './utils/zip' import { createMinappWindow } from './window' +const fileManager = new File() + export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { const { autoUpdater } = new AppUpdater(mainWindow) @@ -31,6 +34,28 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle('zip:compress', (_, text: string) => compress(text)) ipcMain.handle('zip:decompress', (_, text: Buffer) => decompress(text)) + ipcMain.handle('file:select', async (_, options?: OpenDialogOptions) => { + return await fileManager.selectFile(options) + }) + + ipcMain.handle('file:upload', async (_, filePath: string) => { + return await fileManager.uploadFile(filePath) + }) + + ipcMain.handle('file:delete', async (_, fileId: string) => { + await fileManager.deleteFile(fileId) + return { success: true } + }) + + ipcMain.handle('file:batchUpload', async (_, filePaths: string[]) => { + return await fileManager.batchUploadFiles(filePaths) + }) + + ipcMain.handle('file:batchDelete', async (_, fileIds: string[]) => { + await fileManager.batchDeleteFiles(fileIds) + return { success: true } + }) + ipcMain.handle('minapp', (_, args) => { createMinappWindow({ url: args.url, diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index c9d026c8..c55baed5 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,6 +1,8 @@ import { ElectronAPI } from '@electron-toolkit/preload' import type { OpenDialogOptions } from 'electron' +import type FileMetadata from '../main/file' + declare global { interface Window { electron: ElectronAPI @@ -20,6 +22,11 @@ declare global { reload: () => void compress: (text: string) => Promise decompress: (text: Buffer) => Promise + fileSelect: (options?: OpenDialogOptions) => Promise + fileUpload: (filePath: string) => Promise + fileDelete: (fileId: string) => Promise<{ success: boolean }> + fileBatchUpload: (filePaths: string[]) => Promise + fileBatchDelete: (fileIds: string[]) => Promise<{ success: boolean }> } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 3a5db511..32b5f392 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,5 @@ import { electronAPI } from '@electron-toolkit/preload' -import { contextBridge, ipcRenderer } from 'electron' +import { contextBridge, ipcRenderer, OpenDialogOptions } from 'electron' // Custom APIs for renderer const api = { @@ -15,7 +15,12 @@ const api = { ipcRenderer.invoke('save-file', path, content, options) }, compress: (text: string) => ipcRenderer.invoke('zip:compress', text), - decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text) + decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text), + fileSelect: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options), + fileUpload: (filePath: string) => ipcRenderer.invoke('file:upload', filePath), + fileDelete: (fileId: string) => ipcRenderer.invoke('file:delete', fileId), + fileBatchUpload: (filePaths: string[]) => ipcRenderer.invoke('file:batchUpload', filePaths), + fileBatchDelete: (fileIds: string[]) => ipcRenderer.invoke('file:batchDelete', fileIds) } // Use `contextBridge` APIs to expose Electron APIs to diff --git a/src/renderer/src/pages/files/FilesPage.tsx b/src/renderer/src/pages/files/FilesPage.tsx index 3d0a0121..012ee288 100644 --- a/src/renderer/src/pages/files/FilesPage.tsx +++ b/src/renderer/src/pages/files/FilesPage.tsx @@ -7,13 +7,23 @@ import styled from 'styled-components' const FilesPage: FC = () => { const { t } = useTranslation() + const handleSelectFile = async () => { + const files = await window.api.fileSelect({ + properties: ['openFile', 'multiSelections'] + }) + for (const file of files || []) { + const result = await window.api.fileUpload(file.path) + console.log('Selected file:', file, result) + } + } + return ( {t('files.title')} - + )