From 22d89223631c8e079bbff09d14b4652d2b8ea2b7 Mon Sep 17 00:00:00 2001 From: wood chen Date: Fri, 21 Mar 2025 19:58:05 +0800 Subject: [PATCH] 1.0.0 --- .github/pull_request_template.md | 25 ++ .github/workflows/release.yml | 84 ++++ .gitignore | 15 + .prettierrc.json | 8 + .workflow/branch-pipeline.yml | 51 +++ .workflow/master-pipeline.yml | 49 +++ .workflow/pr-pipeline.yml | 36 ++ CHANGELOG.md | 0 LICENSE | 22 ++ README.md | 36 ++ esbuild.config.mjs | 45 +++ manifest.json | 11 + node-shims.js | 5 + package.json | 41 ++ readme-zh.md | 58 +++ rollup.config.js | 16 + src/helper.ts | 118 ++++++ src/lang/README.md | 13 + src/lang/helpers.ts | 57 +++ src/lang/locale/ar.ts | 3 + src/lang/locale/cz.ts | 3 + src/lang/locale/da.ts | 3 + src/lang/locale/de.ts | 3 + src/lang/locale/en-gb.ts | 3 + src/lang/locale/en.ts | 33 ++ src/lang/locale/es.ts | 3 + src/lang/locale/fr.ts | 3 + src/lang/locale/hi.ts | 3 + src/lang/locale/id.ts | 3 + src/lang/locale/it.ts | 3 + src/lang/locale/ja.ts | 3 + src/lang/locale/ko.ts | 3 + src/lang/locale/nl.ts | 3 + src/lang/locale/no.ts | 3 + src/lang/locale/pl.ts | 3 + src/lang/locale/pt-br.ts | 4 + src/lang/locale/pt.ts | 3 + src/lang/locale/ro.ts | 3 + src/lang/locale/ru.ts | 3 + src/lang/locale/tr.ts | 3 + src/lang/locale/zh-cn.ts | 31 ++ src/lang/locale/zh-tw.ts | 3 + src/main.ts | 648 +++++++++++++++++++++++++++++++ src/setting.ts | 194 +++++++++ src/uploader.ts | 201 ++++++++++ src/utils.ts | 79 ++++ tsconfig.json | 16 + versions.json | 3 + 48 files changed, 1959 insertions(+) create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .prettierrc.json create mode 100644 .workflow/branch-pipeline.yml create mode 100644 .workflow/master-pipeline.yml create mode 100644 .workflow/pr-pipeline.yml create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 esbuild.config.mjs create mode 100644 manifest.json create mode 100644 node-shims.js create mode 100644 package.json create mode 100644 readme-zh.md create mode 100644 rollup.config.js create mode 100644 src/helper.ts create mode 100644 src/lang/README.md create mode 100644 src/lang/helpers.ts create mode 100644 src/lang/locale/ar.ts create mode 100644 src/lang/locale/cz.ts create mode 100644 src/lang/locale/da.ts create mode 100644 src/lang/locale/de.ts create mode 100644 src/lang/locale/en-gb.ts create mode 100644 src/lang/locale/en.ts create mode 100644 src/lang/locale/es.ts create mode 100644 src/lang/locale/fr.ts create mode 100644 src/lang/locale/hi.ts create mode 100644 src/lang/locale/id.ts create mode 100644 src/lang/locale/it.ts create mode 100644 src/lang/locale/ja.ts create mode 100644 src/lang/locale/ko.ts create mode 100644 src/lang/locale/nl.ts create mode 100644 src/lang/locale/no.ts create mode 100644 src/lang/locale/pl.ts create mode 100644 src/lang/locale/pt-br.ts create mode 100644 src/lang/locale/pt.ts create mode 100644 src/lang/locale/ro.ts create mode 100644 src/lang/locale/ru.ts create mode 100644 src/lang/locale/tr.ts create mode 100644 src/lang/locale/zh-cn.ts create mode 100644 src/lang/locale/zh-tw.ts create mode 100644 src/main.ts create mode 100644 src/setting.ts create mode 100644 src/uploader.ts create mode 100644 src/utils.ts create mode 100644 tsconfig.json create mode 100644 versions.json diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..2ea48b6 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,25 @@ +## Obsidian Plugin Submission + +### Plugin Info + +- Plugin Name: LskyPro Upload +- Plugin Author: woodchen +- Plugin Description: Auto upload images from clipboard to LskyPro. +- Repository URL: https://github.com/woodchen-ink/obsidian-lskypro-upload +- Plugin License: MIT + +### Requirements + +- [x] My plugin is tested and works on the latest version of Obsidian +- [x] My plugin follows the [Community Plugin Guidelines](https://docs.obsidian.md/Developer+docs/Plugin+guidelines) +- [x] My plugin respects Obsidian's [Security Guidelines](https://docs.obsidian.md/Developer+docs/Security+best+practices) +- [x] I have added a valid `fundingUrl` field in my `manifest.json` (optional) + +### Additional Information + +- This plugin helps users automatically upload images to LskyPro image hosting service +- Features include: + - Auto upload on paste + - Support for public/private permissions + - Album support + - Custom domain support \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..42a2985 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,84 @@ +name: Release Obsidian plugin + +on: + push: + tags: + - "*" + +permissions: + contents: write + +env: + PLUGIN_NAME: lskypro-upload + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 # 获取完整历史记录 + + - name: Use Node.js + uses: actions/setup-node@v3 + with: + node-version: '18.x' + + - name: Get Version + id: version + run: | + echo "version=$(node -p "require('./manifest.json').version")" >> $GITHUB_OUTPUT + echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + + - name: Check Version Match + run: | + if [ "${{ steps.version.outputs.version }}" != "${{ steps.version.outputs.tag }}" ]; then + echo "Version in manifest.json (${{ steps.version.outputs.version }}) does not match tag (${{ steps.version.outputs.tag }})" + exit 1 + fi + + - name: Install Dependencies + run: npm install + + - name: Build plugin + run: npm run build + + - name: Create release package + run: | + mkdir ${{ env.PLUGIN_NAME }} + cp main.js manifest.json ${{ env.PLUGIN_NAME }} + if [ -f "styles.css" ]; then + cp styles.css ${{ env.PLUGIN_NAME }} + fi + zip -r ${{ env.PLUGIN_NAME }}-${{ steps.version.outputs.version }}.zip ${{ env.PLUGIN_NAME }} + + - name: Create source packages + run: | + # 创建临时目录 + mkdir temp_source + # 复制所有文件到临时目录,排除不需要的文件 + rsync -av --exclude='node_modules' --exclude='${{ env.PLUGIN_NAME }}' --exclude='*.zip' . temp_source/ + + # 从临时目录创建压缩包 + cd temp_source + zip -r ../source-code.zip . + tar czf ../source-code.tar.gz . + cd .. + + # 清理临时目录 + rm -rf temp_source + + - name: Create release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "${{ steps.version.outputs.tag }}" \ + --title="${{ steps.version.outputs.tag }}" \ + --notes "Release ${{ steps.version.outputs.tag }}" \ + ${{ env.PLUGIN_NAME }}-${{ steps.version.outputs.version }}.zip \ + main.js \ + manifest.json \ + $([ -f "styles.css" ] && echo "styles.css") \ + source-code.zip \ + source-code.tar.gz diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a65821 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Intellij +*.iml +.idea + +# npm +node_modules +package-lock.json +# build +*.js.map +main.js + +# ignore +data.json + +workspace.code-workspace \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..f0b18cc --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "arrowParens": "avoid" +} diff --git a/.workflow/branch-pipeline.yml b/.workflow/branch-pipeline.yml new file mode 100644 index 0000000..1128d8a --- /dev/null +++ b/.workflow/branch-pipeline.yml @@ -0,0 +1,51 @@ +version: '1.0' +name: branch-pipeline +displayName: BranchPipeline +stages: + - stage: + name: compile + displayName: 编译 + steps: + - step: build@nodejs + name: build_nodejs + displayName: Nodejs 构建 + # 支持8.16.2、10.17.0、12.16.1、14.16.0、15.12.0五个版本 + nodeVersion: 14.16.0 + # 构建命令:安装依赖 -> 清除上次打包产物残留 -> 执行构建 【请根据项目实际产出进行填写】 + commands: + - npm install && rm -rf ./dist && npm run build + # 非必填字段,开启后表示将构建产物暂存,但不会上传到制品库中,7天后自动清除 + artifacts: + # 构建产物名字,作为产物的唯一标识可向下传递,支持自定义,默认为BUILD_ARTIFACT。在下游可以通过${BUILD_ARTIFACT}方式引用来获取构建物地址 + - name: BUILD_ARTIFACT + # 构建产物获取路径,是指代码编译完毕之后构建物的所在路径 + path: + - ./dist + - step: publish@general_artifacts + name: publish_general_artifacts + displayName: 上传制品 + # 上游构建任务定义的产物名,默认BUILD_ARTIFACT + dependArtifact: BUILD_ARTIFACT + # 上传到制品库时的制品命名,默认output + artifactName: output + dependsOn: build_nodejs + - stage: + name: release + displayName: 发布 + steps: + - step: publish@release_artifacts + name: publish_release_artifacts + displayName: '发布' + # 上游上传制品任务的产出 + dependArtifact: output + # 发布制品版本号 + version: '1.0.0.0' + # 是否开启版本号自增,默认开启 + autoIncrement: true +triggers: + push: + branches: + exclude: + - master + include: + - .* diff --git a/.workflow/master-pipeline.yml b/.workflow/master-pipeline.yml new file mode 100644 index 0000000..8faf2bc --- /dev/null +++ b/.workflow/master-pipeline.yml @@ -0,0 +1,49 @@ +version: '1.0' +name: master-pipeline +displayName: MasterPipeline +stages: + - stage: + name: compile + displayName: 编译 + steps: + - step: build@nodejs + name: build_nodejs + displayName: Nodejs 构建 + # 支持8.16.2、10.17.0、12.16.1、14.16.0、15.12.0五个版本 + nodeVersion: 14.16.0 + # 构建命令:安装依赖 -> 清除上次打包产物残留 -> 执行构建 【请根据项目实际产出进行填写】 + commands: + - npm install && rm -rf ./dist && npm run build + # 非必填字段,开启后表示将构建产物暂存,但不会上传到制品库中,7天后自动清除 + artifacts: + # 构建产物名字,作为产物的唯一标识可向下传递,支持自定义,默认为BUILD_ARTIFACT。在下游可以通过${BUILD_ARTIFACT}方式引用来获取构建物地址 + - name: BUILD_ARTIFACT + # 构建产物获取路径,是指代码编译完毕之后构建物的所在路径 + path: + - ./dist + - step: publish@general_artifacts + name: publish_general_artifacts + displayName: 上传制品 + # 上游构建任务定义的产物名,默认BUILD_ARTIFACT + dependArtifact: BUILD_ARTIFACT + # 上传到制品库时的制品命名,默认output + artifactName: output + dependsOn: build_nodejs + - stage: + name: release + displayName: 发布 + steps: + - step: publish@release_artifacts + name: publish_release_artifacts + displayName: '发布' + # 上游上传制品任务的产出 + dependArtifact: output + # 发布制品版本号 + version: '1.0.0.0' + # 是否开启版本号自增,默认开启 + autoIncrement: true +triggers: + push: + branches: + include: + - master diff --git a/.workflow/pr-pipeline.yml b/.workflow/pr-pipeline.yml new file mode 100644 index 0000000..1a05dd0 --- /dev/null +++ b/.workflow/pr-pipeline.yml @@ -0,0 +1,36 @@ +version: '1.0' +name: pr-pipeline +displayName: PRPipeline +stages: + - stage: + name: compile + displayName: 编译 + steps: + - step: build@nodejs + name: build_nodejs + displayName: Nodejs 构建 + # 支持8.16.2、10.17.0、12.16.1、14.16.0、15.12.0五个版本 + nodeVersion: 14.16.0 + # 构建命令:安装依赖 -> 清除上次打包产物残留 -> 执行构建 【请根据项目实际产出进行填写】 + commands: + - npm install && rm -rf ./dist && npm run build + # 非必填字段,开启后表示将构建产物暂存,但不会上传到制品库中,7天后自动清除 + artifacts: + # 构建产物名字,作为产物的唯一标识可向下传递,支持自定义,默认为BUILD_ARTIFACT。在下游可以通过${BUILD_ARTIFACT}方式引用来获取构建物地址 + - name: BUILD_ARTIFACT + # 构建产物获取路径,是指代码编译完毕之后构建物的所在路径 + path: + - ./dist + - step: publish@general_artifacts + name: publish_general_artifacts + displayName: 上传制品 + # 上游构建任务定义的产物名,默认BUILD_ARTIFACT + dependArtifact: BUILD_ARTIFACT + # 上传到制品库时的制品命名,默认output + artifactName: output + dependsOn: build_nodejs +triggers: + pr: + branches: + include: + - master diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5e1bbdd --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2023 NekoTarou +Copyright (c) 2025 WoodChen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9cfbb70 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# LskyPro Upload Plugin + +A plugin for uploading images to LskyPro image hosting service. + +## Features + +- Auto upload images from clipboard +- Support uploading to specified album +- Support public/private image permissions +- Support custom domain +- Support token authentication + +## Installation + +1. Open Obsidian Settings +2. Go to Community Plugins and disable Safe Mode +3. Click Browse and search for "LskyPro Upload" +4. Install and enable the plugin + +## Configuration + +1. Get your LskyPro token from your LskyPro instance +2. Configure the plugin settings: + - Set your LskyPro domain + - Add your token + - Configure other optional settings + +## Usage + +1. Copy an image +2. Paste into your note +3. The image will be automatically uploaded to LskyPro + +## License + +MIT \ No newline at end of file diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..13d861a --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,45 @@ +import esbuild from "esbuild"; +import process from "process"; +import builtins from "builtin-modules"; + +const banner = +`/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +if you want to view the source, please visit the github repository of this plugin +*/ +`; + +const prod = process.argv[2] === "production"; + +esbuild.build({ + banner: { + js: banner, + }, + entryPoints: ["src/main.ts"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins], + format: "cjs", + target: "es2018", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", + platform: 'node', + inject: [ + "./node-shims.js", + ], +}).catch(() => process.exit(1)); \ No newline at end of file diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..37d5e07 --- /dev/null +++ b/manifest.json @@ -0,0 +1,11 @@ +{ + "id": "LskyPro-Uploader", + "name": "LskyPro Uploader", + "version": "1.0.0", + "minAppVersion": "0.15.0", + "description": "Auto upload images from clipboard to LskyPro.", + "author": "woodchen", + "authorUrl": "https://woodchen.ink", + "isDesktopOnly": true, + "repo": "woodchen-ink/lskypro-uploader" +} diff --git a/node-shims.js b/node-shims.js new file mode 100644 index 0000000..04d6787 --- /dev/null +++ b/node-shims.js @@ -0,0 +1,5 @@ +import { Buffer } from 'buffer'; +import process from 'process'; +import { Stream } from 'stream'; + +export { Buffer, process, Stream }; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..5eeb3ee --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "lskypro-uploader", + "version": "1.0.0", + "type": "module", + "description": "Auto upload images from clipboard to LskyPro", + "main": "main.js", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", + "version": "node version-bump.mjs && git add manifest.json versions.json" + }, + "keywords": [ + "obsidian.md", + "lsky", + "upload", + "auto" + ], + "author": "woodchen", + "license": "MIT", + "devDependencies": { + "@rollup/plugin-commonjs": "^24.1.0", + "@rollup/plugin-json": "^6.0.0", + "@rollup/plugin-node-resolve": "^15.0.2", + "@rollup/plugin-typescript": "^11.1.0", + "@types/electron": "^1.6.10", + "@types/node": "^14.14.2", + "buffer": "^6.0.3", + "builtin-modules": "^4.0.0", + "esbuild": "^0.24.1", + "obsidian": "^1.2.8", + "process": "^0.11.10", + "rollup": "^3.21.6", + "stream": "^0.0.3", + "tslib": "^2.5.0", + "typescript": "^5.7.2" + }, + "dependencies": { + "fix-path": "^4.0.0", + "image-type": "^5.2.0" + } +} diff --git a/readme-zh.md b/readme-zh.md new file mode 100644 index 0000000..2e24e15 --- /dev/null +++ b/readme-zh.md @@ -0,0 +1,58 @@ +# Obsidian LskyPro Auto Upload Plugin + +这是一个支持直接上传图片到图床[Lsky](https://github.com/lsky-org/lsky-pro)的工具,基于[renmu123/obsidian-image-auto-upload-plugin](https://github.com/renmu123/obsidian-image-auto-upload-plugin.git)改造。 +**更新插件后记得重启一下 Obsidian** + +# 开始 + +1. 安装 LskyPro 图床,并进行配置,配置参考[官网](https://www.lsky.pro/) +2. 开启 LskyPro 的接口 +3. 使用授权接口获取Token,并记录下来 +4. 打开插件配置项,设置LskyPro域名(例如:https://lsky.xxx.com) +5. 设置LskyPro Token +6. 存储策略ID是可选配置,根据 LskyPro 的策略和自己的要求来配置,如果只有一个策略,可以不设置 + +# 特性 + +## 剪切板上传 + +支持黏贴剪切板的图片的时候直接上传,目前支持复制系统内图像直接上传。 +支持通过设置 `frontmatter` 来控制单个文件的上传,默认值为 `true`,控制关闭请将该值设置为 `false` + +支持 ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".svg", ".tiff"(因为是直接调用LskyPro接口,理论上图床支持的文件都可以) + +```yaml +--- +image-auto-upload: true +--- +``` + +## 批量上传一个文件中的所有图像文件 + +输入 `ctrl+P` 呼出面板,输入 `upload all images`,点击回车,就会自动开始上传。 + +路径解析优先级,会依次按照优先级查找: + +1. 绝对路径,指基于库的绝对路径 +2. 相对路径,以./或../开头 +3. 尽可能简短的形式 + +## 批量下载网络图片到本地 + +输入 `ctrl+P` 呼出面板,输入 `download all images`,点击回车,就会自动开始下载。只在 win 进行过测试 + +## 支持右键菜单上传图片 + +目前已支持标准 md 以及 wiki 格式。支持相对路径以及绝对路径,需要进行正确设置,不然会引发奇怪的问题 + +## 支持拖拽上传 + +允许多文件拖拽 + + + +# TODO + +# 感谢 + +[renmu123/obsidian-image-auto-upload-plugin](https://github.com/renmu123/obsidian-image-auto-upload-plugin.git) \ No newline at end of file diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..33a9c20 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,16 @@ +import typescript from "@rollup/plugin-typescript"; +import { nodeResolve } from "@rollup/plugin-node-resolve"; +import commonjs from "@rollup/plugin-commonjs"; +import json from "@rollup/plugin-json"; + +export default { + input: "src/main.ts", + output: { + dir: ".", + sourcemap: "inline", + format: "cjs", + exports: "default", + }, + external: ["obsidian", "electron"], + plugins: [typescript(), nodeResolve({ browser: false }), commonjs(), json()], +}; diff --git a/src/helper.ts b/src/helper.ts new file mode 100644 index 0000000..da4cf78 --- /dev/null +++ b/src/helper.ts @@ -0,0 +1,118 @@ +import { MarkdownView, App } from "obsidian"; +import { parse } from "path"; + +interface Image { + path: string; + obspath: string; + name: string; + source: string; +} +// ![](./dsa/aa.png) local image should has ext +// ![](https://dasdasda) internet image should not has ext +//const REGEX_FILE = /\!\[(.*?)\]\((\S+\.\w+)\)|\!\[(.*?)\]\((https?:\/\/.*?)\)/g; +const REGEX_FILE = /!\[(.*?)\]\((.*?)\)/g; +const REGEX_WIKI_FILE = /\!\[\[(.*?)(\s\|.*?)?\]\]/g; +export default class Helper { + app: App; + + constructor(app: App) { + this.app = app; + } + getFrontmatterValue(key: string, defaultValue: any = undefined) { + const file = this.app.workspace.getActiveFile(); + if (!file) { + return undefined; + } + const path = file.path; + const cache = this.app.metadataCache.getCache(path); + + let value = defaultValue; + if (cache?.frontmatter && cache.frontmatter.hasOwnProperty(key)) { + value = cache.frontmatter[key]; + } + return value; + } + + getEditor() { + const mdView = this.app.workspace.getActiveViewOfType(MarkdownView); + if (mdView) { + return mdView.editor; + } else { + return null; + } + } + getValue() { + const editor = this.getEditor(); + return editor.getValue(); + } + + setValue(value: string) { + const editor = this.getEditor(); + const { left, top } = editor.getScrollInfo(); + const position = editor.getCursor(); + + editor.setValue(value); + editor.scrollTo(left, top); + editor.setCursor(position); + } + + // get all file urls, include local and internet + getAllFiles(): Image[] { + const editor = this.getEditor(); + let value = editor.getValue(); + return this.getImageLink(value); + } + getImageLink(value: string): Image[] { + const matches = value.matchAll(REGEX_FILE); + const WikiMatches = value.matchAll(REGEX_WIKI_FILE); + + let fileArray: Image[] = []; + + for (const match of matches) { + const source = match[0]; + + let name = match[1]; + let path = match[2]; + if (!name&&match.length>3) { + name = match[3]; + } + if (!path&&match.length>4) { + path = match[4]; + } + if (!name) { + name = path?.substring(path?.lastIndexOf('/')+1) + } + + fileArray.push({ + path: path, + obspath: path, + name: name, + source: source, + }); + } + + for (const match of WikiMatches) { + const name = parse(match[1]).name; + const path = match[1]; + const source = match[0]; + fileArray.push({ + path: path, + obspath: path, + name: name, + source: source, + }); + } + return fileArray; + } + + hasBlackDomain(src: string, blackDomains: string) { + if (blackDomains.trim() === "") { + return false; + } + const blackDomainList = blackDomains.split(",").filter(item => item !== ""); + let url = new URL(src); + const domain = url.hostname; + + return blackDomainList.some(blackDomain => domain.includes(blackDomain)); + } +} diff --git a/src/lang/README.md b/src/lang/README.md new file mode 100644 index 0000000..78f7f73 --- /dev/null +++ b/src/lang/README.md @@ -0,0 +1,13 @@ +## Localization + +The plugin has full localization support, and will attempt to load the current Obsidian locale. If one does not exist, it will fall back to English. + +### Adding a new Locale + +New locales can be added by creating a pull request. Two things should be done in this pull request: + +1. Create the locale in the `locales` folder by copying the `en.ts` file. This file should be given a name matching the string returned by `moment.locale()`. +2. Create the translation by editing the value of each property. +3. Add the import in `locales.ts`. +4. Add the language to the `localeMap` variable. + diff --git a/src/lang/helpers.ts b/src/lang/helpers.ts new file mode 100644 index 0000000..ec930e2 --- /dev/null +++ b/src/lang/helpers.ts @@ -0,0 +1,57 @@ +import { moment } from 'obsidian'; + +import ar from './locale/ar'; +import cz from './locale/cz'; +import da from './locale/da'; +import de from './locale/de'; +import en from './locale/en'; +import enGB from './locale/en-gb'; +import es from './locale/es'; +import fr from './locale/fr'; +import hi from './locale/hi'; +import id from './locale/id'; +import it from './locale/it'; +import ja from './locale/ja'; +import ko from './locale/ko'; +import nl from './locale/nl'; +import no from './locale/no'; +import pl from './locale/pl'; +import pt from './locale/pt'; +import ptBR from './locale/pt-br'; +import ro from './locale/ro'; +import ru from './locale/ru'; +import tr from './locale/tr'; +import zhCN from './locale/zh-cn'; +import zhTW from './locale/zh-tw'; + +const localeMap: { [k: string]: Partial } = { + ar, + cs: cz, + da, + de, + en, + 'en-gb': enGB, + es, + fr, + hi, + id, + it, + ja, + ko, + nl, + nn: no, + pl, + pt, + 'pt-br': ptBR, + ro, + ru, + tr, + 'zh-cn': zhCN, + 'zh-tw': zhTW, +}; + +const locale = localeMap[moment.locale()]; + +export function t(str: keyof typeof en): string { + return (locale && locale[str]) || en[str]; +} diff --git a/src/lang/locale/ar.ts b/src/lang/locale/ar.ts new file mode 100644 index 0000000..fdcaab2 --- /dev/null +++ b/src/lang/locale/ar.ts @@ -0,0 +1,3 @@ +// العربية + +export default {}; diff --git a/src/lang/locale/cz.ts b/src/lang/locale/cz.ts new file mode 100644 index 0000000..6ab53ab --- /dev/null +++ b/src/lang/locale/cz.ts @@ -0,0 +1,3 @@ +// čeština + +export default {}; diff --git a/src/lang/locale/da.ts b/src/lang/locale/da.ts new file mode 100644 index 0000000..68f85af --- /dev/null +++ b/src/lang/locale/da.ts @@ -0,0 +1,3 @@ +// Dansk + +export default {}; diff --git a/src/lang/locale/de.ts b/src/lang/locale/de.ts new file mode 100644 index 0000000..9d044c2 --- /dev/null +++ b/src/lang/locale/de.ts @@ -0,0 +1,3 @@ +// Deutsch + +export default {}; \ No newline at end of file diff --git a/src/lang/locale/en-gb.ts b/src/lang/locale/en-gb.ts new file mode 100644 index 0000000..3678622 --- /dev/null +++ b/src/lang/locale/en-gb.ts @@ -0,0 +1,3 @@ +// British English + +export default {}; diff --git a/src/lang/locale/en.ts b/src/lang/locale/en.ts new file mode 100644 index 0000000..7aa1350 --- /dev/null +++ b/src/lang/locale/en.ts @@ -0,0 +1,33 @@ +// English + +export default { + // setting.ts + "Plugin Settings": "Plugin Settings", + "Auto pasted upload": "Auto pasted upload", + "If you set this value true, when you paste image, it will be auto uploaded": + "If you set this value true, when you paste image, it will be auto uploaded", + "Default uploader": "Default uploader", + "PicList desc": "Search PicList on Github to download and install", + "Delete image using PicList": "Delete image using PicList", + "Delete successfully": "Delete successfully", + "Delete failed": "Delete failed", + "Image size suffix": "Image size suffix", + "Image size suffix Description": "like |300 for resize image in ob.", + "Please input image size suffix": "Please input image size suffix", + "Error, could not delete": "Error, could not delete", + "Work on network": "Work on network", + "Work on network Description": + "Allow upload network image by 'Upload all' command.\n Or when you paste, md standard image link in your clipboard will be auto upload.", + fixPath: "fixPath", + "Upload when clipboard has image and text together": + "Upload when clipboard has image and text together", + "When you copy, some application like Excel will image and text to clipboard, you can upload or not.": + "When you copy, some application like Excel will image and text to clipboard, you can upload or not.", + "Network Domain Black List": "Network Domain Black List", + "Network Domain Black List Description": + "Image in the domain list will not be upload,use comma separated", + "Delete source file after you upload file": + "Delete source file after you upload file", + "Delete source file in ob assets after you upload file.": + "Delete source file in ob assets after you upload file.", +}; diff --git a/src/lang/locale/es.ts b/src/lang/locale/es.ts new file mode 100644 index 0000000..cc4ce68 --- /dev/null +++ b/src/lang/locale/es.ts @@ -0,0 +1,3 @@ +// Español + +export default {}; diff --git a/src/lang/locale/fr.ts b/src/lang/locale/fr.ts new file mode 100644 index 0000000..b05317b --- /dev/null +++ b/src/lang/locale/fr.ts @@ -0,0 +1,3 @@ +// français + +export default {}; diff --git a/src/lang/locale/hi.ts b/src/lang/locale/hi.ts new file mode 100644 index 0000000..ed2bf66 --- /dev/null +++ b/src/lang/locale/hi.ts @@ -0,0 +1,3 @@ +// हिन्दी + +export default {}; diff --git a/src/lang/locale/id.ts b/src/lang/locale/id.ts new file mode 100644 index 0000000..b431a6f --- /dev/null +++ b/src/lang/locale/id.ts @@ -0,0 +1,3 @@ +// Bahasa Indonesia + +export default {}; diff --git a/src/lang/locale/it.ts b/src/lang/locale/it.ts new file mode 100644 index 0000000..fb9329e --- /dev/null +++ b/src/lang/locale/it.ts @@ -0,0 +1,3 @@ +// Italiano + +export default {}; diff --git a/src/lang/locale/ja.ts b/src/lang/locale/ja.ts new file mode 100644 index 0000000..0876f5b --- /dev/null +++ b/src/lang/locale/ja.ts @@ -0,0 +1,3 @@ +// 日本語 + +export default {}; \ No newline at end of file diff --git a/src/lang/locale/ko.ts b/src/lang/locale/ko.ts new file mode 100644 index 0000000..74bfc3a --- /dev/null +++ b/src/lang/locale/ko.ts @@ -0,0 +1,3 @@ +// 한국어 + +export default {}; diff --git a/src/lang/locale/nl.ts b/src/lang/locale/nl.ts new file mode 100644 index 0000000..f6acbd3 --- /dev/null +++ b/src/lang/locale/nl.ts @@ -0,0 +1,3 @@ +// Nederlands + +export default {}; diff --git a/src/lang/locale/no.ts b/src/lang/locale/no.ts new file mode 100644 index 0000000..0739bc4 --- /dev/null +++ b/src/lang/locale/no.ts @@ -0,0 +1,3 @@ +// Norsk + +export default {}; diff --git a/src/lang/locale/pl.ts b/src/lang/locale/pl.ts new file mode 100644 index 0000000..caed52b --- /dev/null +++ b/src/lang/locale/pl.ts @@ -0,0 +1,3 @@ +// język polski + +export default {}; diff --git a/src/lang/locale/pt-br.ts b/src/lang/locale/pt-br.ts new file mode 100644 index 0000000..ace53cd --- /dev/null +++ b/src/lang/locale/pt-br.ts @@ -0,0 +1,4 @@ +// Português do Brasil +// Brazilian Portuguese + +export default {}; \ No newline at end of file diff --git a/src/lang/locale/pt.ts b/src/lang/locale/pt.ts new file mode 100644 index 0000000..f4cb0c8 --- /dev/null +++ b/src/lang/locale/pt.ts @@ -0,0 +1,3 @@ +// Português + +export default {}; diff --git a/src/lang/locale/ro.ts b/src/lang/locale/ro.ts new file mode 100644 index 0000000..4ea9cae --- /dev/null +++ b/src/lang/locale/ro.ts @@ -0,0 +1,3 @@ +// Română + +export default {}; diff --git a/src/lang/locale/ru.ts b/src/lang/locale/ru.ts new file mode 100644 index 0000000..7b0f002 --- /dev/null +++ b/src/lang/locale/ru.ts @@ -0,0 +1,3 @@ +// русский + +export default {}; diff --git a/src/lang/locale/tr.ts b/src/lang/locale/tr.ts new file mode 100644 index 0000000..d1db79b --- /dev/null +++ b/src/lang/locale/tr.ts @@ -0,0 +1,3 @@ +// Türkçe + +export default {}; diff --git a/src/lang/locale/zh-cn.ts b/src/lang/locale/zh-cn.ts new file mode 100644 index 0000000..2a2233c --- /dev/null +++ b/src/lang/locale/zh-cn.ts @@ -0,0 +1,31 @@ +// 简体中文 + +export default { + // setting.ts + "Plugin Settings": "插件设置", + "Auto pasted upload": "剪切板自动上传", + "If you set this value true, when you paste image, it will be auto uploaded": + "启用该选项后,黏贴图片时会自动上传", + "Default uploader": "默认上传器", + "Delete image using PicList": "使用 PicList 删除图片", + "Delete successfully": "删除成功", + "Delete failed": "删除失败", + "Error, could not delete": "错误,无法删除", + "Image size suffix": "图片大小后缀", + "Image size suffix Description": "比如:|300 用于调整图片大小", + "Please input image size suffix": "请输入图片大小后缀", + "Work on network": "应用网络图片", + "Work on network Description": + "当你上传所有图片时,也会上传网络图片。以及当你进行黏贴时,剪切板中的标准 md 图片会被上传", + fixPath: "修正PATH变量", + "Upload when clipboard has image and text together": + "当剪切板同时拥有文本和图片剪切板数据时是否上传图片", + "When you copy, some application like Excel will image and text to clipboard, you can upload or not.": + "当你复制时,某些应用例如 Excel 会在剪切板同时文本和图像数据,确认是否上传。", + "Network Domain Black List": "网络图片域名黑名单", + "Network Domain Black List Description": + "黑名单域名中的图片将不会被上传,用英文逗号分割", + "Delete source file after you upload file": "上传文件后移除源文件", + "Delete source file in ob assets after you upload file.": + "上传文件后移除在ob附件文件夹中的文件", +}; diff --git a/src/lang/locale/zh-tw.ts b/src/lang/locale/zh-tw.ts new file mode 100644 index 0000000..0b7c4b6 --- /dev/null +++ b/src/lang/locale/zh-tw.ts @@ -0,0 +1,3 @@ +// 繁體中文 + +export default {}; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..42bae06 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,648 @@ +import { + MarkdownView, + Plugin, + FileSystemAdapter, + Editor, + Menu, + Notice, + addIcon, + requestUrl, + MarkdownFileInfo, + TFile, +} from "obsidian"; + +import { join, parse, basename, dirname } from "path"; + +import imageType from "image-type"; + +import { + isAssetTypeAnImage, + getUrlAsset, + arrayToObject, +} from "./utils"; +import { LskyProUploader } from "./uploader"; +import Helper from "./helper"; + +import { SettingTab, PluginSettings, DEFAULT_SETTINGS } from "./setting"; + +interface Image { + path: string; + obspath: string; + name: string; + source: string; +} + +export default class imageAutoUploadPlugin extends Plugin { + settings: PluginSettings; + helper: Helper; + editor: Editor; + lskyUploader: LskyProUploader; + uploader: LskyProUploader; + + async loadSettings() { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + this.initializeUploader(); + } + + private initializeUploader() { + this.lskyUploader = new LskyProUploader(this.settings, this.app); + if (this.settings.uploader === "LskyPro") { + this.uploader = this.lskyUploader; + } else { + new Notice("unknown uploader"); + } + } + + async saveSettings() { + await this.saveData(this.settings); + this.initializeUploader(); + } + + onunload() { } + + async onload() { + await this.loadSettings(); + this.helper = new Helper(this.app); + + addIcon( + "upload", + ` + + ` + ); + + this.addSettingTab(new SettingTab(this.app, this)); + + this.addCommand({ + id: "Upload all images", + name: "Upload all images", + checkCallback: (checking: boolean) => { + let leaf = this.app.workspace.getActiveViewOfType(MarkdownView); + if (leaf) { + if (!checking) { + this.uploadAllFile(); + } + return true; + } + return false; + }, + }); + this.addCommand({ + id: "Download all images", + name: "Download all images", + checkCallback: (checking: boolean) => { + let leaf = this.app.workspace.getActiveViewOfType(MarkdownView); + if (leaf) { + if (!checking) { + this.downloadAllImageFiles(); + } + return true; + } + return false; + }, + }); + + this.setupPasteHandler(); + this.registerSelection(); + + // 添加文件菜单项 + this.registerEvent( + this.app.workspace.on("file-menu", (menu, file) => { + // 检查是否为 TFile 类型且是 markdown 文件 + if (file instanceof TFile && file.extension === "md") { + menu.addItem((item) => { + item + .setTitle("上传所有图片到图床") + .setIcon("upload") + .onClick(async () => { + // 获取当前文件的内容 + const content = await this.app.vault.read(file); + + // 临时设置 helper 的内容 + this.helper.setValue(content); + + // 调用现有的上传功能 + this.uploadAllFile(); + }); + }); + } + }) + ); + } + + registerSelection() { + this.registerEvent( + this.app.workspace.on( + "editor-menu", + (menu: Menu, editor: Editor, info: MarkdownView | MarkdownFileInfo) => { + if (this.app.workspace.getLeavesOfType("markdown").length === 0) { + return; + } + // 保留一个基本的空方法,以备将来可能需要添加其他编辑器菜单功能 + } + ) + ); + } + + async downloadAllImageFiles() { + const fileArray = this.helper.getAllFiles(); + const folderPathAbs = this.getAttachmentFolderPath(); + if (folderPathAbs==null||!folderPathAbs) { + new Notice( + `Get attachment folder path faild.` + ); + return ; + } + let absfolder = this.app.vault.getAbstractFileByPath(folderPathAbs); + if (!absfolder) { + this.app.vault.createFolder(folderPathAbs); + } + + let imageArray = []; + let count:number = 0; + for (const file of fileArray) { + if (!file.path.startsWith("http")) { + continue; + } + count++; + const url = file.path; + const asset = getUrlAsset(url); + let [name, ext] = [ + decodeURI(parse(asset).name).replaceAll(/[\\\\/:*?\"<>|]/g, "-"), + parse(asset).ext, + ]; + + // 如果文件名已存在,则用随机值替换 + if (this.app.vault.getAbstractFileByPath(folderPathAbs+"/"+asset)) { + name = (Math.random() + 1).toString(36).substring(2, 7); + } + try { + const response = await this.download(url, folderPathAbs, name, ext); + if (response.ok) { + imageArray.push({ + source: file.source, + name: name, + path: response.path, + }); + } + } catch (error) { + + } + + } + let value = this.helper.getValue(); + imageArray.map(image => { + value = value.replace( + image.source, + `![${image.name}](${encodeURI( + image.path + )})` + ); + }); + + this.helper.setValue(value); + + new Notice( + `all: ${count}\nsuccess: ${imageArray.length}\nfailed: ${count - imageArray.length + }` + ); + } + //获取附件路径(相对路径) + getAttachmentFolderPath() { + // @ts-ignore + let assetFolder: string = this.app.vault.config.attachmentFolderPath; + if (!assetFolder) { + assetFolder = "/" + } + const activeFile = this.app.vault.getAbstractFileByPath( + this.app.workspace.getActiveFile()?.path + ); + if (activeFile==null||!activeFile) { + return null; + } + const parentPath = activeFile.parent.path; + // 当前文件夹下的子文件夹 + if (assetFolder.startsWith("./")) { + assetFolder = assetFolder.substring(1); + let pathTem = parentPath + (assetFolder==="/"?"":assetFolder); + while(pathTem.startsWith("/")) { + pathTem = pathTem.substring(1); + } + return pathTem; + } else { + return assetFolder; + } + } + + async download(url: string, folderPath: string, name: string, ext: string) { + const response = await requestUrl({ url }); + const type = await imageType(new Uint8Array(response.arrayBuffer)); + + if (response.status !== 200) { + return { + ok: false, + msg: "error", + }; + } + if (!type) { + return { + ok: false, + msg: "error", + }; + } + + const buffer = Buffer.from(response.arrayBuffer); + + try { + let path = folderPath+'/'+`${name}${ext}`; + + if (!ext) { + path = folderPath +'/'+ `${name}.${type.ext}`; + } + return { + ok: true, + msg: "ok", + path: path, + type, + }; + } catch (err) { + console.error(err); + + return { + ok: false, + msg: err, + }; + } + } + + filterFile(fileArray: Image[]) { + const imageList: Image[] = []; + + for (const match of fileArray) { + if (match.path.startsWith("http")) { + if (this.settings.workOnNetWork) { + if ( + !this.helper.hasBlackDomain( + match.path, + this.settings.newWorkBlackDomains + ) + ) { + imageList.push({ + path: match.path, + obspath: match.path, + name: match.name, + source: match.source, + }); + } + } + } else { + imageList.push({ + path: match.path, + obspath: match.obspath, + name: match.name, + source: match.source, + }); + } + } + + return imageList; + } + getFile(fileName: string, fileMap: any) { + if (!fileMap) { + fileMap = arrayToObject(this.app.vault.getFiles(), "name"); + } + return fileMap[fileName]; + } + // uploda all file + async uploadAllFile() { + let content = this.helper.getValue(); + + const adapter = this.app.vault.adapter; + if (!(adapter instanceof FileSystemAdapter)) { + new Notice('Current adapter is not FileSystemAdapter'); + return; + } + const basePath = adapter.getBasePath(); + const activeFile = this.app.workspace.getActiveFile(); + const fileMap = arrayToObject(this.app.vault.getFiles(), "name"); + const filePathMap = arrayToObject(this.app.vault.getFiles(), "path"); + let imageList: Image[] = []; + const fileArray = this.filterFile(this.helper.getAllFiles()); + + // 收集所有需要上传的图片 + for (const match of fileArray) { + const imageName = match.name; + const encodedUri = match.path; + + if (!encodedUri.startsWith("http")) { + const matchPath = decodeURI(encodedUri); + const fileName = basename(matchPath); + let file; + // 绝对路径 + if (filePathMap[matchPath]) { + file = filePathMap[matchPath]; + } + + // 相对路径 + if ( + (!file && matchPath.startsWith("./")) || + matchPath.startsWith("../") + ) { + let absoPath = ""; + //查找相对路径 + if (matchPath.startsWith("./")) { + absoPath = dirname(activeFile.path)+matchPath.substring(1) + } else { + //对于../../开头的路径,需要向上查找匹配 + let num = matchPath.split("../").length-1; + absoPath = matchPath; + for (let i=0;iitem.path===abstractImageFile&&item.name===imageName&&item.source===match.source)) { + imageList.push(pushObj); + } + } + } + } + } + + if (imageList.length === 0) { + new Notice("没有解析到图像文件"); + return; + } + + new Notice(`共找到${imageList.length}个图像文件,开始上传`); + + // 一个一个上传图片 + for (const image of imageList) { + try { + const res = await this.uploader.uploadFilesByPath([image.obspath]); + if (res.success) { + const uploadUrl = res.result[0]; + // 替换内容 + content = content.replaceAll( + image.source, + `![${image.name}](${uploadUrl})` + ); + this.helper.setValue(content); + + // 如果设置了删除源文件 + if (this.settings.deleteSource) { + const fileDel = this.app.vault.getAbstractFileByPath(image.obspath); + if (fileDel) { + await this.app.fileManager.trashFile(fileDel); + } + } + + // 保存上传记录 + if (res.fullResult) { + this.settings.uploadedImages = [ + ...(this.settings.uploadedImages || []), + ...res.fullResult, + ]; + await this.saveSettings(); + } + + new Notice(`图片 ${image.name} 上传成功`); + } else { + new Notice(`图片 ${image.name} 上传失败`); + } + } catch (error) { + console.error(`Upload error for ${image.name}:`, error); + new Notice(`图片 ${image.name} 上传出错`); + } + } + + new Notice("所有图片处理完成"); + } + + setupPasteHandler() { + this.registerEvent( + this.app.workspace.on( + "editor-paste", + (evt: ClipboardEvent, editor: Editor, markdownView: MarkdownView) => { + const allowUpload = this.helper.getFrontmatterValue( + "image-auto-upload", + this.settings.uploadByClipSwitch + ); + + let files = evt.clipboardData.files; + if (!allowUpload) { + return; + } + // 剪贴板内容有md格式的图片时 + if (this.settings.workOnNetWork) { + const clipboardValue = evt.clipboardData.getData("text/plain"); + const imageList = this.helper + .getImageLink(clipboardValue) + .filter(image => image.path.startsWith("http")) + .filter( + image => + !this.helper.hasBlackDomain( + image.path, + this.settings.newWorkBlackDomains + ) + ); + + if (imageList.length !== 0) { + this.uploader + .uploadFilesByPath(imageList.map(item => item.path)) + .then(res => { + let value = this.helper.getValue(); + if (res.success) { + let uploadUrlList = res.result; + imageList.map(item => { + const uploadImage = uploadUrlList.shift(); + value = value.replaceAll( + item.source, + `![${item.name}](${uploadImage})` + ); + }); + this.helper.setValue(value); + const uploadUrlFullResultList = res.fullResult || []; + this.settings.uploadedImages = [ + ...(this.settings.uploadedImages || []), + ...uploadUrlFullResultList, + ]; + this.saveSettings(); + } else { + new Notice("Upload error"); + } + }); + } + } + + // 剪贴板中是图片时进行上传 + if (this.canUpload(evt.clipboardData)) { + this.uploadFileAndEmbedImgurImage( + editor, + async (editor: Editor, pasteId: string) => { + let res = await this.uploader.uploadFileByClipboard(evt); + if (res.code !== 0) { + this.handleFailedUpload(editor, pasteId, res.msg); + return; + } + const url = res.data; + const uploadUrlFullResultList = res.fullResult || []; + this.settings.uploadedImages = [ + ...(this.settings.uploadedImages || []), + ...uploadUrlFullResultList, + ]; + await this.saveSettings(); + return url; + }, + evt.clipboardData + ).catch(); + evt.preventDefault(); + } + } + ) + ); + this.registerEvent( + this.app.workspace.on( + "editor-drop", + async (evt: DragEvent, editor: Editor, markdownView: MarkdownView) => { + const allowUpload = this.helper.getFrontmatterValue( + "image-auto-upload", + this.settings.uploadByClipSwitch + ); + let files = evt.dataTransfer.files; + if (!allowUpload) { + return; + } + + if (files.length !== 0 && files[0].type.startsWith("image")) { + let files = evt.dataTransfer.files; + evt.preventDefault(); + + const data = await this.uploader.uploadFiles(Array.from(files)); + + if (data.success) { + const uploadUrlFullResultList = data.fullResult ?? []; + this.settings.uploadedImages = [ + ...(this.settings.uploadedImages ?? []), + ...uploadUrlFullResultList, + ]; + this.saveSettings(); + data.result.map((value: string) => { + let pasteId = (Math.random() + 1).toString(36).substring(2, 7); + this.insertTemporaryText(editor, pasteId); + this.embedMarkDownImage(editor, pasteId, value, files[0].name); + }); + } else { + new Notice("Upload error"); + } + } + } + ) + ); + } + + canUpload(clipboardData: DataTransfer) { + this.settings.applyImage; + const files = clipboardData.files; + const text = clipboardData.getData("text"); + + const hasImageFile = + files.length !== 0 && files[0].type.startsWith("image"); + if (hasImageFile) { + if (!!text) { + return this.settings.applyImage; + } else { + return true; + } + } else { + return false; + } + } + + async uploadFileAndEmbedImgurImage( + editor: Editor, + callback: Function, + clipboardData: DataTransfer + ) { + let pasteId = (Math.random() + 1).toString(36).substring(2, 7); + this.insertTemporaryText(editor, pasteId); + const name = clipboardData.files[0].name; + try { + const url = await callback(editor, pasteId); + this.embedMarkDownImage(editor, pasteId, url, name); + } catch (e) { + this.handleFailedUpload(editor, pasteId, e); + } + } + + insertTemporaryText(editor: Editor, pasteId: string) { + let progressText = imageAutoUploadPlugin.progressTextFor(pasteId); + editor.replaceSelection(progressText + "\n"); + } + + private static progressTextFor(id: string) { + return `![Uploading file...${id}]()`; + } + + embedMarkDownImage( + editor: Editor, + pasteId: string, + imageUrl: any, + name: string = "" + ) { + let progressText = imageAutoUploadPlugin.progressTextFor(pasteId); + const imageSizeSuffix = this.settings.imageSizeSuffix || ""; + let markDownImage = `![${name}${imageSizeSuffix}](${imageUrl})`; + + imageAutoUploadPlugin.replaceFirstOccurrence( + editor, + progressText, + markDownImage + ); + } + + handleFailedUpload(editor: Editor, pasteId: string, reason: any) { + new Notice(reason); + console.error("Failed request: ", reason); + let progressText = imageAutoUploadPlugin.progressTextFor(pasteId); + imageAutoUploadPlugin.replaceFirstOccurrence( + editor, + progressText, + "⚠️upload failed, check dev console" + ); + } + + static replaceFirstOccurrence( + editor: Editor, + target: string, + replacement: string + ) { + let lines = editor.getValue().split("\n"); + for (let i = 0; i < lines.length; i++) { + let ch = lines[i].indexOf(target); + if (ch != -1) { + let from = { line: i, ch: ch }; + let to = { line: i, ch: ch + target.length }; + editor.replaceRange(replacement, from, to); + break; + } + } + } +} diff --git a/src/setting.ts b/src/setting.ts new file mode 100644 index 0000000..9da2533 --- /dev/null +++ b/src/setting.ts @@ -0,0 +1,194 @@ +import { App, PluginSettingTab, Setting, Notice } from "obsidian"; +import imageAutoUploadPlugin from "./main"; +import { t } from "./lang/helpers"; + +export interface PluginSettings { + uploadByClipSwitch: boolean; + uploadServer: string; + token: string; + strategy_id: string; + uploader: string; + workOnNetWork: boolean; + newWorkBlackDomains: string; + fixPath: boolean; + applyImage: boolean; + deleteSource: boolean; + isPublic: boolean; + albumId: string; + [propName: string]: any; +} + +export const DEFAULT_SETTINGS: PluginSettings = { + uploadByClipSwitch: true, + uploader: "LskyPro", + token: "", + strategy_id:"", + uploadServer: "https://img.czl.net", + workOnNetWork: false, + fixPath: false, + applyImage: true, + newWorkBlackDomains: "", + deleteSource: false, + isPublic: false, + albumId: "", +}; + +export class SettingTab extends PluginSettingTab { + plugin: imageAutoUploadPlugin; + + constructor(app: App, plugin: imageAutoUploadPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + let { containerEl } = this; + containerEl.empty(); + new Setting(containerEl) + .setName(t("Auto pasted upload")) + .setDesc( + "启用该选项后,黏贴图片时会自动上传到lsky图床" + ) + .addToggle(toggle => + toggle + .setValue(this.plugin.settings.uploadByClipSwitch) + .onChange(async value => { + this.plugin.settings.uploadByClipSwitch = value; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName(t("Default uploader")) + .setDesc(t("Default uploader")) + .addDropdown(cb => + cb + .addOption("LskyPro", "LskyPro") + .setValue(this.plugin.settings.uploader) + .onChange(async value => { + this.plugin.settings.uploader = value; + this.display(); + await this.plugin.saveSettings(); + }) + ); + + if (this.plugin.settings.uploader === "LskyPro") { + new Setting(containerEl) + .setName("LskyPro 域名") + .setDesc("LskyPro 域名(不需要填写完整的API路径)") + .addText(text => + text + .setPlaceholder("请输入LskyPro 域名(例如:https://img.czl.net)") + .setValue(this.plugin.settings.uploadServer) + .onChange(async key => { + this.plugin.settings.uploadServer = key; + await this.plugin.saveSettings(); + }) + ); + new Setting(containerEl) + .setName("LskyPro Token") + .setDesc("LskyPro Token(不需要包含Bearer)") + .addText(text => + text + .setPlaceholder("请输入LskyPro Token") + .setValue(this.plugin.settings.token) + .onChange(async key => { + this.plugin.settings.token = key; + await this.plugin.saveSettings(); + }) + ); + new Setting(containerEl) + .setName("LskyPro Strategy id") + .setDesc("LskyPro 存储策略ID(非必填)") + .addText(text => + text + .setPlaceholder("存储策略ID") + .setValue(this.plugin.settings.strategy_id) + .onChange(async key => { + this.plugin.settings.strategy_id = key; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("图片是否公开") + .setDesc("设置上传图片的权限(开启=公开,关闭=私有)") + .addToggle(toggle => + toggle + .setValue(this.plugin.settings.isPublic) + .onChange(async value => { + this.plugin.settings.isPublic = value; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("相册ID") + .setDesc("指定上传到的相册ID(可选)") + .addText(text => + text + .setPlaceholder("相册ID") + .setValue(this.plugin.settings.albumId) + .onChange(async value => { + this.plugin.settings.albumId = value; + await this.plugin.saveSettings(); + }) + ); + } + + new Setting(containerEl) + .setName(t("Work on network")) + .setDesc(t("Work on network Description")) + .addToggle(toggle => + toggle + .setValue(this.plugin.settings.workOnNetWork) + .onChange(async value => { + this.plugin.settings.workOnNetWork = value; + this.display(); + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName(t("Network Domain Black List")) + .setDesc(t("Network Domain Black List Description")) + .addTextArea(textArea => + textArea + .setValue(this.plugin.settings.newWorkBlackDomains) + .onChange(async value => { + this.plugin.settings.newWorkBlackDomains = value; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName(t("Upload when clipboard has image and text together")) + .setDesc( + t( + "When you copy, some application like Excel will image and text to clipboard, you can upload or not." + ) + ) + .addToggle(toggle => + toggle + .setValue(this.plugin.settings.applyImage) + .onChange(async value => { + this.plugin.settings.applyImage = value; + this.display(); + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName(t("Delete source file after you upload file")) + .setDesc(t("Delete source file in ob assets after you upload file.")) + .addToggle(toggle => + toggle + .setValue(this.plugin.settings.deleteSource) + .onChange(async value => { + this.plugin.settings.deleteSource = value; + this.display(); + await this.plugin.saveSettings(); + }) + ); + } +} diff --git a/src/uploader.ts b/src/uploader.ts new file mode 100644 index 0000000..b9c639e --- /dev/null +++ b/src/uploader.ts @@ -0,0 +1,201 @@ +import { PluginSettings } from "./setting"; +import { App, TFile, Notice } from "obsidian"; + +// 添加返回值类型接口 +interface UploadResponse { + code: number; + msg: string; + data: string; + fullResult?: Array; +} + +//兰空上传器 +export class LskyProUploader { + settings: PluginSettings; + lskyUrl: string; + app: App; + + constructor(settings: PluginSettings, app: App) { + this.settings = settings; + this.lskyUrl = this.settings.uploadServer.endsWith("/") + ? this.settings.uploadServer + "api/v1/upload" + : this.settings.uploadServer + "/api/v1/upload"; + this.app = app; + } + + //上传请求配置 + getRequestOptions(file: File) { + let headers = new Headers(); + // 严格检查 token + if (!this.settings.token || this.settings.token.trim() === '') { + throw new Error('请先配置 Token'); + } + + + + // 设置认证头 + const authHeader = `Bearer ${this.settings.token.trim()}`; + headers.append("Authorization", authHeader); + + + + headers.append("Accept", "application/json"); + + let formdata = new FormData(); + formdata.append("file", file); + + // 添加策略ID(如果有) + if (this.settings.strategy_id) { + formdata.append("strategy_id", this.settings.strategy_id); + } + + // 添加权限设置 + formdata.append("permission", this.settings.isPublic ? "1" : "0"); + + // 添加相册ID(如果有) + if (this.settings.albumId) { + formdata.append("album_id", this.settings.albumId); + } + + + return { + method: "POST", + headers: headers, + body: formdata, + }; + } + + // 修改重试逻辑,增加对 401 的处理 + async retryFetch(url: string, options: RequestInit, maxRetries = 3): Promise { + for (let i = 0; i < maxRetries; i++) { + try { + const response = await fetch(url, options); + + // 如果是认证错误,直接抛出错误不重试 + if (response.status === 401) { + throw new Error('Token 无效或已过期,请检查配置'); + } + + // 对其他错误进行重试 + if (response.ok || response.status !== 403) { + return response; + } + + // 如果是 403 错误,等待一段时间后重试 + await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1))); + } catch (error) { + if (i === maxRetries - 1 || error.message.includes('Token')) { + throw error; + } + // 等待后重试 + await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1))); + } + } + throw new Error(`Failed after ${maxRetries} retries`); + } + + //上传文件,返回promise对象 + async promiseRequest(file: any): Promise { + try { + let requestOptions = this.getRequestOptions(file); + const response = await this.retryFetch(this.lskyUrl, requestOptions); + const value = await response.json(); + + if (!value.status) { + const errorMsg = value.message || '上传失败'; + new Notice(`上传失败: ${errorMsg}`); + return { + code: -1, + msg: errorMsg, + data: value.data, + fullResult: [] + }; + } + + return { + code: 0, + msg: "success", + data: value.data?.links?.url, + fullResult: [] + }; + + } catch (error) { + console.error("Upload error:", error); + new Notice(error.message || '上传出错'); + return { + code: -1, + msg: error.message || '上传失败', + data: "", + fullResult: [] + }; + } + } + + //通过路径创建文件 + async createFileObjectFromPath(path: string) { + return new Promise(resolve => { + if(path.startsWith('https://') || path.startsWith('http://')){ + return fetch(path).then(response => { + return response.blob().then(blob => { + resolve(new File([blob], path.split("/").pop())); + }); + }); + } + let obsfile = this.app.vault.getAbstractFileByPath(path); + //@ts-ignore + this.app.vault.readBinary(obsfile).then(data=>{ + const fileName = path.split("/").pop(); // 获取文件名 + const fileExtension = fileName.split(".").pop(); // 获取后缀名 + const blob = new Blob([data], { type: "image/" + fileExtension }); + const file = new File([blob], fileName); + resolve(file); + }).catch(err=>{ + console.error("Error reading file:", err); + return; + }); + }); + } + + async uploadFilesByPath(fileList: Array): Promise { + let promiseArr = fileList.map(async filepath => { + let file = await this.createFileObjectFromPath(filepath.format()); + return this.promiseRequest(file); + }); + try { + let reurnObj = await Promise.all(promiseArr); + return { + result: reurnObj.map((item: { data: string }) => item.data), + success: true, + }; + } catch (error) { + return { + success: false, + }; + } + } + async uploadFiles(fileList: Array): Promise { + let promiseArr = fileList.map(async file => { + return this.promiseRequest(file); + }); + try { + let reurnObj = await Promise.all(promiseArr); + let failItem:any = reurnObj.find((item: { code: number })=>item.code===-1); + if (failItem) { + throw {err:failItem.msg} + } + return { + result: reurnObj.map((item: { data: string }) => item.data), + success: true, + }; + } catch (error) { + return { + success: false, + }; + } + } + async uploadFileByClipboard(evt: ClipboardEvent): Promise { + let files = evt.clipboardData.files; + let file = files[0]; + return this.promiseRequest(file); + } +} \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..1c64a42 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,79 @@ +import { extname } from "path"; +import { Readable } from "stream"; + +export interface IStringKeyMap { + [key: string]: T; +} + +const IMAGE_EXT_LIST = [ + ".png", + ".jpg", + ".jpeg", + ".bmp", + ".gif", + ".svg", + ".tiff", + ".webp", + ".avif", +]; + +export function isAnImage(ext: string) { + return IMAGE_EXT_LIST.includes(ext.toLowerCase()); +} +export function isAssetTypeAnImage(path: string): Boolean { + return isAnImage(extname(path)); +} + +export async function streamToString(stream: Readable) { + const chunks: Uint8Array[] = []; + + for await (const chunk of stream) { + chunks.push(new Uint8Array(chunk)); + } + + // 计算总长度 + const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0); + + // 创建一个新的 Uint8Array 并复制所有数据 + const result = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + + return new TextDecoder().decode(result); +} + +export function getUrlAsset(url: string) { + return (url = url.substring(1 + url.lastIndexOf("/")).split("?")[0]).split( + "#" + )[0]; +} + +export function getLastImage(list: string[]) { + const reversedList = list.reverse(); + let lastImage; + reversedList.forEach(item => { + if (item && item.startsWith("http")) { + lastImage = item; + return item; + } + }); + return lastImage; +} + +interface AnyObj { + [key: string]: any; +} + +export function arrayToObject( + arr: T[], + key: string +): { [key: string]: T } { + const obj: { [key: string]: T } = {}; + arr.forEach(element => { + obj[element[key]] = element; + }); + return obj; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9e2da1e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "es5", + "downlevelIteration": true, + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "node", + "importHelpers": true, + "lib": ["dom", "es5", "scripthost", "es2015", "es2020", "ESNext"] + }, + "include": ["**/*.ts"] +} diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..d42b0ac --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "1.0.0": "0.0.1" +}