Compare commits

..
5 Commits
Author SHA1 Message Date
PiEgg 2a716ecdbc 🎉 Release: v2.4.3 2026-01-12 20:11:52 +08:00
PiEgg 089884b10a 🐛 Fix(ui): some ui bugs 2026-01-12 20:10:56 +08:00
PiEggandGitHub 2ed1dd5abd Feature: add global url rewrite support (#1377)
ISSUES CLOSED: #1255, #1281
2026-01-12 18:41:17 +08:00
PiEggandGitHub c84d542557 Feature(upload): add batch url upload support (#1376)
ISSUES CLOSED: #1302
2026-01-12 09:29:01 +08:00
PiEggandGitHub dfe92d496b 🐛 Fix(gallery): fix picbed list visible status not sync with gallery (#1373)
ISSUES CLOSED: #1372
2026-01-08 17:16:56 +08:00
40 changed files with 1757 additions and 1312 deletions
+2 -1
View File
@@ -27,4 +27,5 @@ scripts/*.yml
dist/*
test.js
specs/
.cache/
.cache/
openspec/
+1
View File
@@ -0,0 +1 @@
pnpm commitlint ${1}
+1
View File
@@ -0,0 +1 @@
pnpm check
+1
View File
@@ -0,0 +1 @@
pnpm test
+16
View File
@@ -1,3 +1,19 @@
## :tada: 2.4.3 (2026-01-12)
### :sparkles: Features
* add global url rewrite support ([#1377](https://github.com/Molunerfinn/PicGo/issues/1377)) ([2ed1dd5](https://github.com/Molunerfinn/PicGo/commit/2ed1dd5)), closes [#1255](https://github.com/Molunerfinn/PicGo/issues/1255) [#1281](https://github.com/Molunerfinn/PicGo/issues/1281)
* **upload:** add batch url upload support ([#1376](https://github.com/Molunerfinn/PicGo/issues/1376)) ([c84d542](https://github.com/Molunerfinn/PicGo/commit/c84d542)), closes [#1302](https://github.com/Molunerfinn/PicGo/issues/1302)
### :bug: Bug Fixes
* **gallery:** fix picbed list visible status not sync with gallery ([#1373](https://github.com/Molunerfinn/PicGo/issues/1373)) ([dfe92d4](https://github.com/Molunerfinn/PicGo/commit/dfe92d4)), closes [#1372](https://github.com/Molunerfinn/PicGo/issues/1372)
* **ui:** some ui bugs ([089884b](https://github.com/Molunerfinn/PicGo/commit/089884b))
## :tada: 2.4.2 (2026-01-07)
+9 -5
View File
@@ -1,6 +1,6 @@
{
"name": "picgo",
"version": "2.4.2",
"version": "2.4.3",
"private": true,
"main": "dist_electron/main/index.js",
"description": "A powerful & simple image uploader for creators.",
@@ -26,7 +26,10 @@
"postuninstall": "electron-builder install-app-deps",
"upload-dist": "node ./scripts/upload-dist.js",
"lint:dpdm": "dpdm -T --tsconfig ./tsconfig.json --no-tree --no-warning --exit-code circular:1 src/background.ts",
"check": "pnpm run tsc && pnpm run lint"
"check": "pnpm run tsc && pnpm run lint",
"test": "vitest run src/__tests__",
"prepare": "husky",
"commitlint": "commitlint --edit"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
@@ -50,7 +53,7 @@
"mime-types": "^3.0.2",
"mitt": "^3.0.1",
"multer": "^1.4.5-lts.1",
"picgo": "^1.6.4",
"picgo": "^1.8.1",
"qrcode.vue": "^3.3.3",
"semver": "^7.7.3",
"shell-path": "2.1.0",
@@ -102,13 +105,14 @@
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-vue": "^10.6.2",
"globals": "^16.5.0",
"husky": "^3.1.0",
"husky": "^9.1.7",
"postcss": "^8.4.23",
"stylus": "^0.54.7",
"stylus-loader": "^3.0.2",
"tailwindcss": "^3.3.2",
"typescript": "^5.9.3",
"vite": "^7.2.6"
"vite": "^7.2.6",
"vitest": "^4.0.16"
},
"commitlint": {
"extends": [
+261 -966
View File
File diff suppressed because it is too large Load Diff
+59 -6
View File
@@ -68,6 +68,7 @@ SETTINGS_SET_LOG_FILE: Set Log File
SETTINGS_CLICK_TO_SET: Click to Set
SETTINGS_CLICK_TO_CHECK: Click to Check
SETTINGS_SET_SHORTCUT: Set Shortcut
SETTINGS_URL_REWRITE: URL Rewrite
SETTINGS_CUSTOM_LINK_FORMAT: Custom Link Format
SETTINGS_SET_PROXY_AND_MIRROR: Set Proxy and Mirror
SETTINGS_SET_SERVER: Set Server
@@ -118,6 +119,46 @@ SETTINGS_USE_BUILTIN_CLIPBOARD_UPLOAD: Use Builtin Clipboard to Upload
SETTINGS_CHOOSE_LANGUAGE: Choose Language
UPLOADER_CONFIG_NAME: Configuration Name
BUILTIN_CLIPBOARD_TIPS: Use builtin clipboard function to upload instead of using scripts
# url rewrite
URL_REWRITE_HELP: Rewrites uploaded image URLs. Rules are evaluated in order; the first matched rule wins.
URL_REWRITE_ADD_RULE: Add Rule
URL_REWRITE_EDIT_RULE: Edit Rule
URL_REWRITE_EMPTY: No rules
URL_REWRITE_ORDER: Order
URL_REWRITE_MATCH: Match
URL_REWRITE_REPLACE: Replace
URL_REWRITE_FLAGS: Flags
URL_REWRITE_ENABLED: Enabled
URL_REWRITE_ACTIONS: Actions
URL_REWRITE_MOVE_UP: Up
URL_REWRITE_MOVE_DOWN: Down
URL_REWRITE_EDIT: Edit
URL_REWRITE_DELETE: Delete
URL_REWRITE_DELETE_CONFIRM: Delete this rule?
URL_REWRITE_MATCH_TIPS: Supports regex (JavaScript RegExp)
URL_REWRITE_MATCH_PLACEHOLDER: https://example.com/path
URL_REWRITE_REPLACE_TIPS: Replacement string (supports $1, $2, ...)
URL_REWRITE_REPLACE_PLACEHOLDER: https://example.org/newpath
URL_REWRITE_OPTIONS: Options
URL_REWRITE_RULE_ENABLED: Enable this rule
URL_REWRITE_FLAG_GLOBAL_LABEL: Global (g)
URL_REWRITE_FLAG_GLOBAL_DESC: Replace all occurrences, not just the first one
URL_REWRITE_FLAG_IGNORE_CASE_LABEL: Ignore case (i)
URL_REWRITE_FLAG_IGNORE_CASE_DESC: Case-insensitive matching (e.g. JPG equals jpg)
URL_REWRITE_MATCH_REQUIRED: Match is required
URL_REWRITE_REPLACE_REQUIRED: Replace is required
URL_REWRITE_INVALID_REGEX: Invalid regex
URL_REWRITE_PREVIEW_TITLE: Preview
URL_REWRITE_PREVIEW_TIPS: Enter a URL to see how the current rules rewrite it (matched in order; only the first match is applied)
URL_REWRITE_PREVIEW_PLACEHOLDER: https://example.com/path/to/image.png
URL_REWRITE_PREVIEW_RUN: Preview
URL_REWRITE_PREVIEW_OUTPUT: Output URL
URL_REWRITE_PREVIEW_INPUT_REQUIRED: Please enter a URL to preview
URL_REWRITE_PREVIEW_RULE_INVALID: Invalid rule
URL_REWRITE_PREVIEW_MATCHED_RULE: Matched rule
URL_REWRITE_PREVIEW_NO_MATCH: No rules matched
UPLOADER_CONFIG_PLACEHOLDER: Please Enter Configuration Name
SELECTED_SETTING_HINT: Selected
SETTINGS_ENCODE_OUTPUT_URL: Encode Output(or Copyed) URL
@@ -144,11 +185,20 @@ SHORTCUT_EDIT: Edit
SHORTCUT_CHANGE_UPLOAD: Change Upload Shortcut
# gallery-page
CHANGE_IMAGE_URL_HOST: Change Image URL HOST
NEW_IMAGE_URL_HOST: New Image URL HOST
SELECTED_IMAGE_URL_HOST: Selected Image URL HOST
CHANGE_IMAGE_URL_HOST_RESULT: The result of changing image URL HOST
CHANGE_IMAGE_URL_HOST_WARN: You must select at least one picture first
GALLERY_URL_REWRITE_TITLE: Rewrite Selected Image URLs
GALLERY_URL_REWRITE_RESULT_TITLE: Rewrite Image URL Result
GALLERY_URL_REWRITE_WARN_NO_SELECTION: You must select at least one picture first
GALLERY_URL_REWRITE_APPLY_GLOBAL_RULES: Apply global URL rewrite rules
GALLERY_URL_REWRITE_GLOBAL_RULES_COUNT: Global rules
GALLERY_URL_REWRITE_TEMP_RULE_TIPS: Optional. Leave empty to skip the temporary rule. Temporary rule has higher priority than global rules.
GALLERY_URL_REWRITE_TEMP_RULE_REQUIRED: Match and Replace are required for the temporary rule
GALLERY_URL_REWRITE_NO_RULES_TO_APPLY: No global rules enabled and no temporary rule provided
GALLERY_URL_REWRITE_SAVE_TEMP_RULE_PROMPT: Save the temporary rule to global URL rewrite rules?
GALLERY_URL_REWRITE_APPLY_AND_SAVE: Apply and Save
GALLERY_URL_REWRITE_APPLY_ONLY: Apply Only
GALLERY_URL_REWRITE_NO_CHANGES: No URLs were changed
GALLERY_URL_REWRITE_EMPTY_RESULT_WARN: The rewrite result is empty; skipped
# tray-page
@@ -165,7 +215,7 @@ CUSTOM: Custom
CLIPBOARD_PICTURE: Clipboard
TIPS_DRAG_VALID_PICTURE_OR_URL: Drag valid picture or url to here
TIPS_INPUT_URL: Input URL
TIPS_HTTP_PREFIX: http:// or https://
TIPS_HTTP_PREFIX: Starts with http:// or https://. Multiple URLs supported (one per line)
TIPS_INPUT_VALID_URL: Input valid URL
# plugins
@@ -237,6 +287,9 @@ TOOLBOX_CHECK_CLIPBOARD_FILE_PATH_ERROR_TIPS: "Please create the folder yourself
TIPS_NOTICE: Tips
TIPS_WARNING: Warning
TIPS_ERROR: Error
TIPS_SKIPPED_INVALID_URLS: Skipped ${n} invalid URL(s), see logs for details
TIPS_TOO_MANY_URLS_CONFIRM: You are about to upload ${n} URLs at once. This may cause lag. It's recommended to upload in batches. Continue?
TIPS_NO_VALID_URLS: No valid URL found
TIPS_INSTALL_NODE_AND_RELOAD_PICGO: Please install Node.js and restart PicGo to continue
TIPS_PLUGIN_REMOVE_GALLERY_ITEM: Plugin is trying to remove some images from the album gallery, continue?
TIPS_PLUGIN_OVERWRITE_GALLERY: Plugin is trying to overwrite the album gallery, continue?
+59 -6
View File
@@ -68,6 +68,7 @@ SETTINGS_SET_LOG_FILE: 设置日志文件
SETTINGS_CLICK_TO_SET: 点击设置
SETTINGS_CLICK_TO_CHECK: 点击检查
SETTINGS_SET_SHORTCUT: 设置快捷键
SETTINGS_URL_REWRITE: URL 重写
SETTINGS_CUSTOM_LINK_FORMAT: 自定义链接格式
SETTINGS_SET_PROXY_AND_MIRROR: 设置代理和镜像地址
SETTINGS_SET_SERVER: 设置Server
@@ -118,6 +119,46 @@ SETTINGS_USE_BUILTIN_CLIPBOARD_UPLOAD: 使用内置剪贴板上传
SETTINGS_CHOOSE_LANGUAGE: 选择语言
BUILTIN_CLIPBOARD_TIPS: 使用内置剪贴板函数而不是调用脚本获取剪贴板图片
UPLOADER_CONFIG_NAME: 图床配置名
# url rewrite
URL_REWRITE_HELP: 用于重写上传后的图片 URL。规则按顺序匹配,命中的第一条匹配生效。
URL_REWRITE_ADD_RULE: 新增规则
URL_REWRITE_EDIT_RULE: 编辑规则
URL_REWRITE_EMPTY: 暂无规则
URL_REWRITE_ORDER: 顺序
URL_REWRITE_MATCH: 匹配
URL_REWRITE_REPLACE: 替换
URL_REWRITE_FLAGS: 标志
URL_REWRITE_ENABLED: 启用
URL_REWRITE_ACTIONS: 操作
URL_REWRITE_MOVE_UP: 上移
URL_REWRITE_MOVE_DOWN: 下移
URL_REWRITE_EDIT: 编辑
URL_REWRITE_DELETE: 删除
URL_REWRITE_DELETE_CONFIRM: 确定删除该规则?
URL_REWRITE_MATCH_TIPS: 支持正则(JavaScript RegExp
URL_REWRITE_MATCH_PLACEHOLDER: https://example.com/path
URL_REWRITE_REPLACE_TIPS: 替换内容(支持 $1、$2...
URL_REWRITE_REPLACE_PLACEHOLDER: https://example.org/newpath
URL_REWRITE_OPTIONS: 选项
URL_REWRITE_RULE_ENABLED: 启用该规则
URL_REWRITE_FLAG_GLOBAL_LABEL: 全局(g
URL_REWRITE_FLAG_GLOBAL_DESC: 替换所有找到的内容,而不仅仅是第一个
URL_REWRITE_FLAG_IGNORE_CASE_LABEL: 忽略大小写(i
URL_REWRITE_FLAG_IGNORE_CASE_DESC: 匹配时不区分字母大小写(例如 JPG 等同于 jpg)
URL_REWRITE_MATCH_REQUIRED: 匹配规则不能为空
URL_REWRITE_REPLACE_REQUIRED: 替换内容不能为空
URL_REWRITE_INVALID_REGEX: 正则表达式不合法
URL_REWRITE_PREVIEW_TITLE: 预览
URL_REWRITE_PREVIEW_TIPS: 输入一个 URL,查看当前规则的重写结果(按顺序匹配,仅第一条命中的规则生效)
URL_REWRITE_PREVIEW_PLACEHOLDER: https://example.com/path/to/image.png
URL_REWRITE_PREVIEW_RUN: 预览
URL_REWRITE_PREVIEW_OUTPUT: 输出 URL
URL_REWRITE_PREVIEW_INPUT_REQUIRED: 请输入要预览的 URL
URL_REWRITE_PREVIEW_RULE_INVALID: 规则无效
URL_REWRITE_PREVIEW_MATCHED_RULE: 命中规则
URL_REWRITE_PREVIEW_NO_MATCH: 没有规则匹配
UPLOADER_CONFIG_PLACEHOLDER: 请输入配置名称
SELECTED_SETTING_HINT: 已选中
SETTINGS_ENCODE_OUTPUT_URL: 输出(复制) URL 时进行转义
@@ -144,11 +185,20 @@ SHORTCUT_EDIT: 编辑
SHORTCUT_CHANGE_UPLOAD: 修改上传快捷键
# gallery-page
CHANGE_IMAGE_URL_HOST: 修改图片 URL HOST
NEW_IMAGE_URL_HOST: 新的图片 URL HOST
SELECTED_IMAGE_URL_HOST: 已选中的图片 URL HOST
CHANGE_IMAGE_URL_HOST_RESULT: 修改图片 URL HOST 结果
CHANGE_IMAGE_URL_HOST_WARN: 你必须先选中至少一张图片
GALLERY_URL_REWRITE_TITLE: 重写选中图片 URL
GALLERY_URL_REWRITE_RESULT_TITLE: 重写图片 URL 结果
GALLERY_URL_REWRITE_WARN_NO_SELECTION: 你必须先选中至少一张图片
GALLERY_URL_REWRITE_APPLY_GLOBAL_RULES: 应用全局 URL 重写规则
GALLERY_URL_REWRITE_GLOBAL_RULES_COUNT: 全局规则数量
GALLERY_URL_REWRITE_TEMP_RULE_TIPS: 可选,留空则不使用临时规则(临时规则优先级高于全局规则)。
GALLERY_URL_REWRITE_TEMP_RULE_REQUIRED: 临时规则需要同时填写「匹配」和「替换」
GALLERY_URL_REWRITE_NO_RULES_TO_APPLY: 没有可用的全局规则,且未填写临时规则
GALLERY_URL_REWRITE_SAVE_TEMP_RULE_PROMPT: 是否将临时规则写入全局 URL 重写规则列表?
GALLERY_URL_REWRITE_APPLY_AND_SAVE: 应用并写入
GALLERY_URL_REWRITE_APPLY_ONLY: 仅应用
GALLERY_URL_REWRITE_NO_CHANGES: 没有任何 URL 被修改
GALLERY_URL_REWRITE_EMPTY_RESULT_WARN: 重写结果为空,已跳过
# tray-page
@@ -165,7 +215,7 @@ CUSTOM: 自定义
CLIPBOARD_PICTURE: 剪贴板图片
TIPS_DRAG_VALID_PICTURE_OR_URL: 请拖入合法的图片文件或者图片URL地址
TIPS_INPUT_URL: 请输入URL
TIPS_HTTP_PREFIX: http://或者https://开头
TIPS_HTTP_PREFIX: http:// 或者 https:// 开头,支持上传多条 URL(请换行输入)
TIPS_INPUT_VALID_URL: 请输入合法的URL
# plugins
@@ -237,6 +287,9 @@ TOOLBOX_CHECK_CLIPBOARD_FILE_PATH_ERROR_TIPS: 请自行创建文件夹:${path}
TIPS_NOTICE: 注意
TIPS_WARNING: 警告
TIPS_ERROR: 发生错误
TIPS_SKIPPED_INVALID_URLS: 已跳过 ${n} 条非法 URL,请查看日志了解详情
TIPS_TOO_MANY_URLS_CONFIRM: 你将一次上传 ${n} 条 URL,可能会引起卡顿,建议分批上传。是否继续?
TIPS_NO_VALID_URLS: 未检测到合法的 URL
TIPS_INSTALL_NODE_AND_RELOAD_PICGO: 请安装Node.js并重启PicGo再继续操作
TIPS_PLUGIN_REMOVE_GALLERY_ITEM: 有插件正在试图删除一些相册图片,是否继续
TIPS_PLUGIN_OVERWRITE_GALLERY: 有插件正在试图覆盖相册列表,是否继续
+59 -6
View File
@@ -68,6 +68,7 @@ SETTINGS_SET_LOG_FILE: 設定記錄檔案
SETTINGS_CLICK_TO_SET: 點擊設定
SETTINGS_CLICK_TO_CHECK: 點擊檢查
SETTINGS_SET_SHORTCUT: 設定快捷鍵
SETTINGS_URL_REWRITE: URL 重寫
SETTINGS_CUSTOM_LINK_FORMAT: 自訂連結格式
SETTINGS_SET_PROXY_AND_MIRROR: 設定PROXY和鏡像地址
SETTINGS_SET_SERVER: 設定Server
@@ -118,6 +119,46 @@ SETTINGS_USE_BUILTIN_CLIPBOARD_UPLOAD: 使用內建剪貼簿上傳
SETTINGS_CHOOSE_LANGUAGE: 選擇語言
BUILTIN_CLIPBOARD_TIPS: 使用內建剪貼簿函數而不是調用腳本取得剪貼簿內的照片
UPLOADER_CONFIG_NAME: 圖床配置名
# url rewrite
URL_REWRITE_HELP: 用於重寫上傳後的圖片 URL。規則按順序匹配,命中的第一條匹配生效。
URL_REWRITE_ADD_RULE: 新增規則
URL_REWRITE_EDIT_RULE: 編輯規則
URL_REWRITE_EMPTY: 暫無規則
URL_REWRITE_ORDER: 順序
URL_REWRITE_MATCH: 匹配
URL_REWRITE_REPLACE: 替換
URL_REWRITE_FLAGS: 標誌
URL_REWRITE_ENABLED: 啟用
URL_REWRITE_ACTIONS: 操作
URL_REWRITE_MOVE_UP: 上移
URL_REWRITE_MOVE_DOWN: 下移
URL_REWRITE_EDIT: 編輯
URL_REWRITE_DELETE: 刪除
URL_REWRITE_DELETE_CONFIRM: 確定刪除該規則?
URL_REWRITE_MATCH_TIPS: 支援正則(JavaScript RegExp
URL_REWRITE_MATCH_PLACEHOLDER: https://example.com/path
URL_REWRITE_REPLACE_TIPS: 替換內容(支援 $1、$2...
URL_REWRITE_REPLACE_PLACEHOLDER: https://example.org/newpath
URL_REWRITE_OPTIONS: 選項
URL_REWRITE_RULE_ENABLED: 啟用該規則
URL_REWRITE_FLAG_GLOBAL_LABEL: 全域(g
URL_REWRITE_FLAG_GLOBAL_DESC: 替換所有找到的內容,而不僅僅是第一個
URL_REWRITE_FLAG_IGNORE_CASE_LABEL: 忽略大小寫(i
URL_REWRITE_FLAG_IGNORE_CASE_DESC: 匹配時不區分字母大小寫(例如 JPG 等同於 jpg)
URL_REWRITE_MATCH_REQUIRED: 匹配規則不能為空
URL_REWRITE_REPLACE_REQUIRED: 替換內容不能為空
URL_REWRITE_INVALID_REGEX: 正則表達式不合法
URL_REWRITE_PREVIEW_TITLE: 預覽
URL_REWRITE_PREVIEW_TIPS: 輸入一個 URL,查看目前規則的重寫結果(按順序匹配,僅第一條命中的規則生效)
URL_REWRITE_PREVIEW_PLACEHOLDER: https://example.com/path/to/image.png
URL_REWRITE_PREVIEW_RUN: 預覽
URL_REWRITE_PREVIEW_OUTPUT: 輸出 URL
URL_REWRITE_PREVIEW_INPUT_REQUIRED: 請輸入要預覽的 URL
URL_REWRITE_PREVIEW_RULE_INVALID: 規則無效
URL_REWRITE_PREVIEW_MATCHED_RULE: 命中規則
URL_REWRITE_PREVIEW_NO_MATCH: 沒有規則匹配
UPLOADER_CONFIG_PLACEHOLDER: 請輸入配置名稱
SELECTED_SETTING_HINT: 已選中
SETTINGS_ENCODE_OUTPUT_URL: 輸出(複製) URL 時進行轉義
@@ -144,11 +185,20 @@ SHORTCUT_EDIT: 編輯
SHORTCUT_CHANGE_UPLOAD: 修改上傳快捷鍵
# gallery-page
CHANGE_IMAGE_URL_HOST: 修改圖片 URL HOST
NEW_IMAGE_URL_HOST: 新的圖片 URL HOST
SELECTED_IMAGE_URL_HOST: 已選中的圖片 URL HOST
CHANGE_IMAGE_URL_HOST_RESULT: 修改圖片 URL HOST 結果
CHANGE_IMAGE_URL_HOST_WARN: 你必須先選中至少一張圖片
GALLERY_URL_REWRITE_TITLE: 重寫選中圖片 URL
GALLERY_URL_REWRITE_RESULT_TITLE: 重寫圖片 URL 結果
GALLERY_URL_REWRITE_WARN_NO_SELECTION: 你必須先選中至少一張圖片
GALLERY_URL_REWRITE_APPLY_GLOBAL_RULES: 套用全域 URL 重寫規則
GALLERY_URL_REWRITE_GLOBAL_RULES_COUNT: 全域規則數量
GALLERY_URL_REWRITE_TEMP_RULE_TIPS: 可選,留空則不使用臨時規則(臨時規則優先級高於全域規則)。
GALLERY_URL_REWRITE_TEMP_RULE_REQUIRED: 臨時規則需要同時填寫「匹配」和「替換」
GALLERY_URL_REWRITE_NO_RULES_TO_APPLY: 沒有可用的全域規則,且未填寫臨時規則
GALLERY_URL_REWRITE_SAVE_TEMP_RULE_PROMPT: 是否將臨時規則寫入全域 URL 重寫規則列表?
GALLERY_URL_REWRITE_APPLY_AND_SAVE: 套用並寫入
GALLERY_URL_REWRITE_APPLY_ONLY: 僅套用
GALLERY_URL_REWRITE_NO_CHANGES: 沒有任何 URL 被修改
GALLERY_URL_REWRITE_EMPTY_RESULT_WARN: 重寫結果為空,已跳過
# tray-page
@@ -165,7 +215,7 @@ CUSTOM: 自訂
CLIPBOARD_PICTURE: 剪貼簿圖片
TIPS_DRAG_VALID_PICTURE_OR_URL: 請拖入合法的圖片檔案或者圖片URL地址
TIPS_INPUT_URL: 請輸入URL
TIPS_HTTP_PREFIX: http://或者https://開頭
TIPS_HTTP_PREFIX: http:// 或者 https:// 開頭,支援上傳多條 URL(請換行輸入)
TIPS_INPUT_VALID_URL: 請輸入合法的URL
# plugins
@@ -237,6 +287,9 @@ TOOLBOX_CHECK_CLIPBOARD_FILE_PATH_ERROR_TIPS: 請自行創建文件夾:${path}
TIPS_NOTICE: 注意
TIPS_WARNING: 警告
TIPS_ERROR: 發生錯誤
TIPS_SKIPPED_INVALID_URLS: 已跳過 ${n} 條非法 URL,請查看日誌了解詳情
TIPS_TOO_MANY_URLS_CONFIRM: 你將一次上傳 ${n} 條 URL,可能會引起卡頓,建議分批上傳。是否繼續?
TIPS_NO_VALID_URLS: 未偵測到合法的 URL
TIPS_INSTALL_NODE_AND_RELOAD_PICGO: 請安裝Node.js並重新啟動PicGo再繼續操作
TIPS_PLUGIN_REMOVE_GALLERY_ITEM: 有插件正在試圖刪除一些相簿圖片,是否繼續?
TIPS_PLUGIN_OVERWRITE_GALLERY: 有插件正在試圖覆蓋相簿列表,是否繼續?
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest'
import { extractHttpUrlsFromText, parseNewlineSeparatedUrls } from '../../../universal/utils/common'
describe('universal/utils/common', () => {
describe('parseNewlineSeparatedUrls', () => {
it('parses newline-separated urls, trims lines, ignores empty lines, and de-duplicates', () => {
const input = [
'https://a.example/1.png',
'',
' https://a.example/2.png ',
'https://a.example/1.png',
'not-a-url'
].join('\n')
const { urls, invalidLines } = parseNewlineSeparatedUrls(input)
expect(urls).toEqual([
'https://a.example/1.png',
'https://a.example/2.png'
])
expect(invalidLines).toEqual(['not-a-url'])
})
it('ignores uri-list comment lines when source is uri-list', () => {
const input = [
'# comment',
'https://a.example/1.png',
'# another comment',
'https://a.example/2.png'
].join('\n')
const { urls, invalidLines } = parseNewlineSeparatedUrls(input, { source: 'uri-list' })
expect(urls).toEqual([
'https://a.example/1.png',
'https://a.example/2.png'
])
expect(invalidLines).toEqual([])
})
it('supports NUL-separated drag payloads by normalizing to newlines', () => {
const input = `https://a.example/1.png\u0000https://a.example/2.png`
const { urls, invalidLines } = parseNewlineSeparatedUrls(input)
expect(urls).toEqual([
'https://a.example/1.png',
'https://a.example/2.png'
])
expect(invalidLines).toEqual([])
})
it('splits concatenated urls in a single line as a fallback', () => {
const input = 'https://a.example/1.pnghttps://b.example/2.webphttps://c.example/3.png'
const { urls, invalidLines } = parseNewlineSeparatedUrls(input)
expect(urls).toEqual([
'https://a.example/1.png',
'https://b.example/2.webp',
'https://c.example/3.png'
])
expect(invalidLines).toEqual([])
})
it('does not split embedded urls in query parameters', () => {
const input = 'https://example.com/?url=https://a.example/1.png'
const { urls, invalidLines } = parseNewlineSeparatedUrls(input)
expect(urls).toEqual(['https://example.com/?url=https://a.example/1.png'])
expect(invalidLines).toEqual([])
})
})
describe('extractHttpUrlsFromText', () => {
it('extracts urls from mixed text, strips trailing punctuation, and de-duplicates', () => {
const input = [
'hello',
'https://a.example/1.png)',
'https://b.example/2.webp]',
'https://a.example/1.png'
].join(' ')
expect(extractHttpUrlsFromText(input)).toEqual([
'https://a.example/1.png',
'https://b.example/2.webp'
])
})
it('returns empty array when no urls are present', () => {
expect(extractHttpUrlsFromText('no urls here')).toEqual([])
})
})
})
+10 -1
View File
@@ -10,6 +10,7 @@ import { IPasteStyle, IRPCActionType, IWindowList } from '#/types/enum'
import uploader from 'apis/app/uploader'
import pasteTemplate from '~/main/utils/pasteTemplate'
import picgo from '@core/picgo'
import logger from '@core/picgo/logger'
import { GalleryDB } from '~/main/apis/core/datastore'
import server from '~/main/server'
import getPicBeds from '~/main/utils/getPicBeds'
@@ -27,7 +28,8 @@ import {
OPEN_URL,
SHOW_PLUGIN_PAGE_MENU,
SET_MINI_WINDOW_POS,
GET_PICBEDS
GET_PICBEDS,
LOG_INVALID_URL_LINES
} from '#/events/constants'
import {
uploadClipboardFiles,
@@ -216,6 +218,13 @@ export default {
const window = BrowserWindow.getFocusedWindow()
window?.setBounds(pos)
})
ipcMain.on(LOG_INVALID_URL_LINES, (_evt: IpcMainEvent, lines: string[]) => {
if (!Array.isArray(lines) || !lines.length) return
lines.forEach((line, index) => {
logger.warn(`[Batch URL Upload] invalid url line #${index + 1}: ${line}`)
})
})
},
dispose () {}
}
@@ -1,77 +0,0 @@
import { logger } from '@picgo/i18n'
import { IPicGo } from 'picgo'
import { T } from '~/main/i18n'
import { getHost, removeProtocolAndSuffix, replaceHost } from '~/main/utils/common'
export const galleryMenu = () => {
return [{
label: T('CHANGE_IMAGE_URL_HOST'),
async handle (ctx: IPicGo, guiApi: IGuiApi, selectedList: ImgInfo[] = []) {
const hostList = [...new Set(selectedList.map((item) => {
return getHost(item.imgUrl)
}).filter(Boolean) as string[])]
if (hostList.length === 0) {
guiApi.showNotification({
title: T('CHANGE_IMAGE_URL_HOST'),
body: T('CHANGE_IMAGE_URL_HOST_WARN')
})
logger.warn(T('CHANGE_IMAGE_URL_HOST_WARN'))
return
}
const config: IPicGoPluginConfig[] = [
{
alias: T('SELECTED_IMAGE_URL_HOST'),
name: 'selectedHost',
type: 'checkbox',
choices: hostList.map(item => ({
name: item,
value: item,
checked: true
})),
required: true
},
{
alias: T('NEW_IMAGE_URL_HOST'),
name: 'newHost',
type: 'input',
message: 'www.example.com',
default: '',
required: true
}
]
const options: IPicGoPluginShowConfigDialogOption = {
title: T('CHANGE_IMAGE_URL_HOST'),
config
}
const res = await guiApi.showConfigDialog<{
selectedHost: string[]
newHost: string
}>(options)
if (res) {
const selectedHost = res.selectedHost
const newHost = removeProtocolAndSuffix(res.newHost)
const changedList = selectedList.map((item) => {
try {
const url = new URL(item.imgUrl || '')
const host = url.host
if (selectedHost.includes(host)) {
item.imgUrl = replaceHost(item.imgUrl!, host, newHost)
return item
} else {
return false
}
} catch (e: any) {
ctx.log.error(e)
return false
}
}).filter(Boolean) as ImgInfo[]
const updateRes = await guiApi.galleryDB.updateMany(changedList)
guiApi.showNotification({
title: T('CHANGE_IMAGE_URL_HOST_RESULT'),
body: `${T('SUCCESS')}: ${updateRes.success} ${T('FAILED')}: ${updateRes.total - updateRes.success}}`
})
}
}
}]
}
@@ -0,0 +1,230 @@
import { logger } from '@picgo/i18n'
import { PicGoUtils, type IPicGo } from 'picgo'
import { T } from '~/main/i18n'
interface IUrlRewriteRule {
match: string
replace: string
enable?: boolean
global?: boolean
ignoreCase?: boolean
}
interface IUrlRewriteDialogResult {
applyGlobalRules?: boolean
match?: string
replace?: string
global?: boolean
ignoreCase?: boolean
}
function normalizeRules (value: unknown): IUrlRewriteRule[] {
if (!Array.isArray(value)) return []
return value.map(item => {
const raw = (item ?? {}) as Partial<Record<keyof IUrlRewriteRule, unknown>>
return {
match: String(raw.match ?? ''),
replace: String(raw.replace ?? ''),
enable: raw.enable === false ? false : true,
global: raw.global === true,
ignoreCase: raw.ignoreCase === true
}
}).filter(rule => rule.match.length > 0)
}
function buildFlags (rule: Pick<IUrlRewriteRule, 'global' | 'ignoreCase'>): string {
return `${rule.global ? 'g' : ''}${rule.ignoreCase ? 'i' : ''}`
}
function validateRuleOrThrow (rule: IUrlRewriteRule) {
if (!rule.match.trim() || !rule.replace.trim()) {
throw new Error(T('GALLERY_URL_REWRITE_TEMP_RULE_REQUIRED'))
}
try {
new RegExp(rule.match, buildFlags(rule))
} catch (error) {
const message = `Invalid URL rewrite regex pattern "${rule.match}": ${error instanceof Error ? error.message : String(error)}`
logger.error(message)
throw new Error(message)
}
}
function applyFirstMatchRewrite (ctx: IPicGo, imgItem: ImgInfo, rules: IUrlRewriteRule[]): ImgInfo {
const imgInfo = {
imgUrl: imgItem.imgUrl,
originImgUrl: imgItem.originImgUrl
}
PicGoUtils.applyUrlRewriteToImgInfo(imgInfo, rules, {
log: {
error: (...args: Parameters<IPicGo['log']['error']>) => ctx.log.error(...args),
warn: () => ctx.log.warn(T('GALLERY_URL_REWRITE_EMPTY_RESULT_WARN'))
}
})
if (imgInfo.imgUrl === '') return imgItem
return imgInfo
}
export const galleryMenu = () => {
return [{
label: T('GALLERY_URL_REWRITE_TITLE'),
async handle (ctx: IPicGo, guiApi: IGuiApi, selectedList: ImgInfo[] = []) {
if (!selectedList.length) {
guiApi.showNotification({
title: T('GALLERY_URL_REWRITE_TITLE'),
body: T('GALLERY_URL_REWRITE_WARN_NO_SELECTION')
})
logger.warn(T('GALLERY_URL_REWRITE_WARN_NO_SELECTION'))
return
}
const globalRules = normalizeRules(ctx.getConfig('settings.urlRewrite.rules'))
const config: IPicGoPluginConfig[] = [
{
alias: T('GALLERY_URL_REWRITE_APPLY_GLOBAL_RULES'),
name: 'applyGlobalRules',
type: 'confirm',
default: globalRules.length > 0,
required: false,
confirmText: T('SETTINGS_OPEN'),
cancelText: T('SETTINGS_CLOSE'),
tips: `${T('GALLERY_URL_REWRITE_GLOBAL_RULES_COUNT')}: ${globalRules.length}`
},
{
alias: T('URL_REWRITE_MATCH'),
name: 'match',
type: 'input',
message: T('URL_REWRITE_MATCH_PLACEHOLDER'),
default: '',
required: false,
tips: `${T('GALLERY_URL_REWRITE_TEMP_RULE_TIPS')}\n\n${T('URL_REWRITE_MATCH_TIPS')}`
},
{
alias: T('URL_REWRITE_REPLACE'),
name: 'replace',
type: 'input',
message: T('URL_REWRITE_REPLACE_PLACEHOLDER'),
default: '',
required: false,
tips: T('URL_REWRITE_REPLACE_TIPS')
},
{
alias: T('URL_REWRITE_FLAG_GLOBAL_LABEL'),
name: 'global',
type: 'confirm',
default: false,
required: false,
confirmText: 'g',
cancelText: '-',
tips: T('URL_REWRITE_FLAG_GLOBAL_DESC')
},
{
alias: T('URL_REWRITE_FLAG_IGNORE_CASE_LABEL'),
name: 'ignoreCase',
type: 'confirm',
default: false,
required: false,
confirmText: 'i',
cancelText: '-',
tips: T('URL_REWRITE_FLAG_IGNORE_CASE_DESC')
}
]
const options: IPicGoPluginShowConfigDialogOption = {
title: T('GALLERY_URL_REWRITE_TITLE'),
config
}
const res = await guiApi.showConfigDialog<IUrlRewriteDialogResult>(options)
if (!res) return
const applyGlobalRules = res.applyGlobalRules === true
const tempMatch = String(res.match ?? '').trim()
const tempReplace = String(res.replace ?? '').trim()
const hasTempRuleInput = tempMatch.length > 0 || tempReplace.length > 0
let tempRule: IUrlRewriteRule | null = null
if (hasTempRuleInput) {
tempRule = {
match: tempMatch,
replace: tempReplace,
enable: true,
global: res.global === true,
ignoreCase: res.ignoreCase === true
}
}
if (tempRule) {
try {
validateRuleOrThrow(tempRule)
} catch (e: any) {
guiApi.showNotification({
title: T('GALLERY_URL_REWRITE_TITLE'),
body: e.message
})
return
}
}
if (!applyGlobalRules && !tempRule) {
guiApi.showNotification({
title: T('GALLERY_URL_REWRITE_TITLE'),
body: T('GALLERY_URL_REWRITE_NO_RULES_TO_APPLY')
})
return
}
let shouldSaveTempRule = false
if (tempRule) {
const saveRes = await guiApi.showMessageBox({
title: T('GALLERY_URL_REWRITE_TITLE'),
message: T('GALLERY_URL_REWRITE_SAVE_TEMP_RULE_PROMPT'),
type: 'info',
buttons: [
T('GALLERY_URL_REWRITE_APPLY_AND_SAVE'),
T('GALLERY_URL_REWRITE_APPLY_ONLY'),
T('CANCEL')
]
})
if (saveRes.result === 2) return
shouldSaveTempRule = saveRes.result === 0
}
const rulesToApply: IUrlRewriteRule[] = [
...(tempRule ? [tempRule] : []),
...(applyGlobalRules ? globalRules : [])
]
const changedList = selectedList.map((item) => {
const current = item.imgUrl || ''
if (!current) return false
const next = applyFirstMatchRewrite(ctx, item, rulesToApply)
if (next.imgUrl === current) return false
return {
id: item.id,
imgUrl: next.imgUrl,
originImgUrl: next.originImgUrl
} as ImgInfo
}).filter(Boolean) as ImgInfo[]
if (changedList.length === 0) {
guiApi.showNotification({
title: T('GALLERY_URL_REWRITE_RESULT_TITLE'),
body: T('GALLERY_URL_REWRITE_NO_CHANGES')
})
return
}
if (shouldSaveTempRule && tempRule) {
const nextGlobalRules = [...globalRules, tempRule]
ctx.saveConfig({
'settings.urlRewrite.rules': nextGlobalRules
})
}
const updateRes = await guiApi.galleryDB.updateMany(changedList)
guiApi.showNotification({
title: T('GALLERY_URL_REWRITE_RESULT_TITLE'),
body: `${T('SUCCESS')}: ${updateRes.success} ${T('FAILED')}: ${updateRes.total - updateRes.success}`
})
}
}]
}
@@ -1,6 +1,6 @@
import { galleryMenu as changeHostGalleryMenu } from './changeHost'
import { galleryMenu as changeURLGalleryMenu } from './changeURL'
export const builtInGalleryToolboxMenu = () => {
const menuList = [...changeHostGalleryMenu()]
const menuList = [...changeURLGalleryMenu()]
return menuList
}
@@ -3,6 +3,7 @@
<ConfirmDialog
v-model="visible"
:title="title"
:width="width"
@confirm="handleConfirm"
@cancel="handleCancel"
@close="handleCancel"
@@ -31,6 +32,7 @@ const configList = ref<IPicGoPluginConfig[]>([])
const formModel = reactive<IStringKeyMap>({})
const $form = ref<IFormInstance>()
const title = ref('')
const width = ref(500)
const handleConfigForm = useConfigForm()
@@ -39,6 +41,7 @@ useIPCOn(IRPCActionType.OPEN_CONFIG_DIALOG, (event, options: IPicGoPluginShowCon
visible.value = true
configList.value = handleConfigForm(options.config, formModel)
title.value = options.title
width.value = options.width || 500
})
const handleConfirm = async () => {
const res = await $form.value?.validate() || false
@@ -2,6 +2,8 @@
<ElDialog
v-model="visible"
:title="title"
:width="width + 'px'"
:append-to-body="true"
@close="handleClose"
>
<slot name="body" />
@@ -39,10 +41,12 @@ interface IProps {
title: string
confirmButtonText?: string
cancelButtonText?: string
width?: number
}
const props = withDefaults(defineProps<IProps>(), {
confirmButtonText: $T('CONFIRM'),
cancelButtonText: $T('CANCEL')
cancelButtonText: $T('CANCEL'),
width: 500
})
const $emit = defineEmits(['confirm', 'cancel', 'close'])
@@ -2,11 +2,15 @@
<el-dialog
v-model="showInputBoxVisible"
:title="inputBoxOptions.title || $T('INPUT')"
:modal-append-to-body="false"
:append-to-body="true"
:width="inputBoxOptions.width + 'px'"
>
<el-input
v-model="inputBoxValue"
:placeholder="inputBoxOptions.placeholder"
:type="inputBoxOptions.inputType || 'text'"
:rows="inputBoxOptions.inputType === 'textarea' ? 6 : undefined"
:class="{ 'input-box__textarea': inputBoxOptions.inputType === 'textarea' }"
/>
<template #footer>
<el-button
@@ -38,7 +42,9 @@ const inputBoxValue = ref('')
const showInputBoxVisible = ref(false)
const inputBoxOptions = reactive({
title: '',
placeholder: ''
placeholder: '',
inputType: 'text' as 'text' | 'textarea',
width: 500
})
onBeforeMount(() => {
@@ -54,6 +60,8 @@ function initInputBoxValue (options: IShowInputBoxOption) {
inputBoxValue.value = options.value || ''
inputBoxOptions.title = options.title || ''
inputBoxOptions.placeholder = options.placeholder || ''
inputBoxOptions.inputType = options.inputType || 'text'
inputBoxOptions.width = options.width || 400
showInputBoxVisible.value = true
}
@@ -82,4 +90,8 @@ export default {
}
</script>
<style lang='stylus'>
.input-box__textarea
.el-textarea__inner
resize vertical
max-height 240px
</style>
@@ -89,6 +89,7 @@ import { ref } from 'vue'
import { marked } from 'marked'
import type { FormInstance } from 'element-plus'
import { useVModel } from '@/hooks/useVModel'
import { QuestionFilled } from '@element-plus/icons-vue'
const $form = ref<FormInstance>()
@@ -117,7 +118,6 @@ async function validate (): Promise<IStringKeyMap | false> {
resolve(form.value)
} else {
resolve(false)
return false
}
})
})
+5 -1
View File
@@ -31,7 +31,7 @@
:placeholder="$T('CHOOSE_SHOWED_PICBED')"
>
<el-option
v-for="item in picBed"
v-for="item in visiblePicBedList"
:key="item.type"
:label="item.name"
:value="item.type"
@@ -230,6 +230,7 @@ const pasteStyleMap = {
Custom: 'Custom'
}
const picBed = ref<IPicBedType[]>([])
const visiblePicBedList = computed(() => picBed.value.filter(item => item.visible))
onBeforeRouteUpdate((to, from) => {
if (from.name === 'gallery') {
clearSelectedList()
@@ -280,6 +281,9 @@ const isAllSelected = computed(() => {
function getPicBeds (event: IpcRendererEvent, picBeds: IPicBedType[]) {
picBed.value = picBeds
if (selectedPicBed.value.length === 0) return
const visibleTypes = new Set(picBeds.filter(item => item.visible).map(item => item.type))
selectedPicBed.value = selectedPicBed.value.filter(type => visibleTypes.has(type))
}
function getGallery (): IGalleryItem[] {
+66 -29
View File
@@ -31,15 +31,16 @@
// import mixin from '@/utils/mixin'
// import { Component, Vue, Watch } from 'vue-property-decorator'
import { T as $T } from '@/i18n/index'
import { ElMessage as $message } from 'element-plus'
import { showNotification } from '@/utils/notification'
import {
ipcRenderer,
IpcRendererEvent
} from 'electron'
import { onBeforeUnmount, onBeforeMount, ref, watch } from 'vue'
import { SHOW_MINI_PAGE_MENU, SET_MINI_WINDOW_POS } from '~/universal/events/constants'
import { LOG_INVALID_URL_LINES, SHOW_MINI_PAGE_MENU, SET_MINI_WINDOW_POS } from '~/universal/events/constants'
import {
isUrl
isUrl,
parseNewlineSeparatedUrls
} from '~/universal/utils/common'
import { sendToMain } from '@/utils/dataSender'
import { getFilePath } from '@/utils/common'
@@ -84,42 +85,78 @@ watch(progress, (val) => {
}
})
function onDrop (e: DragEvent) {
async function onDrop (e: DragEvent) {
dragover.value = false
const items = e.dataTransfer?.items!
const files = e.dataTransfer?.files!
// send files first
if (files?.length) {
ipcSendFiles(e.dataTransfer?.files!)
} else {
if (items.length === 2 && items[0].type === 'text/uri-list') {
handleURLDrag(items, e.dataTransfer!)
} else if (items[0].type === 'text/plain') {
const str = e.dataTransfer!.getData(items[0].type)
if (isUrl(str)) {
sendToMain('uploadChoosedFiles', [{ path: str }])
} else {
$message.error($T('TIPS_DRAG_VALID_PICTURE_OR_URL'))
}
}
return
}
const dataTransfer = e.dataTransfer
if (!dataTransfer) return
const uriList = dataTransfer.getData('text/uri-list')
if (uriList) {
await handleUriListDrop(uriList, dataTransfer.getData('text/html'))
return
}
const plainText = dataTransfer.getData('text/plain')
if (plainText) {
await handlePlainTextDrop(plainText)
return
}
showNotification({
title: $T('TIPS_ERROR'),
body: $T('TIPS_DRAG_VALID_PICTURE_OR_URL')
})
}
function handleURLDrag (items: DataTransferItemList, dataTransfer: DataTransfer) {
// text/html
// Use this data to get a more precise URL
const urlString = dataTransfer.getData(items[1].type)
const urlMatch = urlString.match(/<img.*src="(.*?)"/)
if (urlMatch) {
sendToMain('uploadChoosedFiles', [
{
path: urlMatch[1]
}
])
} else {
$message.error($T('TIPS_DRAG_VALID_PICTURE_OR_URL'))
async function uploadUrls (urls: string[], invalidLines: string[]) {
if (invalidLines.length) {
sendToMain(LOG_INVALID_URL_LINES, invalidLines)
showNotification({
title: $T('TIPS_WARNING'),
body: $T('TIPS_SKIPPED_INVALID_URLS', { n: invalidLines.length })
})
}
sendToMain('uploadChoosedFiles', urls.map((url) => ({ path: url })))
}
async function handlePlainTextDrop (plainText: string) {
const { urls, invalidLines } = parseNewlineSeparatedUrls(plainText, { source: 'plain' })
if (!urls.length) {
showNotification({
title: $T('TIPS_ERROR'),
body: $T('TIPS_DRAG_VALID_PICTURE_OR_URL')
})
return
}
await uploadUrls(urls, invalidLines)
}
async function handleUriListDrop (uriListText: string, urlString: string) {
const { urls, invalidLines } = parseNewlineSeparatedUrls(uriListText, { source: 'uri-list' })
if (urls.length) {
await uploadUrls(urls, invalidLines)
return
}
const urlMatch = urlString.match(/<img.*src="(.*?)"/)
if (urlMatch && isUrl(urlMatch[1])) {
await uploadUrls([urlMatch[1]], invalidLines)
return
}
showNotification({
title: $T('TIPS_ERROR'),
body: $T('TIPS_DRAG_VALID_PICTURE_OR_URL')
})
}
function openUploadWindow () {
+104 -39
View File
@@ -109,16 +109,19 @@ import {
IpcRendererEvent,
ipcRenderer
} from 'electron'
import { ElMessage as $message } from 'element-plus'
import { ElMessage as $message, ElMessageBox } from 'element-plus'
import { onBeforeMount, onBeforeUnmount, ref, watch } from 'vue'
import {
GET_PICBEDS,
LOG_INVALID_URL_LINES,
SHOW_INPUT_BOX,
SHOW_INPUT_BOX_RESPONSE,
SHOW_UPLOAD_PAGE_MENU
} from '~/universal/events/constants'
import {
isUrl
extractHttpUrlsFromText,
isUrl,
parseNewlineSeparatedUrls
} from '~/universal/utils/common'
const dragover = ref(false)
const progress = ref(0)
@@ -128,6 +131,7 @@ const pasteStyle = ref('')
const picBed = ref<IPicBedType[]>([])
const picBedName = ref('')
const configName = ref('')
const $confirm = ElMessageBox.confirm
onBeforeMount(() => {
ipcRenderer.on('uploadProgress', (event: IpcRendererEvent, _progress: number) => {
if (_progress !== -1) {
@@ -169,42 +173,88 @@ onBeforeUnmount(() => {
ipcRenderer.removeListener(GET_PICBEDS, getPicBeds)
})
function onDrop (e: DragEvent) {
async function onDrop (e: DragEvent) {
dragover.value = false
const items = e.dataTransfer?.items!
const files = e.dataTransfer?.files!
// send files first
if (files?.length) {
ipcSendFiles(e.dataTransfer?.files!)
} else {
if (items.length === 2 && items[0].type === 'text/uri-list') {
handleURLDrag(items, e.dataTransfer!)
} else if (items[0].type === 'text/plain') {
const str = e.dataTransfer!.getData(items[0].type)
if (isUrl(str)) {
sendToMain('uploadChoosedFiles', [{ path: str }])
} else {
$message.error($T('TIPS_DRAG_VALID_PICTURE_OR_URL'))
return
}
const dataTransfer = e.dataTransfer
if (!dataTransfer) return
const uriList = dataTransfer.getData('text/uri-list')
if (uriList) {
await handleUriListDrop(uriList, dataTransfer.getData('text/html'))
return
}
const plainText = dataTransfer.getData('text/plain')
if (plainText) {
await handlePlainTextDrop(plainText)
return
}
$message.error($T('TIPS_DRAG_VALID_PICTURE_OR_URL'))
}
async function confirmLargeUrlBatch (count: number, onCancel?: () => void): Promise<boolean> {
if (count <= 10) return true
try {
await $confirm(
$T('TIPS_TOO_MANY_URLS_CONFIRM', { n: count }),
$T('TIPS_WARNING'),
{
type: 'warning',
confirmButtonText: $T('CONFIRM'),
cancelButtonText: $T('CANCEL')
}
}
)
return true
} catch (e) {
onCancel?.()
return false
}
}
function handleURLDrag (items: DataTransferItemList, dataTransfer: DataTransfer) {
// text/html
// Use this data to get a more precise URL
const urlString = dataTransfer.getData(items[1].type)
const urlMatch = urlString.match(/<img.*src="(.*?)"/)
if (urlMatch) {
sendToMain('uploadChoosedFiles', [
{
path: urlMatch[1]
}
])
} else {
$message.error($T('TIPS_DRAG_VALID_PICTURE_OR_URL'))
async function uploadUrls (urls: string[], invalidLines: string[], onCancel?: () => void) {
if (invalidLines.length) {
sendToMain(LOG_INVALID_URL_LINES, invalidLines)
$message.warning($T('TIPS_SKIPPED_INVALID_URLS', { n: invalidLines.length }))
}
const canUpload = await confirmLargeUrlBatch(urls.length, onCancel)
if (!canUpload) return
sendToMain('uploadChoosedFiles', urls.map((url) => ({ path: url })))
}
async function handlePlainTextDrop (plainText: string) {
const { urls, invalidLines } = parseNewlineSeparatedUrls(plainText, { source: 'plain' })
if (!urls.length) {
$message.error($T('TIPS_DRAG_VALID_PICTURE_OR_URL'))
return
}
await uploadUrls(urls, invalidLines)
}
async function handleUriListDrop (uriListText: string, urlString: string) {
const { urls, invalidLines } = parseNewlineSeparatedUrls(uriListText, { source: 'uri-list' })
if (urls.length) {
await uploadUrls(urls, invalidLines)
return
}
const urlMatch = urlString.match(/<img.*src="(.*?)"/)
if (urlMatch && isUrl(urlMatch[1])) {
await uploadUrls([urlMatch[1]], invalidLines)
return
}
$message.error($T('TIPS_DRAG_VALID_PICTURE_OR_URL'))
}
function openUploadWindow () {
@@ -234,7 +284,7 @@ async function getPasteStyle () {
pasteStyle.value = await getConfig('settings.pasteStyle') || 'markdown'
}
function handlePasteStyleChange (val: string | number | boolean) {
function handlePasteStyleChange (val: string | number | boolean | undefined) {
saveConfig({
'settings.pasteStyle': val
})
@@ -244,24 +294,39 @@ function uploadClipboardFiles () {
sendToMain('uploadClipboardFilesFromUploadPage')
}
async function uploadURLFiles () {
const str = await navigator.clipboard.readText()
function openUrlInputBox (value: string) {
$bus.emit(SHOW_INPUT_BOX, {
value: isUrl(str) ? str : '',
value,
title: $T('TIPS_INPUT_URL'),
placeholder: $T('TIPS_HTTP_PREFIX')
placeholder: $T('TIPS_HTTP_PREFIX'),
inputType: 'textarea'
})
}
function handleInputBoxValue (val: string) {
async function uploadURLFiles () {
let str = ''
try {
str = await navigator.clipboard.readText()
} catch (e) {}
const urls = extractHttpUrlsFromText(str)
openUrlInputBox(urls.join('\n'))
}
async function handleInputBoxValue (val: string) {
if (val === '') return
if (isUrl(val)) {
sendToMain('uploadChoosedFiles', [{
path: val
}])
} else {
$message.error($T('TIPS_INPUT_VALID_URL'))
const { urls, invalidLines } = parseNewlineSeparatedUrls(val, { source: 'plain' })
if (!urls.length) {
if (invalidLines.length) {
sendToMain(LOG_INVALID_URL_LINES, invalidLines)
$message.error($T('TIPS_SKIPPED_INVALID_URLS', { n: invalidLines.length }))
return
}
$message.error($T('TIPS_NO_VALID_URLS'))
return
}
await uploadUrls(urls, invalidLines, () => openUrlInputBox(val))
}
async function getDefaultPicBed () {
+577
View File
@@ -0,0 +1,577 @@
<template>
<div id="url-rewrite-page">
<div class="view-title">
{{ $T('SETTINGS_URL_REWRITE') }}
</div>
<el-row
class="url-rewrite-list"
justify="center"
>
<el-col
:span="20"
:offset="2"
>
<div class="flex mb-[12px] justify-between align-middle">
<div class="text-[12px] text-[#bbb] leading-[18px]">
{{ $T('URL_REWRITE_HELP') }}
</div>
<el-button
type="primary"
size="small"
@click="openAddDialog"
>
<el-icon class="mr-[4px]">
<Plus />
</el-icon>
{{ $T('URL_REWRITE_ADD_RULE') }}
</el-button>
</div>
<el-table
class="url-rewrite-table-border"
:data="rules"
size="small"
header-cell-class-name="url-rewrite-table-border"
cell-class-name="url-rewrite-table-border"
:empty-text="$T('URL_REWRITE_EMPTY')"
>
<el-table-column
width="50px"
:label="$T('URL_REWRITE_ORDER')"
>
<template #default="scope">
{{ scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column
min-width="300px"
:label="$T('URL_REWRITE_MATCH')"
>
<template #default="scope">
<span class="font-mono text-[12px] break-all">
{{ scope.row.match }}
</span>
</template>
</el-table-column>
<el-table-column
width="80px"
:label="$T('URL_REWRITE_FLAGS')"
>
<template #default="scope">
<el-tag
v-if="scope.row.global"
size="small"
>
G
</el-tag>
<el-tag
v-if="scope.row.ignoreCase"
size="small"
class="ml-[4px]"
>
I
</el-tag>
<span v-if="!scope.row.global && !scope.row.ignoreCase">-</span>
</template>
</el-table-column>
<el-table-column
width="70px"
:label="$T('URL_REWRITE_ENABLED')"
>
<template #default="scope">
<el-switch
v-model="scope.row.enable"
@change="handleToggleEnable(scope.$index, $event as boolean)"
/>
</template>
</el-table-column>
<el-table-column
width="220px"
:label="$T('URL_REWRITE_ACTIONS')"
>
<template #default="scope">
<el-row class="mb-[2px]">
<el-button
size="small"
type="text"
:disabled="scope.$index === 0"
@click="moveRule(scope.$index, scope.$index - 1)"
>
<el-icon class="mr-[2px]">
<ArrowUp />
</el-icon>
{{ $T('URL_REWRITE_MOVE_UP') }}
</el-button>
<el-button
size="small"
type="text"
:disabled="scope.$index === rules.length - 1"
@click="moveRule(scope.$index, scope.$index + 1)"
>
<el-icon class="mr-[2px]">
<ArrowDown />
</el-icon>
{{ $T('URL_REWRITE_MOVE_DOWN') }}
</el-button>
</el-row>
<el-row>
<el-button
size="small"
type="text"
@click="openEditDialog(scope.row, scope.$index)"
>
<el-icon class="mr-[2px]">
<Edit />
</el-icon>
{{ $T('URL_REWRITE_EDIT') }}
</el-button>
<el-button
class="danger"
size="small"
type="text"
@click="confirmDelete(scope.$index)"
>
<el-icon class="mr-[2px]">
<Delete />
</el-icon>
{{ $T('URL_REWRITE_DELETE') }}
</el-button>
</el-row>
</template>
</el-table-column>
</el-table>
<div class="mt-[16px] rounded-[8px] url-rewrite-panel p-[12px]">
<div class="text-[14px] mb-[8px] text-[#bbb] font-bold">
{{ $T('URL_REWRITE_PREVIEW_TITLE') }}
</div>
<div class="text-[12px] text-[#bbb] leading-[18px] mb-[10px]">
{{ $T('URL_REWRITE_PREVIEW_TIPS') }}
</div>
<el-row
class="mb-[10px]"
:gutter="10"
>
<el-col :span="18">
<el-input
v-model="previewInputUrl"
:placeholder="$T('URL_REWRITE_PREVIEW_PLACEHOLDER')"
size="small"
/>
</el-col>
<el-col :span="6">
<el-button
type="primary"
size="small"
class="w-full"
@click="runPreview"
>
{{ $T('URL_REWRITE_PREVIEW_RUN') }}
</el-button>
</el-col>
</el-row>
<el-alert
v-if="previewStatus === 'error'"
:title="previewMessage"
type="error"
show-icon
:closable="false"
/>
<el-alert
v-else-if="previewStatus === 'matched'"
:title="previewMessage"
type="success"
show-icon
:closable="false"
/>
<el-alert
v-else-if="previewStatus === 'noMatch'"
:title="previewMessage"
type="info"
show-icon
:closable="false"
/>
<div
v-if="previewStatus !== 'idle'"
class="mt-[10px]"
>
<div class="text-[12px] text-[#bbb] mb-[6px]">
{{ $T('URL_REWRITE_PREVIEW_OUTPUT') }}
</div>
<div class="rounded-[6px] url-rewrite-mono-box p-[10px] font-mono text-[12px] break-all text-[#bbb]">
{{ previewOutputUrl }}
</div>
</div>
</div>
</el-col>
</el-row>
<el-dialog
v-model="editDialogVisible"
:title="editDialogTitle"
width="500px"
:append-to-body="true"
>
<el-form
label-position="top"
label-width="80px"
size="small"
>
<el-form-item
:label="$T('URL_REWRITE_MATCH')"
>
<div class="flex flex-col gap-[6px] w-full">
<div class="text-[12px] text-[#bbb] leading-[18px]">
{{ $T('URL_REWRITE_MATCH_TIPS') }}
</div>
<el-input
v-model="ruleForm.match"
class="align-center"
:autofocus="true"
:placeholder="$T('URL_REWRITE_MATCH_PLACEHOLDER')"
/>
</div>
</el-form-item>
<el-form-item
:label="$T('URL_REWRITE_REPLACE')"
>
<div class="flex flex-col gap-[6px] w-full">
<div class="text-[12px] text-[#bbb] leading-[18px]">
{{ $T('URL_REWRITE_REPLACE_TIPS') }}
</div>
<el-input
v-model="ruleForm.replace"
class="align-center"
:placeholder="$T('URL_REWRITE_REPLACE_PLACEHOLDER')"
/>
</div>
</el-form-item>
<el-form-item
:label="$T('URL_REWRITE_OPTIONS')"
>
<div class="flex flex-col gap-[10px] w-full">
<el-row
justify="space-between"
align="middle"
>
<div class="text-[13px]">
{{ $T('URL_REWRITE_RULE_ENABLED') }}
</div>
<el-switch v-model="ruleForm.enable" />
</el-row>
<div class="grid grid-cols-2 gap-[12px]">
<div class="rounded-[6px] url-rewrite-option-card p-[10px]">
<el-checkbox
v-model="ruleForm.global"
>
{{ $T('URL_REWRITE_FLAG_GLOBAL_LABEL') }}
</el-checkbox>
<div class="text-[12px] text-[#bbb] leading-[18px] mt-[6px]">
{{ $T('URL_REWRITE_FLAG_GLOBAL_DESC') }}
</div>
</div>
<div class="rounded-[6px] url-rewrite-option-card p-[10px]">
<el-checkbox
v-model="ruleForm.ignoreCase"
>
{{ $T('URL_REWRITE_FLAG_IGNORE_CASE_LABEL') }}
</el-checkbox>
<div class="text-[12px] text-[#bbb] leading-[18px] mt-[6px]">
{{ $T('URL_REWRITE_FLAG_IGNORE_CASE_DESC') }}
</div>
</div>
</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button
round
@click="cancelEditDialog"
>
{{ $T('CANCEL') }}
</el-button>
<el-button
type="primary"
round
@click="confirmEditDialog"
>
{{ $T('CONFIRM') }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { ArrowDown, ArrowUp, Delete, Edit, Plus } from '@element-plus/icons-vue'
import { ElMessage as $message, ElMessageBox } from 'element-plus'
import { computed, onBeforeMount, reactive, ref } from 'vue'
import { getConfig, saveConfig } from '@/utils/dataSender'
import { T as $T } from '@/i18n'
interface IUrlRewriteRule {
match: string
replace: string
enable: boolean
global: boolean
ignoreCase: boolean
}
const $confirm = ElMessageBox.confirm
const rules = ref<IUrlRewriteRule[]>([])
const editDialogVisible = ref(false)
const editDialogMode = ref<'add' | 'edit'>('add')
const editRuleIndex = ref(-1)
const previewInputUrl = ref('')
const previewOutputUrl = ref('')
const previewStatus = ref<'idle' | 'matched' | 'noMatch' | 'error'>('idle')
const previewMessage = ref('')
const ruleForm = reactive<IUrlRewriteRule>({
match: '',
replace: '',
enable: true,
global: false,
ignoreCase: false
})
const editDialogTitle = computed(() => {
return editDialogMode.value === 'add' ? $T('URL_REWRITE_ADD_RULE') : $T('URL_REWRITE_EDIT_RULE')
})
onBeforeMount(async () => {
await initRules()
})
async function initRules () {
const configRules = await getConfig<unknown>('settings.urlRewrite.rules')
rules.value = normalizeRules(configRules)
}
function normalizeRules (value: unknown): IUrlRewriteRule[] {
if (!Array.isArray(value)) return []
return value.map(item => {
const raw = (item ?? {}) as Partial<Record<keyof IUrlRewriteRule, unknown>>
return {
match: String(raw.match ?? ''),
replace: String(raw.replace ?? ''),
enable: raw.enable === false ? false : true,
global: raw.global === true,
ignoreCase: raw.ignoreCase === true
}
})
}
async function persistRules () {
try {
await saveConfig('settings.urlRewrite.rules', rules.value)
} catch (e) {
$message.error($T('OPERATION_FAILED'))
throw e
}
}
async function handleToggleEnable (index: number, value: boolean) {
if (!rules.value[index]) return
rules.value[index].enable = value
await persistRules()
}
async function moveRule (fromIndex: number, toIndex: number) {
if (fromIndex === toIndex) return
if (toIndex < 0 || toIndex >= rules.value.length) return
const next = [...rules.value]
const [item] = next.splice(fromIndex, 1)
next.splice(toIndex, 0, item)
rules.value = next
await persistRules()
}
async function confirmDelete (index: number) {
if (!rules.value[index]) return
try {
await $confirm($T('URL_REWRITE_DELETE_CONFIRM'), $T('TIPS_WARNING'), {
type: 'warning',
confirmButtonText: $T('CONFIRM'),
cancelButtonText: $T('CANCEL')
})
} catch {
return
}
rules.value.splice(index, 1)
await persistRules()
}
function openAddDialog () {
editDialogMode.value = 'add'
editRuleIndex.value = -1
ruleForm.match = ''
ruleForm.replace = ''
ruleForm.enable = true
ruleForm.global = false
ruleForm.ignoreCase = false
editDialogVisible.value = true
}
function openEditDialog (rule: IUrlRewriteRule, index: number) {
editDialogMode.value = 'edit'
editRuleIndex.value = index
ruleForm.match = rule.match
ruleForm.replace = rule.replace
ruleForm.enable = rule.enable
ruleForm.global = rule.global
ruleForm.ignoreCase = rule.ignoreCase
editDialogVisible.value = true
}
function cancelEditDialog () {
editDialogVisible.value = false
}
function buildRuleFlags (rule: Pick<IUrlRewriteRule, 'global' | 'ignoreCase'>) {
return `${rule.global ? 'g' : ''}${rule.ignoreCase ? 'i' : ''}`
}
function validateRuleOrThrow (rule: IUrlRewriteRule) {
if (!rule.match.trim()) {
throw new Error($T('URL_REWRITE_MATCH_REQUIRED'))
}
if (!rule.replace.trim()) {
throw new Error($T('URL_REWRITE_REPLACE_REQUIRED'))
}
new RegExp(rule.match, buildRuleFlags(rule))
}
function runPreview () {
previewStatus.value = 'idle'
previewMessage.value = ''
previewOutputUrl.value = previewInputUrl.value
const url = previewInputUrl.value
if (!url) {
previewStatus.value = 'error'
previewMessage.value = $T('URL_REWRITE_PREVIEW_INPUT_REQUIRED')
return
}
for (const [index, rule] of rules.value.entries()) {
if (rule.enable === false) continue
let regexp: RegExp
try {
regexp = new RegExp(rule.match, buildRuleFlags(rule))
} catch (e) {
previewStatus.value = 'error'
previewMessage.value = `${$T('URL_REWRITE_PREVIEW_RULE_INVALID')} #${index + 1}: ${(e as Error).message}`
return
}
const matched = regexp.test(url)
regexp.lastIndex = 0
if (!matched) continue
const output = url.replace(regexp, rule.replace)
previewOutputUrl.value = output
previewStatus.value = 'matched'
previewMessage.value = `${$T('URL_REWRITE_PREVIEW_MATCHED_RULE')} #${index + 1}`
return
}
previewStatus.value = 'noMatch'
previewMessage.value = $T('URL_REWRITE_PREVIEW_NO_MATCH')
}
async function confirmEditDialog () {
const nextRule: IUrlRewriteRule = {
match: ruleForm.match,
replace: ruleForm.replace,
enable: ruleForm.enable,
global: ruleForm.global,
ignoreCase: ruleForm.ignoreCase
}
try {
validateRuleOrThrow(nextRule)
} catch (e) {
$message.error((e as Error).message || $T('URL_REWRITE_INVALID_REGEX'))
return
}
if (editDialogMode.value === 'add') {
rules.value.push(nextRule)
} else if (editRuleIndex.value >= 0 && rules.value[editRuleIndex.value]) {
rules.value.splice(editRuleIndex.value, 1, nextRule)
}
await persistRules()
editDialogVisible.value = false
$message.success($T('TIPS_SET_SUCCEED'))
}
</script>
<script lang="ts">
export default {
name: 'UrlRewritePage'
}
</script>
<style lang='stylus'>
#url-rewrite-page
.url-rewrite-list
height 360px
box-sizing border-box
overflow-y auto
overflow-x hidden
width 100%
.url-rewrite-panel
background rgba(130, 130, 130, .12)
border 1px solid darken(#eee, 50%)
.url-rewrite-option-card
background rgba(130, 130, 130, .12)
border 1px solid rgba(255, 255, 255, .06)
.url-rewrite-mono-box
background rgba(130, 130, 130, .12)
border 1px solid rgba(255, 255, 255, .06)
.url-rewrite-table-border
border-color darken(#eee, 50%)
.el-checkbox__label
color #aaa
.el-table
background-color: transparent
color #ddd
&::before
background-color darken(#eee, 50%)
thead
color #bbb
th,tr
background-color: transparent
&__body
tr.el-table__row--striped
td
background transparent
&--enable-row-hover
.el-table__body
tr:hover
&>td
background #333
.el-button+.el-button
margin-left 4px
.el-button
&.danger
color: #F56C6C
</style>
@@ -14,6 +14,11 @@
:button-label="$T('SETTINGS_CLICK_TO_SET')"
@click="goShortCutPage"
/>
<ButtonFormItem
:label="$T('SETTINGS_URL_REWRITE')"
:button-label="$T('SETTINGS_CLICK_TO_SET')"
@click="goUrlRewritePage"
/>
<ButtonFormItem
:label="$T('SETTINGS_CUSTOM_LINK_FORMAT')"
:button-label="$T('SETTINGS_CLICK_TO_SET')"
@@ -72,7 +77,7 @@ import { reactive, ref } from 'vue'
import { PICGO_OPEN_FILE } from '~/universal/events/constants'
import LogSettingDialog from './LogSettingDialog.vue'
import { useRouter } from 'vue-router'
import { SHORTKEY_PAGE } from '@/router/config'
import { SHORTKEY_PAGE, URL_REWRITE_PAGE } from '@/router/config'
import { useVModel } from '@/hooks/useVModel'
const $router = useRouter()
@@ -103,6 +108,12 @@ function goShortCutPage () {
})
}
function goUrlRewritePage () {
$router.push({
name: URL_REWRITE_PAGE
})
}
</script>
<script lang="ts">
export default {
+1
View File
@@ -8,5 +8,6 @@ export const PICBEDS_PAGE = 'PicbedsPage'
export const SETTING_PAGE = 'SettingPage'
export const PLUGIN_PAGE = 'PluginPage'
export const SHORTKEY_PAGE = 'ShortkeyPage'
export const URL_REWRITE_PAGE = 'UrlRewritePage'
export const UPLOADER_CONFIG_PAGE = 'UploaderConfigPage'
export const TOOLBOX_CONFIG_PAGE = 'ToolBoxPage'
+5
View File
@@ -57,6 +57,11 @@ export default createRouter({
component: () => import(/* webpackChunkName: "ShortkeyPage" */ '@/pages/ShortKey.vue'),
name: config.SHORTKEY_PAGE
},
{
path: 'urlRewrite',
component: () => import(/* webpackChunkName: "UrlRewritePage" */ '@/pages/UrlRewrite.vue'),
name: config.URL_REWRITE_PAGE
},
{
path: 'uploader-config-page/:type',
component: () => import(/* webpackChunkName: "Other" */ '@/pages/UploaderConfigPage.vue'),
+1 -5
View File
@@ -7,11 +7,7 @@ import {
type IEvent ={
[SHOW_INPUT_BOX_RESPONSE]: string
[SHOW_INPUT_BOX]: {
value: string
title: string
placeholder: string
},
[SHOW_INPUT_BOX]: IShowInputBoxOption,
[FORCE_UPDATE]: void
}
+5 -4
View File
@@ -25,13 +25,14 @@ export const trimValues = (obj: IStringKeyMap) => {
* get raw data from reactive or ref
*/
export const getRawData = (args: any): any => {
if (args === null) return args
if (Array.isArray(args)) {
const data = args.map((item: any) => {
if (isRef(item)) {
return unref(item)
return getRawData(unref(item))
}
if (isReactive(item)) {
return toRaw(item)
return getRawData(toRaw(item))
}
return getRawData(item)
})
@@ -42,9 +43,9 @@ export const getRawData = (args: any): any => {
Object.keys(args).forEach(key => {
const item = args[key]
if (isRef(item)) {
data[key] = unref(item)
data[key] = getRawData(unref(item))
} else if (isReactive(item)) {
data[key] = toRaw(item)
data[key] = getRawData(toRaw(item))
} else {
data[key] = getRawData(item)
}
+1 -1
View File
@@ -8,7 +8,7 @@ export async function saveConfig (_config: IObj | string, value?: any) {
let config
if (typeof _config === 'string') {
config = {
[_config]: value
[_config]: getRawData(value)
}
} else {
config = getRawData(_config)
+1
View File
@@ -1,5 +1,6 @@
export const SHOW_INPUT_BOX = 'SHOW_INPUT_BOX'
export const SHOW_INPUT_BOX_RESPONSE = 'SHOW_INPUT_BOX_RESPONSE'
export const LOG_INVALID_URL_LINES = 'LOG_INVALID_URL_LINES'
export const TOGGLE_SHORTKEY_MODIFIED_MODE = 'TOGGLE_SHORTKEY_MODIFIED_MODE'
export const TALKING_DATA_APPID = '7E6832BCE3F1438696579E541DFEBFDA'
export const TALKING_DATA_EVENT = 'TALKING_DATA_EVENT'
+54 -5
View File
@@ -63,6 +63,7 @@ interface ILocales {
SETTINGS_CLICK_TO_SET: string
SETTINGS_CLICK_TO_CHECK: string
SETTINGS_SET_SHORTCUT: string
SETTINGS_URL_REWRITE: string
SETTINGS_CUSTOM_LINK_FORMAT: string
SETTINGS_SET_PROXY_AND_MIRROR: string
SETTINGS_SET_SERVER: string
@@ -113,6 +114,43 @@ interface ILocales {
SETTINGS_CHOOSE_LANGUAGE: string
BUILTIN_CLIPBOARD_TIPS: string
UPLOADER_CONFIG_NAME: string
URL_REWRITE_HELP: string
URL_REWRITE_ADD_RULE: string
URL_REWRITE_EDIT_RULE: string
URL_REWRITE_EMPTY: string
URL_REWRITE_ORDER: string
URL_REWRITE_MATCH: string
URL_REWRITE_REPLACE: string
URL_REWRITE_FLAGS: string
URL_REWRITE_ENABLED: string
URL_REWRITE_ACTIONS: string
URL_REWRITE_MOVE_UP: string
URL_REWRITE_MOVE_DOWN: string
URL_REWRITE_EDIT: string
URL_REWRITE_DELETE: string
URL_REWRITE_DELETE_CONFIRM: string
URL_REWRITE_MATCH_TIPS: string
URL_REWRITE_MATCH_PLACEHOLDER: string
URL_REWRITE_REPLACE_TIPS: string
URL_REWRITE_REPLACE_PLACEHOLDER: string
URL_REWRITE_OPTIONS: string
URL_REWRITE_RULE_ENABLED: string
URL_REWRITE_FLAG_GLOBAL_LABEL: string
URL_REWRITE_FLAG_GLOBAL_DESC: string
URL_REWRITE_FLAG_IGNORE_CASE_LABEL: string
URL_REWRITE_FLAG_IGNORE_CASE_DESC: string
URL_REWRITE_MATCH_REQUIRED: string
URL_REWRITE_REPLACE_REQUIRED: string
URL_REWRITE_INVALID_REGEX: string
URL_REWRITE_PREVIEW_TITLE: string
URL_REWRITE_PREVIEW_TIPS: string
URL_REWRITE_PREVIEW_PLACEHOLDER: string
URL_REWRITE_PREVIEW_RUN: string
URL_REWRITE_PREVIEW_OUTPUT: string
URL_REWRITE_PREVIEW_INPUT_REQUIRED: string
URL_REWRITE_PREVIEW_RULE_INVALID: string
URL_REWRITE_PREVIEW_MATCHED_RULE: string
URL_REWRITE_PREVIEW_NO_MATCH: string
UPLOADER_CONFIG_PLACEHOLDER: string
SELECTED_SETTING_HINT: string
SETTINGS_ENCODE_OUTPUT_URL: string
@@ -134,11 +172,19 @@ interface ILocales {
SHORTCUT_DISABLE: string
SHORTCUT_EDIT: string
SHORTCUT_CHANGE_UPLOAD: string
CHANGE_IMAGE_URL_HOST: string
NEW_IMAGE_URL_HOST: string
SELECTED_IMAGE_URL_HOST: string
CHANGE_IMAGE_URL_HOST_RESULT: string
CHANGE_IMAGE_URL_HOST_WARN: string
GALLERY_URL_REWRITE_TITLE: string
GALLERY_URL_REWRITE_RESULT_TITLE: string
GALLERY_URL_REWRITE_WARN_NO_SELECTION: string
GALLERY_URL_REWRITE_APPLY_GLOBAL_RULES: string
GALLERY_URL_REWRITE_GLOBAL_RULES_COUNT: string
GALLERY_URL_REWRITE_TEMP_RULE_TIPS: string
GALLERY_URL_REWRITE_TEMP_RULE_REQUIRED: string
GALLERY_URL_REWRITE_NO_RULES_TO_APPLY: string
GALLERY_URL_REWRITE_SAVE_TEMP_RULE_PROMPT: string
GALLERY_URL_REWRITE_APPLY_AND_SAVE: string
GALLERY_URL_REWRITE_APPLY_ONLY: string
GALLERY_URL_REWRITE_NO_CHANGES: string
GALLERY_URL_REWRITE_EMPTY_RESULT_WARN: string
WAIT_TO_UPLOAD: string
ALREADY_UPLOAD: string
PICTURE_UPLOAD: string
@@ -207,6 +253,9 @@ interface ILocales {
TIPS_NOTICE: string
TIPS_WARNING: string
TIPS_ERROR: string
TIPS_SKIPPED_INVALID_URLS: string
TIPS_TOO_MANY_URLS_CONFIRM: string
TIPS_NO_VALID_URLS: string
TIPS_INSTALL_NODE_AND_RELOAD_PICGO: string
TIPS_PLUGIN_REMOVE_GALLERY_ITEM: string
TIPS_PLUGIN_OVERWRITE_GALLERY: string
+10
View File
@@ -55,6 +55,7 @@ interface ImgInfo {
imgUrl?: string
id?: string
type?: string
originImgUrl?: string
[propName: string]: any
}
@@ -183,6 +184,10 @@ interface IPicGoPluginShowConfigDialogOption {
tips?: string
confirmText?: string
cancelText?: string
/**
* default to 500
*/
width?: number
}
interface IPicGoPluginOriginConfig {
@@ -247,6 +252,11 @@ interface IShowInputBoxOption {
value?: string
title: string
placeholder: string
inputType?: 'text' | 'textarea'
/**
* default to 400
*/
width?: number
}
type IShowFileExplorerOption = IObj
+88
View File
@@ -1,4 +1,92 @@
export const isUrl = (url: string): boolean => (url.startsWith('http://') || url.startsWith('https://'))
export interface IParseNewlineSeparatedUrlsResult {
urls: string[]
invalidLines: string[]
}
export interface IParseNewlineSeparatedUrlsOptions {
source?: 'plain' | 'uri-list'
}
export const parseNewlineSeparatedUrls = (
input: string,
options: IParseNewlineSeparatedUrlsOptions = {}
): IParseNewlineSeparatedUrlsResult => {
const source = options.source || 'plain'
const urls: string[] = []
const invalidLines: string[] = []
const seen = new Set<string>()
const splitConcatenatedUrls = (line: string): string[] => {
const indexes: number[] = []
const re = /https?:\/\//g
let match: RegExpExecArray | null = null
while ((match = re.exec(line))) {
const index = match.index
if (index === 0) {
indexes.push(index)
continue
}
const prevChar = line[index - 1]
if (prevChar === '=' || prevChar === '?' || prevChar === '&' || prevChar === '#') continue
indexes.push(index)
}
if (indexes.length <= 1) return [line]
return indexes.map((startIndex, i) => {
const endIndex = indexes[i + 1] || line.length
return line.slice(startIndex, endIndex)
})
}
const normalizedInput = input
.split('\u0000').join('\n')
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
normalizedInput.split('\n').forEach((rawLine) => {
const line = rawLine.trim()
if (!line) return
if (source === 'uri-list' && line.startsWith('#')) return
if (!isUrl(line)) {
invalidLines.push(rawLine)
return
}
const parts = splitConcatenatedUrls(line)
parts.forEach((part) => {
if (seen.has(part)) return
urls.push(part)
seen.add(part)
})
})
return {
urls,
invalidLines
}
}
export const extractHttpUrlsFromText = (text: string): string[] => {
const urls: string[] = []
const seen = new Set<string>()
const matches = text.match(/https?:\/\/[^\s<>"']+/g) || []
matches.forEach((match) => {
const maybeUrl = match.replace(/[)\]}>.,;:!?]+$/, '')
if (seen.has(maybeUrl)) return
if (!isUrl(maybeUrl)) return
urls.push(maybeUrl)
seen.add(maybeUrl)
})
return urls
}
export const isUrlEncode = (url: string): boolean => {
url = url || ''
try {
-11
View File
@@ -1,11 +0,0 @@
{
"env": {
"mocha": true
},
"globals": {
"assert": true,
"expect": true,
"should": true,
"__static": true
}
}
-18
View File
@@ -1,18 +0,0 @@
'use strict'
// Set BABEL_ENV to use proper env config
process.env.BABEL_ENV = 'test'
// Enable use of ES6+ on required files
require('babel-register')({
ignore: /node_modules/
})
// Attach Chai APIs to global scope
const { expect, should, assert } = require('chai')
global.expect = expect
global.should = should
global.assert = assert
// Require all JS files in `./specs` for Mocha to consume
require('require-dir')('./specs')
-13
View File
@@ -1,13 +0,0 @@
import utils from '../utils'
describe('Launch', function () {
beforeEach(utils.beforeEach)
afterEach(utils.afterEach)
it('shows the proper application title', function () {
return this.app.client.getTitle()
.then(title => {
expect(title).to.equal('picgo')
})
})
})
-23
View File
@@ -1,23 +0,0 @@
import electron from 'electron'
import { Application } from 'spectron'
export default {
afterEach () {
this.timeout(10000)
if (this.app && this.app.isRunning()) {
return this.app.stop()
}
},
beforeEach () {
this.timeout(10000)
this.app = new Application({
path: electron,
args: ['dist/electron/main.js'],
startTimeout: 10000,
waitTimeout: 10000
})
return this.app.start()
}
}
-13
View File
@@ -1,13 +0,0 @@
import Vue from 'vue'
Vue.config.devtools = false
Vue.config.productionTip = false
// require all test files (files that ends with .spec.js)
const testsContext = require.context('./specs', true, /\.spec$/)
testsContext.keys().forEach(testsContext)
// require all src files except main.js for coverage.
// you can also change this to match only the subset of files that
// you want coverage for.
const srcContext = require.context('../../src/renderer', true, /^\.\/(?!main(\.js)?$)/)
srcContext.keys().forEach(srcContext)
-62
View File
@@ -1,62 +0,0 @@
'use strict'
const path = require('path')
const merge = require('webpack-merge')
const webpack = require('webpack')
const baseConfig = require('../../.electron-vue/webpack.renderer.config')
const projectRoot = path.resolve(__dirname, '../../src/renderer')
// Set BABEL_ENV to use proper preset config
process.env.BABEL_ENV = 'test'
let webpackConfig = merge(baseConfig, {
devtool: '#inline-source-map',
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': '"testing"'
})
]
})
// don't treat dependencies as externals
delete webpackConfig.entry
delete webpackConfig.externals
delete webpackConfig.output.libraryTarget
// apply vue option to apply isparta-loader on js
webpackConfig.module.rules
.find(rule => rule.use.loader === 'vue-loader').use.options.loaders.js = 'babel-loader'
module.exports = config => {
config.set({
browsers: ['visibleElectron'],
client: {
useIframe: false
},
coverageReporter: {
dir: './coverage',
reporters: [
{ type: 'lcov', subdir: '.' },
{ type: 'text-summary' }
]
},
customLaunchers: {
'visibleElectron': {
base: 'Electron',
flags: ['--show']
}
},
frameworks: ['mocha', 'chai'],
files: ['./index.js'],
preprocessors: {
'./index.js': ['webpack', 'sourcemap']
},
reporters: ['spec', 'coverage'],
singleRun: true,
webpack: webpackConfig,
webpackMiddleware: {
noInfo: true
}
})
}
-13
View File
@@ -1,13 +0,0 @@
import Vue from 'vue'
import LandingPage from '@/components/LandingPage'
describe('LandingPage.vue', () => {
it('should render correct contents', () => {
const vm = new Vue({
el: document.createElement('div'),
render: h => h(LandingPage)
}).$mount()
expect(vm.$el.querySelector('.title').textContent).to.contain('Welcome to your new project!')
})
})