diff --git a/currentVersion.md b/currentVersion.md index 98727d28..73e24432 100644 --- a/currentVersion.md +++ b/currentVersion.md @@ -1,5 +1,7 @@ ## 🎉 [v3.3.0] 更新日志 +本次更新带来了显著的内存优化,全新的脚本扩展功能和主题系统,此外还对 UI 界面进行了全面的重构和优化。 + ### 🚀 性能优化 - 减少了 **60-70%** 的闲置内存占用和 **20%** 的打开窗口时内存占用 @@ -7,9 +9,18 @@ ### ✨ 新增功能 +#### 🛠 脚本扩展 + +使用自定义的`javascript`脚本来扩展 PicList 的功能,无需本机安装node环境,更加轻量和灵活。 + +- 全新的脚本系统,支持图床上传脚本和多种类型的通用脚本 +- 支持在上传前后、相册添加/删除前后等多个阶段运行脚本,满足更多自定义需求 +- 脚本管理页面,支持新建、编辑、删除和启用/禁用脚本 + #### ⚙️ 核心功能 - 现在支持软件内编辑配置文件并保存 +- 高级自定义图床现在支持使用脚本上传 - Windows 便携模式,无需安装运行,数据存储在程序目录下的 `data` 文件夹中,且支持自动更新。 - Linux 新增 `rpm` 安装包。 - 新增图床编辑卡片页面,解决多配置切换时的混乱问题。 @@ -28,6 +39,7 @@ - 文件浏览侧边栏名称超出宽度时支持滚动显示完整名称。 - 优化了视频播放页面的显示效果。 - 相册页面支持显示已选数量、匹配的 URL 列表和记忆过滤器状态。 +- 优化了设置页面的布局分布 - 支持浏览完整插件列表、查看详情及一键安装。 - 新增新手引导页面,首次运行自动弹出。 diff --git a/currentVersion_en.md b/currentVersion_en.md index d620217a..c67c67ec 100644 --- a/currentVersion_en.md +++ b/currentVersion_en.md @@ -1,31 +1,45 @@ ## 🎉 [v3.3.0] Release Notes -### 🚀 Performance Optimization +This update brings significant memory optimizations, a brand new script extension feature, and a theme system. Additionally, the UI has been comprehensively redesigned and optimized. -- Reduced **60-70%** idle memory usage and **20%** memory usage when opening windows. -- Optimized loading speed and browsing performance for multiple pages +### 🚀 Performance Improvements + +- Reduced idle memory usage by **60-70%** and memory usage when opening windows by **20%** +- Optimized loading speed and browsing performance on multiple pages ### ✨ New Features +#### 🛠 Script Extensions + +Use custom `javascript` scripts to extend PicList's functionality without the need to install a local Node environment, making it lighter and more flexible. + +- A brand new script system that supports image hosting upload scripts and various types of general scripts +- Supports running scripts at multiple stages such as before and after uploading, before and after adding/removing albums, meeting more customization needs +- Script management page that supports creating, editing, deleting, and enabling/disabling scripts + #### ⚙️ Core Features -- Now supports manually disabling GPU acceleration to resolve black screen or flickering issues caused by some hardware compatibility. -- Added advanced animation settings for a better UI interaction experience. +- Now supports editing and saving configuration files within the software +- Advanced custom image hosting now supports script uploads - Windows portable mode, no installation required, data is stored in the `data` folder in the program directory, and supports automatic updates. - Added `rpm` installation package for Linux. -- Added image hosting editing card page to solve confusion when switching multiple configurations. -- Added list mode support for the file browsing page. +- Added image hosting editing card page to resolve confusion when switching multiple configurations. +- Added list mode support to the file browsing page. +- Now supports manually disabling GPU acceleration to resolve black screen or flickering issues caused by some hardware compatibility. +- Added advanced animation settings for a better UI interaction experience when enabled. #### 🎨 UI Interface -- Refactored almost all business pages and optimized dozens of UI details. +- Redesigned almost all business pages and optimized dozens of UI details. - Integrated theme repository [PicList ThemeHub](https://github.com/Kuingsmile/PicList-ThemeHub), supporting custom downloads. - Provided 12 built-in themes (such as bilibili, 二次元, 极夜紫 styles). -- Redesigned all pages of the management feature - - Optimized album page card styles, clearer boundaries, and improved selection box visual effects. - - Optimized the display of multiple pages on narrow screens to avoid content overflow. +- Redesigned all pages of the management function + - Optimized album page card styles, clearer boundaries, improved selection box visual effects. + - Optimized the display of multiple pages under narrow screens to avoid content overflow. - Supported scrolling to display the full name when the file browsing sidebar name exceeds the width. -- The album page supports displaying the number of selected items, matching URL lists, and remembering filter states. + - Optimized the display effect of the video playback page. +- The album page supports displaying the number of selected items, the list of matching URLs, and remembering the filter state. +- Optimized the layout distribution of the settings page. - Supports browsing the complete plugin list, viewing details, and one-click installation. - Added a new user guide page that automatically pops up on the first run. @@ -36,9 +50,10 @@ ### 🐛 Bug Fixes -- Fixed the issue of abnormal display of the sorting dropdown box on the management page. +- Fixed the issue where the sort dropdown box on the management page displayed abnormally. - Fixed the issue where the image hosting list on the management page did not correctly highlight the currently selected item. -- Fixed the display issue of the task page in dark mode. +- Fixed the issue where the markdown preview content on the management page did not render correctly. +- Fixed the display anomaly on the task page in dark mode. - Fixed the issue where the "Set as Default Image Hosting" button status on the image hosting settings page was not updated in a timely manner. -- Fixed the issue where the independent watermark setting button status on the image hosting was not synchronized in the preprocessing settings page. -- Fixed the issue where bottom elements of some pages were obscured. +- Fixed the issue where the button status of the independent watermark setting for image hosting in the preprocessing settings page was not synchronized. +- Fixed the issue where bottom elements on some pages were obscured. diff --git a/src/main/apis/app/system/index.ts b/src/main/apis/app/system/index.ts index 64df7812..3662d981 100644 --- a/src/main/apis/app/system/index.ts +++ b/src/main/apis/app/system/index.ts @@ -26,6 +26,7 @@ import { configPaths } from '~/utils/configPaths' import { IPasteStyle, IWindowList } from '~/utils/enum' import { isMacOSVersionGreaterThanOrEqualTo } from '~/utils/getMacOSVersion' import pasteTemplate from '~/utils/pasteTemplate' +import { runScriptInStage } from '~/utils/runScript' import { hideMiniWindow, openMainWindow, openMiniWindow } from '~/utils/windowHelper' import menubarPng from '../../../../../resources/menubar.png?asset&asarUnpack' @@ -308,8 +309,8 @@ export function createTray(tooltip: string) { const rawInput = cloneDeep(files) const trayWindow = windowManager.get(IWindowList.TRAY_WINDOW) const res = await uploader.setWebContents(trayWindow?.webContents).uploadReturnCtx(files) - const imgs = res[0] ? res[0] : false - const backImgs = res[1] ? res[1] : false + const imgs = res.ctx?.output ? res.ctx.output : false + const backImgs = res.backupCtx?.output ? res.backupCtx.output : false const deleteLocalFile = allConfig.settings?.deleteLocalFile || false if (imgs !== false) { const pasteText: string[] = [] @@ -334,7 +335,8 @@ export function createTray(tooltip: string) { notification.show() }, i * 100) } - await GalleryDB.getInstance().insert(imgs[i]) + const inserted = await GalleryDB.getInstance().insert(imgs[i]) + runScriptInStage('onUploadSuccess', res.ctx || picgo, { galleryItem: inserted }) } handleCopyUrl(pasteText.join('\n')) trayWindow?.webContents.send('dragFiles', imgs) diff --git a/src/main/apis/app/theme/index.ts b/src/main/apis/app/theme/index.ts index faddd217..d791fabd 100644 --- a/src/main/apis/app/theme/index.ts +++ b/src/main/apis/app/theme/index.ts @@ -1,46 +1,56 @@ import path from 'node:path' import { themesDir } from '@core/datastore/dirs' -import * as fsWalk from '@nodelib/fs.walk' import AdmZip from 'adm-zip' import axios from 'axios' import fs from 'fs-extra' import { randomStringGenerator } from '@/manage/utils/common' +import logger from '~/apis/core/picgo/logger' import { IWindowList } from '~/utils/enum' import windowManager from '../window/windowManager' export async function resolveThemes(): Promise<{ key: string; label: string }[]> { - const files = fsWalk.walkSync(themesDir(), { - followSymbolicLinks: true, - fs, - stats: true, - throwErrorOnBrokenSymbolicLink: false, - }) - const result: string[] = [] - files.forEach(item => { - if (item.stats?.isFile()) { - result.push(path.basename(item.path)) - } - }) - const themes = await Promise.all( - result - .filter(file => file.endsWith('.css')) - .map(async file => { - const css = (await fs.readFile(path.join(themesDir(), file), 'utf-8')) || '' - let name = file - if (css.startsWith('/*')) { - name = css.split('\n')[0].replace('/*', '').replace('*/', '').trim() || file + const dir = themesDir() + const entries = await fs.readdir(dir, { withFileTypes: true }) + const themePromises = entries + .filter(entry => entry.isFile() && entry.name.endsWith('.css')) + .map(async entry => { + const filePath = path.join(dir, entry.name) + + let label = entry.name + let fd + try { + fd = await fs.open(filePath, 'r') + const buffer = Buffer.alloc(256) + const { bytesRead } = await fs.read(fd, buffer, 0, 256, 0) + const content = buffer.toString('utf8', 0, bytesRead) + const match = content.match(/^\/\*\s*(.*?)\s*\*\//) + if (match && match[1]) { + label = match[1].trim() } - return { key: file, label: name } - }), - ) - if (themes.find(theme => theme.key === 'default.css')) { - return themes + } catch (e: any) { + logger.error(e) + } finally { + if (fd !== undefined) await fs.close(fd) + } + + return { key: entry.name, label } + }) + + const themes = await Promise.all(themePromises) + + const hasDefault = themes.some(t => t.key === 'default.css') + if (!hasDefault) { + themes.unshift({ key: 'default.css', label: '默认' }) } else { - return [{ key: 'default.css', label: '默认' }, ...themes] + const idx = themes.findIndex(t => t.key === 'default.css') + const [defaultTheme] = themes.splice(idx, 1) + themes.unshift(defaultTheme) } + + return themes } export async function fetchThemes(): Promise { diff --git a/src/main/apis/app/uploader/apis.ts b/src/main/apis/app/uploader/apis.ts index a8d9ad41..1b48d900 100644 --- a/src/main/apis/app/uploader/apis.ts +++ b/src/main/apis/app/uploader/apis.ts @@ -11,8 +11,9 @@ import { handleCopyUrl, handleUrlEncodeWithSetting } from '~/utils/common' import { configPaths } from '~/utils/configPaths' import { IPasteStyle, IWindowList } from '~/utils/enum' import pasteTemplate from '~/utils/pasteTemplate' +import { runScriptInStage } from '~/utils/runScript' -const handleClipboardUploadingReturnCtx = async (img?: IUploadOption): Promise<(ImgInfo[] | false)[]> => { +const handleClipboardUploadingReturnCtx = async (img?: IUploadOption): Promise => { const useBuiltinClipboardConfig = picgo.getConfig(configPaths.settings.useBuiltinClipboard) const useBuiltinClipboard = useBuiltinClipboardConfig === undefined ? true : !!useBuiltinClipboardConfig const win = windowManager.getAvailableWindow() @@ -26,8 +27,8 @@ export const uploadClipboardFiles = async (): Promise => { let img: ImgInfo[] | false = false let backImg: ImgInfo[] | false = false const res = await handleClipboardUploadingReturnCtx() - img = res[0] ? res[0] : false - backImg = res[1] ? res[1] : false + img = res.ctx?.output ? res.ctx.output : false + backImg = res.backupCtx?.output ? res.backupCtx.output : false const allConfig = picgo.getConfig() || {} if (img !== false) { if (img.length > 0) { @@ -50,6 +51,7 @@ export const uploadClipboardFiles = async (): Promise => { }, 100) } const inserted = await GalleryDB.getInstance().insert(img[0]) + runScriptInStage('onUploadSuccess', res.ctx || picgo, { galleryItem: inserted }) // trayWindow just be created in mac/windows, not in linux const trayWindow = windowManager.get(IWindowList.TRAY_WINDOW) trayWindow?.webContents?.send('clipboardFiles', []) @@ -93,8 +95,8 @@ export const uploadChoosedFiles = async ( let imgs: ImgInfo[] | false = false let backImgs: ImgInfo[] | false = false const res = await uploader.setWebContents(webContents).uploadReturnCtx(input) - imgs = res[0] ? res[0] : false - backImgs = res[1] ? res[1] : false + imgs = res.ctx?.output ? res.ctx.output : false + backImgs = res.backupCtx?.output ? res.backupCtx.output : false const result = [] const allConfig = picgo.getConfig() || {} if (imgs !== false) { @@ -140,6 +142,7 @@ export const uploadChoosedFiles = async ( } } const inserted = await GalleryDB.getInstance().insert(imgs[i]) + runScriptInStage('onUploadSuccess', res.ctx || picgo, { galleryItem: inserted }) result.push({ url: handleUrlEncodeWithSetting(inserted.imgUrl!), fullResult: inserted, diff --git a/src/main/apis/app/uploader/index.ts b/src/main/apis/app/uploader/index.ts index b2142e85..37bec34d 100644 --- a/src/main/apis/app/uploader/index.ts +++ b/src/main/apis/app/uploader/index.ts @@ -118,15 +118,15 @@ class Uploader { return filePath } - async uploadWithBuildInClipboardReturnCtx(img?: IUploadOption): Promise<(ImgInfo[] | false)[]> { + async uploadWithBuildInClipboardReturnCtx(img?: IUploadOption): Promise { let imgPath: string | false = false try { imgPath = await this.getClipboardImagePath() - if (!imgPath) return [false, false] + if (!imgPath) return { ctx: undefined, backupCtx: undefined } return await this.uploadReturnCtx(img ?? [imgPath]) } catch (e: any) { logger.error(e) - return [false, false] + return { ctx: undefined, backupCtx: undefined } } finally { if (imgPath && imgPath.startsWith(path.join(picgo.baseDir, CLIPBOARD_IMAGE_FOLDER))) { fs.remove(imgPath) @@ -134,24 +134,24 @@ class Uploader { } } - async uploadReturnCtx(img?: IUploadOption): Promise<(ImgInfo[] | false)[]> { + async uploadReturnCtx(img?: IUploadOption): Promise { try { - const result = [false, false] as (ImgInfo[] | false)[] + const result = { ctx: undefined, backupCtx: undefined } as IuploadReturnCtxResult const res = await picgo.uploadReturnCtx(img) const allConfig = picgo.getConfig() || {} - if (Array.isArray(res.output) && res.output.some((item: ImgInfo) => item.imgUrl)) { - res.output.forEach((item: ImgInfo) => { + if (Array.isArray(res.ctx?.output) && res.ctx?.output.some((item: ImgInfo) => item.imgUrl)) { + res.ctx.output.forEach((item: ImgInfo) => { item.config = JSON.parse(JSON.stringify(allConfig.picBed?.[item.type!])) }) - result[0] = res.output + result.ctx = res.ctx } - if (Array.isArray(res.backupOutput) && res.backupOutput.some((item: ImgInfo) => item.imgUrl)) { - res.backupOutput.forEach((item: ImgInfo) => { + if (Array.isArray(res.backupCtx?.output) && res.backupCtx?.output.some((item: ImgInfo) => item.imgUrl)) { + res.backupCtx.output.forEach((item: ImgInfo) => { item.config = JSON.parse(JSON.stringify(allConfig.picBed?.[item.type!])) }) - result[1] = res.backupOutput + result.backupCtx = res.backupCtx } return result } catch (e: any) { @@ -163,7 +163,7 @@ class Uploader { clickToCopy: true, }) }, 500) - return [false, false] + return { ctx: undefined, backupCtx: undefined } as IuploadReturnCtxResult } finally { ipcMain.removeAllListeners(GET_RENAME_FILE_NAME) } diff --git a/src/main/apis/core/datastore/dirs.ts b/src/main/apis/core/datastore/dirs.ts index 006d51a2..af241941 100644 --- a/src/main/apis/core/datastore/dirs.ts +++ b/src/main/apis/core/datastore/dirs.ts @@ -43,6 +43,10 @@ export function defaultDir() { return userDataDir() } +export function scriptsDir() { + return path.join(dataDir(), 'scripts') +} + export function defaultConfigPath() { if (isPortable()) { return path.join(exeDir(), 'data', 'data.json') diff --git a/src/main/apis/gui/index.ts b/src/main/apis/gui/index.ts index 157f62f2..4596696e 100644 --- a/src/main/apis/gui/index.ts +++ b/src/main/apis/gui/index.ts @@ -13,6 +13,7 @@ import { T as $t } from '~/i18n' import { handleCopyUrl } from '~/utils/common' import { IPasteStyle } from '~/utils/enum' import pasteTemplate from '~/utils/pasteTemplate' +import { runScriptInStage } from '~/utils/runScript' // Cross-process support may be required in the future class GuiApi implements IGuiApi { @@ -74,8 +75,8 @@ class GuiApi implements IGuiApi { const webContents = this.getWebcontentsByWindowId(this.windowId) const rawInput = cloneDeep(input) const res = await uploader.setWebContents(webContents!).uploadReturnCtx(input) - const imgs = res[0] ? res[0] : false - const backImgs = res[1] ? res[1] : false + const imgs = res.ctx?.output ? res.ctx.output : false + const backImgs = res.backupCtx?.output ? res.backupCtx.output : false let result: ImgInfo[] = [] const allConfig = picgo.getConfig() || {} if (imgs !== false) { @@ -103,7 +104,8 @@ class GuiApi implements IGuiApi { notification.show() }, i * 100) } - await GalleryDB.getInstance().insert(imgs[i]) + const inserted = await GalleryDB.getInstance().insert(imgs[i]) + runScriptInStage('onUploadSuccess', res.ctx || picgo, { galleryItem: inserted }) } handleCopyUrl(pasteText.join('\n')) webContents?.send('uploadFiles') diff --git a/src/main/events/rpc/routes/gallery/index.ts b/src/main/events/rpc/routes/gallery/index.ts index b10b73a4..5972cad9 100644 --- a/src/main/events/rpc/routes/gallery/index.ts +++ b/src/main/events/rpc/routes/gallery/index.ts @@ -6,6 +6,7 @@ import { clipboard } from 'electron' import { RPCRouter } from '~/events/rpc/router' import { ICOREBuildInEvent, IPasteStyle, IRPCActionType, IRPCType } from '~/utils/enum' import pasteTemplate from '~/utils/pasteTemplate' +import { runScriptInStage } from '~/utils/runScript' interface IFilter { orderBy?: 'asc' | 'desc' limit?: number @@ -38,6 +39,7 @@ const galleryRoutes = [ action: IRPCActionType.GALLERY_REMOVE_FILES, handler: async (_: IIPCEvent, args: [files: ImgInfo[]]) => { setTimeout(() => { + runScriptInStage('onGalleryRemove', picgo, { galleryItem: args[0] }) picgo.emit(ICOREBuildInEvent.REMOVE, args[0], GuiApi.getInstance()) }, 500) }, diff --git a/src/main/events/rpc/routes/setting/mainApp.ts b/src/main/events/rpc/routes/setting/mainApp.ts index 369c779f..4e094216 100644 --- a/src/main/events/rpc/routes/setting/mainApp.ts +++ b/src/main/events/rpc/routes/setting/mainApp.ts @@ -1,12 +1,15 @@ import path from 'node:path' -import { dataDir } from '@core/datastore/dirs' +import { dataDir, scriptsDir } from '@core/datastore/dirs' import picgo from '@core/picgo' import { IpcMainEvent, shell } from 'electron' import fs from 'fs-extra' +import logger from '~/apis/core/picgo/logger' import { isAutoStartEnabled, setAutoStart } from '~/utils/autoStart' +import { getDirectoryTree } from '~/utils/common' import { IRPCActionType, IRPCType } from '~/utils/enum' +import { runScript } from '~/utils/runScript' const STORE_PATH = dataDir() @@ -57,9 +60,110 @@ export default [ action: IRPCActionType.WRITE_FILE_CONTENT, handler: async (_: IIPCEvent, args: [fileName: string, content: string]) => { const abFilePath = path.join(STORE_PATH, args[0]) + fs.ensureDirSync(path.dirname(abFilePath)) fs.writeFileSync(abFilePath, args[1], 'utf-8') }, }, + { + action: IRPCActionType.RUN_SCRIPT_FILE, + handler: async (_: IIPCEvent, args: [fileName: string[]]) => { + const abFilePath = path.join(scriptsDir(), ...args[0]) + if (!fs.existsSync(abFilePath)) { + throw new Error('Script file does not exist') + } + const scriptContent = fs.readFileSync(abFilePath, 'utf-8') + try { + await runScript(picgo, scriptContent, {}) + logger.info(`Script ${args[0].join('/')} executed successfully`) + return 'Script executed successfully' + } catch (e) { + return Error(`Script execution failed: ${e}`) + } + }, + type: IRPCType.INVOKE, + }, + { + action: IRPCActionType.CREATE_SCRIPTS_FILE, + handler: async (_: IIPCEvent, args: [fileName: string[], content: string]) => { + const abFilePath = path.join(scriptsDir(), ...args[0]) + fs.ensureDirSync(path.dirname(abFilePath)) + fs.writeFileSync(abFilePath, args[1], 'utf-8') + }, + }, + { + action: IRPCActionType.READ_SCRIPTS_FILE, + handler: async (_: IIPCEvent, args: [fileName: string[]]) => { + const abFilePath = path.join(scriptsDir(), ...args[0]) + if (!fs.existsSync(abFilePath)) { + return null + } + return fs.readFileSync(abFilePath, 'utf-8') + }, + type: IRPCType.INVOKE, + }, + { + action: IRPCActionType.LIST_SCRIPTS_FILES, + handler: async (_: IIPCEvent, args: [dirPath: string[]]) => { + const dir = scriptsDir() + const targetDir = path.join(dir, ...(args[0] || [])) + + if (!(await fs.pathExists(targetDir))) { + return {} + } + + try { + return await getDirectoryTree(targetDir) + } catch (error) { + console.error('Failed to list scripts:', error) + return {} + } + }, + type: IRPCType.INVOKE, + }, + { + action: IRPCActionType.WRITE_SCRIPT_FILE, + handler: async (_: IIPCEvent, args: [fileName: string[], content: string]) => { + const abFilePath = path.join(scriptsDir(), ...args[0]) + fs.ensureDirSync(path.dirname(abFilePath)) + fs.writeFileSync(abFilePath, args[1], 'utf-8') + }, + }, + { + action: IRPCActionType.DELETE_SCRIPTS_FILE, + handler: async (_: IIPCEvent, args: [fileName: string[]]) => { + const abFilePath = path.join(scriptsDir(), ...args[0]) + if (fs.existsSync(abFilePath)) { + fs.unlinkSync(abFilePath) + } + }, + }, + { + action: IRPCActionType.GET_FILES_STAT, + handler: async (_: IIPCEvent, [filePaths, type]: [string[][], 'scripts' | 'config']) => { + const basePath = type === 'scripts' ? scriptsDir() : STORE_PATH + const result: IObj[] = [] + const statPromises = filePaths.map(async filePath => { + const absolutePath = path.join(basePath, ...filePath) + try { + return await fs.promises.stat(absolutePath) + } catch { + return undefined + } + }) + const statsResults = await Promise.all(statPromises) + + for (const [index, item] of filePaths.entries()) { + result.push({ + fileName: item[item.length - 1], + stats: statsResults[index], + category: item.length > 1 ? item.slice(0, -1).join('.') : '', + filePath: item, + }) + } + return result + }, + type: IRPCType.INVOKE, + }, { action: IRPCActionType.PICLIST_OPEN_DIRECTORY, handler: async (_: IIPCEvent, args: [dirPath?: string, inStorePath?: boolean]) => { diff --git a/src/main/events/rpc/routes/tray/index.ts b/src/main/events/rpc/routes/tray/index.ts index d266f6f4..55586074 100644 --- a/src/main/events/rpc/routes/tray/index.ts +++ b/src/main/events/rpc/routes/tray/index.ts @@ -32,8 +32,8 @@ const trayRoutes = [ const trayWindow = windowManager.get(IWindowList.TRAY_WINDOW) // macOS use builtin clipboard is OK const res = await uploader.setWebContents(trayWindow?.webContents).uploadWithBuildInClipboardReturnCtx() - const img = res[0] ? res[0] : false - const backupImgs = res[1] ? res[1] : false + const img = res.ctx?.output ? res.ctx.output : false + const backupImgs = res.backupCtx?.output ? res.backupCtx.output : false const allConfig = picgo.getConfig() || {} if (img !== false) { const pasteStyle = allConfig.settings?.pasteStyle || IPasteStyle.MARKDOWN diff --git a/src/main/lifeCycle/index.ts b/src/main/lifeCycle/index.ts index 17f9f4eb..e00ada98 100644 --- a/src/main/lifeCycle/index.ts +++ b/src/main/lifeCycle/index.ts @@ -34,7 +34,7 @@ import { IRemoteNoticeTriggerHook, ISartMode, IWindowList } from '~/utils/enum' import { getUploadFiles } from '~/utils/handleArgv' import { initI18n } from '~/utils/handleI18n' import { notificationList } from '~/utils/notification' -import { MemoryMonitor } from '~/utils/performanceOptimizer' +import { runScriptInStage } from '~/utils/runScript' import { CLIPBOARD_IMAGE_FOLDER } from '~/utils/static' import updateChecker from '~/utils/updateChecker' const isDevelopment = process.env.NODE_ENV !== 'production' @@ -146,9 +146,6 @@ class LifeCycle { notice.show() } } - if (isDevelopment) { - MemoryMonitor.start() - } await remoteNoticeHandler.init() remoteNoticeHandler.triggerHook(IRemoteNoticeTriggerHook.APP_START) if (startMode === ISartMode.MINI && process.platform !== 'darwin') { @@ -196,6 +193,7 @@ class LifeCycle { } const clipboardDir = path.join(picgo.baseDir, CLIPBOARD_IMAGE_FOLDER) fs.emptyDir(clipboardDir) + runScriptInStage('onSoftwareOpen', picgo, {}) } app.whenReady().then(readyFunction) } @@ -251,7 +249,7 @@ class LifeCycle { server.shutdown() webServer.stop() stopFileServer() - MemoryMonitor.stop() + runScriptInStage('onSoftwareClose', picgo, {}) }) // Exit cleanly on request from parent process in development mode. if (isDevelopment) { diff --git a/src/main/utils/common.ts b/src/main/utils/common.ts index 36641f50..64e3e7c8 100644 --- a/src/main/utils/common.ts +++ b/src/main/utils/common.ts @@ -375,3 +375,23 @@ export function getUploaderType(ctx: IPicGo): { const id = picBedConfig._id || '' return { picBed, id } } + +export async function getDirectoryTree(currentPath: string): Promise> { + const result: Record = {} + + const entries = await fs.readdir(currentPath, { withFileTypes: true }) + + await Promise.all( + entries.map(async entry => { + const fullPath = path.join(currentPath, entry.name) + + if (entry.isDirectory()) { + result[entry.name] = await getDirectoryTree(fullPath) + } else if (entry.isFile()) { + result[entry.name] = null + } + }), + ) + + return result +} diff --git a/src/main/utils/enum.ts b/src/main/utils/enum.ts index 4c98cfb8..05d84d7c 100644 --- a/src/main/utils/enum.ts +++ b/src/main/utils/enum.ts @@ -87,6 +87,7 @@ export const IRPCActionType = { SET_CURRENT_LANGUAGE: 'SET_CURRENT_LANGUAGE', OPEN_WINDOW: 'OPEN_WINDOW', OPEN_MINI_WINDOW: 'OPEN_MINI_WINDOW', + RELOAD_WINDOW: 'RELOAD_WINDOW', CLOSE_WINDOW: 'CLOSE_WINDOW', MINIMIZE_WINDOW: 'MINIMIZE_WINDOW', SHOW_MINI_PAGE_MENU: 'SHOW_MINI_PAGE_MENU', @@ -123,9 +124,17 @@ export const IRPCActionType = { PICLIST_OPEN_DIRECTORY: 'PICLIST_OPEN_DIRECTORY', PICLIST_AUTO_START: 'PICLIST_AUTO_START', PICLIST_AUTO_START_STATUS: 'PICLIST_AUTO_START_STATUS', + + // file operation rpc READ_FILE_CONTENT: 'READ_FILE_CONTENT', WRITE_FILE_CONTENT: 'WRITE_FILE_CONTENT', - RELOAD_WINDOW: 'RELOAD_WINDOW', + CREATE_SCRIPTS_FILE: 'CREATE_SCRIPTS_FILE', + READ_SCRIPTS_FILE: 'READ_SCRIPTS_FILE', + LIST_SCRIPTS_FILES: 'LIST_SCRIPTS_FILES', + WRITE_SCRIPT_FILE: 'WRITE_SCRIPT_FILE', + GET_FILES_STAT: 'GET_FILES_STAT', + DELETE_SCRIPTS_FILE: 'DELETE_SCRIPTS_FILE', + RUN_SCRIPT_FILE: 'RUN_SCRIPT_FILE', // shortkey setting rpc SHORTKEY_UPDATE: 'SHORTKEY_UPDATE', diff --git a/src/main/utils/runScript.ts b/src/main/utils/runScript.ts new file mode 100644 index 00000000..3f7b789b --- /dev/null +++ b/src/main/utils/runScript.ts @@ -0,0 +1,123 @@ +import crypto from 'node:crypto' +import os from 'node:os' +import path from 'node:path' +import vm from 'node:vm' + +import { scriptsDir } from '@core/datastore/dirs' +import picgo from '@core/picgo' +import logger from '@core/picgo/logger' +import axios from 'axios' +import fs from 'fs-extra' +import { IPicGo } from 'piclist' + +export const scriptLifecycleStages = [ + 'onSoftwareOpen', + 'onSoftwareClose', + 'preProcess', + 'beforeTransform', + 'transform', + 'beforeUpload', + 'upload', + 'afterUpload', + 'onUploadSuccess', + 'onGalleryRemove', + 'manualTrigger', + 'uploader.advancedplist', +] as const + +function format(data: unknown): string { + if (data instanceof Error) { + return `${data.name}: ${data.message}\n${data.stack}` + } + try { + return JSON.stringify(data) + } catch { + return String(data) + } +} + +export async function runScript(ctx: IPicGo, script: string, extra: Record): Promise { + try { + const base64Decode = (str: string): string => Buffer.from(str, 'base64').toString('utf-8') + const base64Encode = (data: Buffer | string): string => + (Buffer.isBuffer(data) ? data : Buffer.from(String(data))).toString('base64') + const exposedAPI = { + ctx, + extra, + console: Object.freeze({ + log: (...args: unknown[]) => ctx.log.info(args.map(format).join(' ')), + info: (...args: unknown[]) => ctx.log.info(args.map(format).join(' ')), + error: (...args: unknown[]) => ctx.log.error(args.map(format).join(' ')), + debug: (...args: unknown[]) => ctx.log.debug(args.map(format).join(' ')), + }), + axios, + crypto, + setTimeout, + setInterval, + clearTimeout, + clearInterval, + fs, + path, + base64Decode, + base64Encode, + os, + Buffer, + } + vm.createContext(exposedAPI) + ctx.log.info('start to run script') + vm.runInContext(script, exposedAPI) + const promise = vm.runInContext( + `(async () => { + const result = main(ctx, extra) + if (result instanceof Promise) return await result + return result + })()`, + exposedAPI, + ) + const result = await promise + ctx.log.info('script executed successfully') + return result + } catch (e) { + ctx.log.error(`script execution failed: ${e}`) + throw e + } +} + +export async function runScriptInStage(stage: string, ctx: IPicGo, extra: Record): Promise { + const baseDir = scriptsDir() + const enabledPaths: string[] = [] + let scriptDir: string + const allConfig = picgo.getConfig() || {} + const disabledList: string[] = allConfig.scripts?.disabledList || [] + if (stage === 'uploader.advancedplist') { + scriptDir = path.join(baseDir, 'uploader', 'advancedplist') + stage = 'uploader/advancedplist' + } else { + scriptDir = path.join(baseDir, stage) + } + + const files = await fs.readdir(scriptDir).catch(() => []) + if (files.length === 0) { + return + } + for (const file of files) { + if (file.endsWith('.js')) { + if (!disabledList.includes(`${stage}/${file}`)) { + enabledPaths.push(path.join(scriptDir, file)) + } + } + } + if (enabledPaths.length === 0) { + logger.info(`no enabled scripts found in stage ${stage}`) + return + } + for (const scriptPath of enabledPaths) { + const scriptContent = fs.readFileSync(scriptPath, 'utf-8') + try { + await runScript(ctx, scriptContent, extra) + logger.info(`script ${scriptPath} in stage ${stage} executed successfully`) + } catch (e) { + logger.error(`script ${scriptPath} in stage ${stage} execution failed: ${e}`) + } + } +} diff --git a/src/main/utils/uploadTaskQueue.ts b/src/main/utils/uploadTaskQueue.ts index fc71a22d..158a941c 100644 --- a/src/main/utils/uploadTaskQueue.ts +++ b/src/main/utils/uploadTaskQueue.ts @@ -238,8 +238,8 @@ class UploadTaskQueueManager { const rawInput = cloneDeep(input) const res = await uploader.setWebContents(webContents).uploadReturnCtx(input) - const imgs = res[0] ? res[0] : false - const backupImgs = res[1] ? res[1] : false + const imgs = res.ctx?.output ? res.ctx.output : false + const backupImgs = res.backupCtx?.output ? res.backupCtx.output : false const allConfig = picgo.getConfig() || {} if (imgs !== false && imgs.length > 0) { diff --git a/src/renderer/components/Editor.vue b/src/renderer/components/Editor.vue index 1ec7416d..d250b2fd 100644 --- a/src/renderer/components/Editor.vue +++ b/src/renderer/components/Editor.vue @@ -21,14 +21,14 @@ const editorRef = ref(null) const view = shallowRef(null) onMounted(() => { + const languageExtension = props.language === 'json' ? json() : javascript() const startState = EditorState.create({ doc: props.modelValue, extensions: [ lineNumbers(), history(), keymap.of([...defaultKeymap, ...historyKeymap]), - json(), - javascript(), + languageExtension, oneDark, search({ top: true }), keymap.of([...searchKeymap]), diff --git a/src/renderer/components/UnifiedConfigForm.vue b/src/renderer/components/UnifiedConfigForm.vue index 4c57f261..1a203eb3 100644 --- a/src/renderer/components/UnifiedConfigForm.vue +++ b/src/renderer/components/UnifiedConfigForm.vue @@ -95,6 +95,7 @@ + diff --git a/src/renderer/components/common/MultiSelect.vue b/src/renderer/components/common/MultiSelect.vue index 6feca460..1cdbc12a 100644 --- a/src/renderer/components/common/MultiSelect.vue +++ b/src/renderer/components/common/MultiSelect.vue @@ -1,5 +1,5 @@ @@ -146,6 +228,11 @@ import { computed, onBeforeMount, ref } from 'vue' import { useI18n } from 'vue-i18n' import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router' +import CustomButton from '@/components/common/CustomButton.vue' +import CustomInput from '@/components/common/CustomInput.vue' +import CustomModal from '@/components/common/CustomModal.vue' +import SettingCard from '@/components/common/SettingCard.vue' +import Editor from '@/components/Editor.vue' import useConfirm from '@/hooks/useConfirm' import { usePicBed } from '@/hooks/useGlobal' import useMessage from '@/hooks/useMessage' @@ -153,8 +240,9 @@ import { PICBEDS_PAGE, UPLOADER_CONFIG_PAGE } from '@/router/config' import $bus from '@/utils/bus' import { configPaths } from '@/utils/configPaths' import { SHOW_INPUT_BOX, SHOW_INPUT_BOX_RESPONSE } from '@/utils/constant' -import { saveConfig } from '@/utils/dataSender' -import { IRPCActionType } from '@/utils/enum' +import { getConfig, saveConfig } from '@/utils/dataSender' +import { II18nLanguage, IRPCActionType } from '@/utils/enum' +import { defaultScriptTemplate, defaultScriptTemplateEn } from '@/utils/static' const { t } = useI18n() const message = useMessage() @@ -166,6 +254,14 @@ const favoritePicbeds = useStorage('favorite-picbeds', [] const type = ref('') const curConfigList = ref([]) const defaultConfigId = ref('') +const scriptsListVisible = ref(false) +const scriptsList = ref([]) +const editorVisible = ref(false) +const editorContent = ref('') +const editingScriptName = ref('') +const newScriptNameVisible = ref(false) +const newScriptName = ref('') +const deleteScriptListVisible = ref(false) const picBedName = computed(() => { if (!picBedG.value || picBedG.value.length === 0) { @@ -316,6 +412,122 @@ function setDefaultPicBed(type: string) { message.success(t('pages.uploaderConfig.setSuccess')) } +async function getScriptsList() { + const scriptsFiles = await window.electron.triggerRPC>(IRPCActionType.LIST_SCRIPTS_FILES, [ + 'uploader', + 'advancedplist', + ]) + scriptsList.value = Object.keys(scriptsFiles || {}).filter(fileName => fileName.endsWith('.js')) +} + +async function openScriptsList() { + if (scriptsListVisible.value) { + scriptsListVisible.value = false + return + } + await getScriptsList() + if (scriptsList.value.length === 0) { + message.info(t('pages.scripts.noScriptsFound')) + return + } + scriptsListVisible.value = true +} + +function openNewScriptsNameDialog() { + newScriptName.value = '' + newScriptNameVisible.value = true +} + +async function getTemplate() { + const lang = (await getConfig(configPaths.settings.language)) || II18nLanguage.ZH_CN + if (lang === II18nLanguage.ZH_CN || lang === II18nLanguage.ZH_TW) { + return defaultScriptTemplate + } else { + return defaultScriptTemplateEn + } +} + +async function openEditScripts(scriptName: string, mode: 'edit' | 'new' = 'edit') { + editingScriptName.value = scriptName + if (mode === 'edit') { + const filePath = ['uploader', 'advancedplist', editingScriptName.value] + const content = (await window.electron.triggerRPC(IRPCActionType.READ_SCRIPTS_FILE, filePath)) || '' + editorContent.value = content + } else { + editorContent.value = await getTemplate() + } + editorVisible.value = true +} + +async function saveEditorContent() { + const file = ['uploader', 'advancedplist', editingScriptName.value] + const content = editorContent.value.trim() + try { + window.electron.sendRPC(IRPCActionType.WRITE_SCRIPT_FILE, file, content) + message.success(t('pages.settings.advanced.saveFileSuccess')) + await getScriptsList() + } catch (error) { + console.error('Failed to save file:', error) + message.error(t('pages.settings.advanced.saveFileFailed')) + } + editorVisible.value = false +} + +function handleNewScriptNameConfirm() { + let trimmedName = newScriptName.value.trim() + trimmedName = trimmedName.endsWith('.js') ? trimmedName : `${trimmedName}.js` + if (!trimmedName) { + message.error(t('pages.scripts.pleaseEnterScriptName')) + return + } + if (scriptsList.value.includes(trimmedName)) { + message.error(t('pages.scripts.duplicateScriptNameError')) + return + } + newScriptNameVisible.value = false + openEditScripts(trimmedName, 'new') +} + +function handleScriptClick(scriptName: string) { + scriptsListVisible.value = false + openEditScripts(scriptName) +} + +function openDeleteScriptsList() { + if (deleteScriptListVisible.value) { + deleteScriptListVisible.value = false + return + } + getScriptsList().then(() => { + if (scriptsList.value.length === 0) { + message.info(t('pages.scripts.noScriptsFound')) + return + } + deleteScriptListVisible.value = true + }) +} + +async function deleteScript(scriptName: string) { + const result = await confirm({ + title: t('pages.scripts.deleteScriptTitle'), + message: t('pages.scripts.deleteScriptConfirm', { name: scriptName }), + type: 'warning', + confirmButtonText: t('common.confirm'), + cancelButtonText: t('common.cancel'), + center: true, + }) + if (!result) return + try { + const filePath = ['uploader', 'advancedplist', scriptName] + window.electron.sendRPC(IRPCActionType.DELETE_SCRIPTS_FILE, filePath) + message.success(t('pages.scripts.deleteSuccess')) + await getScriptsList() + } catch (error) { + console.error('Failed to delete script file:', error) + message.error(t('pages.scripts.deleteFailed')) + } +} + onBeforeRouteUpdate((to, _, next) => { if (to.params.type && to.name === UPLOADER_CONFIG_PAGE) { type.value = to.params.type as string @@ -327,6 +539,7 @@ onBeforeRouteUpdate((to, _, next) => { onBeforeMount(() => { type.value = route.params.type as string getCurrentConfigList() + getScriptsList() }) diff --git a/src/renderer/router/config.ts b/src/renderer/router/config.ts index d0d8ec5e..447ce5b1 100644 --- a/src/renderer/router/config.ts +++ b/src/renderer/router/config.ts @@ -19,3 +19,4 @@ export const UPDATE_PAGE = 'UpdatePage' export const UPLOAD_PAGE = 'UploadPage' export const UPLOADER_CONFIG_PAGE = 'UploaderConfigPage' export const MANAGE_EDIT_PAGE = 'ManageEditPage' +export const SCRIPT_PAGE = 'ScriptPage' diff --git a/src/renderer/router/index.ts b/src/renderer/router/index.ts index bce293dd..8431ca2c 100644 --- a/src/renderer/router/index.ts +++ b/src/renderer/router/index.ts @@ -12,6 +12,7 @@ import PicBedsPage from '@/pages/PicBed.vue' import SettingPage from '@/pages/PicGoSetting.vue' import PluginPage from '@/pages/Plugin.vue' import RenamePage from '@/pages/RenamePage.vue' +import ScriptPage from '@/pages/ScriptPage.vue' import ShortKeyPage from '@/pages/ShortKey.vue' import Toolbox from '@/pages/Toolbox.vue' import TrayPage from '@/pages/TrayPage.vue' @@ -104,6 +105,11 @@ export default createRouter({ component: PluginPage, name: config.PLUGIN_PAGE, }, + { + path: 'scripts', + component: ScriptPage, + name: config.SCRIPT_PAGE, + }, { path: 'shortKey', component: ShortKeyPage, diff --git a/src/renderer/utils/configPaths.ts b/src/renderer/utils/configPaths.ts index 3c329cc5..ce98f3f5 100644 --- a/src/renderer/utils/configPaths.ts +++ b/src/renderer/utils/configPaths.ts @@ -93,6 +93,9 @@ export interface IConfigStruct { needReload: boolean picgoPlugins: IPicGoPlugins uploader: IUploaderConfig + scripts: { + disabledList: string[] + } buildIn: { compress: IBuildInCompressOptions watermark: IBuildInWaterMarkOptions @@ -189,6 +192,9 @@ export const configPaths = { needReload: 'needReload', picgoPlugins: 'picgoPlugins', uploader: 'uploader', + scripts: { + disabledList: 'scripts.disabledList', + }, buildIn: { _name: 'buildIn', compress: 'buildIn.compress', diff --git a/src/renderer/utils/enum.ts b/src/renderer/utils/enum.ts index 5c4d27f8..6c085661 100644 --- a/src/renderer/utils/enum.ts +++ b/src/renderer/utils/enum.ts @@ -30,6 +30,7 @@ export const IRPCActionType = { OPEN_WINDOW: 'OPEN_WINDOW', OPEN_MINI_WINDOW: 'OPEN_MINI_WINDOW', CLOSE_WINDOW: 'CLOSE_WINDOW', + RELOAD_WINDOW: 'RELOAD_WINDOW', MINIMIZE_WINDOW: 'MINIMIZE_WINDOW', SHOW_MINI_PAGE_MENU: 'SHOW_MINI_PAGE_MENU', SHOW_MAIN_PAGE_MENU: 'SHOW_MAIN_PAGE_MENU', @@ -41,6 +42,7 @@ export const IRPCActionType = { MAIN_WINDOW_ON_TOP: 'MAIN_WINDOW_ON_TOP', UPDATE_MINI_WINDOW_ICON: 'UPDATE_MINI_WINDOW_ICON', REFRESH_SETTING_WINDOW: 'REFRESH_SETTING_WINDOW', + // picbed RPC PICBED_GET_PICBED_CONFIG: 'PICBED_GET_PICBED_CONFIG', PICBED_GET_CONFIG_LIST: 'PICBED_GET_CONFIG_LIST', @@ -51,6 +53,8 @@ export const IRPCActionType = { UPLOADER_UPDATE_CONFIG: 'UPLOADER_UPDATE_CONFIG', UPLOADER_RESET_CONFIG: 'UPLOADER_RESET_CONFIG', DELETE_ALL_API: 'DELETE_ALL_API', + GET_FILES_STAT: 'GET_FILES_STAT', + RUN_SCRIPT_FILE: 'RUN_SCRIPT_FILE', // toolbox rpc TOOLBOX_CHECK: 'TOOLBOX_CHECK', @@ -65,9 +69,15 @@ export const IRPCActionType = { PICLIST_OPEN_DIRECTORY: 'PICLIST_OPEN_DIRECTORY', PICLIST_AUTO_START: 'PICLIST_AUTO_START', PICLIST_AUTO_START_STATUS: 'PICLIST_AUTO_START_STATUS', + + // file operation rpc READ_FILE_CONTENT: 'READ_FILE_CONTENT', WRITE_FILE_CONTENT: 'WRITE_FILE_CONTENT', - RELOAD_WINDOW: 'RELOAD_WINDOW', + CREATE_SCRIPTS_FILE: 'CREATE_SCRIPTS_FILE', + READ_SCRIPTS_FILE: 'READ_SCRIPTS_FILE', + LIST_SCRIPTS_FILES: 'LIST_SCRIPTS_FILES', + WRITE_SCRIPT_FILE: 'WRITE_SCRIPT_FILE', + DELETE_SCRIPTS_FILE: 'DELETE_SCRIPTS_FILE', // shortkey setting rpc SHORTKEY_UPDATE: 'SHORTKEY_UPDATE', diff --git a/src/renderer/utils/static.ts b/src/renderer/utils/static.ts index 1a71fd4e..8f823ef6 100644 --- a/src/renderer/utils/static.ts +++ b/src/renderer/utils/static.ts @@ -67,3 +67,25 @@ export const picBedManualUrlList: IStringKeyMap = { webdavplist: 'https://piclist.cn/en/configure.html#webdav', }, } + +export const defaultScriptTemplate = ` +// ctx 为 核心PicList实例, extra为额外参数, 其中extra.galleryItem为当前删除的相册对象 +// 可用额外API: axios, crypto, fs, path, os, setTimeout, setInterval, clearTimeout, clearInterval, base64Decode, base64Encode +// 图床上传脚本必须返回 ctx 对象, 其它脚本可根据需求返回任意数据 + +async function main(ctx, extra) { + // 在这里编写你的脚本代码 + return ctx +} +` + +export const defaultScriptTemplateEn = ` +// ctx is the core PicList instance, extra is additional parameters, among which extra.galleryItem is the currently deleted album object +// Available additional APIs: axios, crypto, fs, path, os, setTimeout, setInterval, clearTimeout, clearInterval, base64Decode, base64Encode +// The image bed upload script must return the ctx object, other scripts can return any data as needed + +async function main(ctx, extra) { + // Write your script code here + return ctx +} +` diff --git a/src/universal/types/types.d.ts b/src/universal/types/types.d.ts index 6c6ac50a..c7eb94e1 100644 --- a/src/universal/types/types.d.ts +++ b/src/universal/types/types.d.ts @@ -528,3 +528,22 @@ interface IFavoritePicbedItem { type: string configName: string } + +interface IuploadReturnCtxResult { + ctx: import('piclist').IPicGo | undefined + backupCtx: import('piclist').IPicGo | undefined +} + +type IScriptLifecycle = + | 'onSoftwareOpen' + | 'onSoftwareClose' + | 'preProcess' + | 'beforeTransform' + | 'transform' + | 'beforeUpload' + | 'upload' + | 'afterUpload' + | 'onUploadSuccess' + | 'onGalleryRemove' + | 'manualTrigger' + | 'uploader.advancedplist'