This commit is contained in:
wood chen 2025-03-21 19:58:05 +08:00
commit 22d8922363
48 changed files with 1959 additions and 0 deletions

25
.github/pull_request_template.md vendored Normal file
View File

@ -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

84
.github/workflows/release.yml vendored Normal file
View File

@ -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

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
# Intellij
*.iml
.idea
# npm
node_modules
package-lock.json
# build
*.js.map
main.js
# ignore
data.json
workspace.code-workspace

8
.prettierrc.json Normal file
View File

@ -0,0 +1,8 @@
{
"trailingComma": "es5",
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"arrowParens": "avoid"
}

View File

@ -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:
- .*

View File

@ -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

36
.workflow/pr-pipeline.yml Normal file
View File

@ -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

0
CHANGELOG.md Normal file
View File

22
LICENSE Normal file
View File

@ -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.

36
README.md Normal file
View File

@ -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

45
esbuild.config.mjs Normal file
View File

@ -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));

11
manifest.json Normal file
View File

@ -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"
}

5
node-shims.js Normal file
View File

@ -0,0 +1,5 @@
import { Buffer } from 'buffer';
import process from 'process';
import { Stream } from 'stream';
export { Buffer, process, Stream };

41
package.json Normal file
View File

@ -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"
}
}

58
readme-zh.md Normal file
View File

@ -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)

16
rollup.config.js Normal file
View File

@ -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()],
};

118
src/helper.ts Normal file
View File

@ -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));
}
}

13
src/lang/README.md Normal file
View File

@ -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.

57
src/lang/helpers.ts Normal file
View File

@ -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<typeof en> } = {
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];
}

3
src/lang/locale/ar.ts Normal file
View File

@ -0,0 +1,3 @@
// العربية
export default {};

3
src/lang/locale/cz.ts Normal file
View File

@ -0,0 +1,3 @@
// čeština
export default {};

3
src/lang/locale/da.ts Normal file
View File

@ -0,0 +1,3 @@
// Dansk
export default {};

3
src/lang/locale/de.ts Normal file
View File

@ -0,0 +1,3 @@
// Deutsch
export default {};

3
src/lang/locale/en-gb.ts Normal file
View File

@ -0,0 +1,3 @@
// British English
export default {};

33
src/lang/locale/en.ts Normal file
View File

@ -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.",
};

3
src/lang/locale/es.ts Normal file
View File

@ -0,0 +1,3 @@
// Español
export default {};

3
src/lang/locale/fr.ts Normal file
View File

@ -0,0 +1,3 @@
// français
export default {};

3
src/lang/locale/hi.ts Normal file
View File

@ -0,0 +1,3 @@
// हिन्दी
export default {};

3
src/lang/locale/id.ts Normal file
View File

@ -0,0 +1,3 @@
// Bahasa Indonesia
export default {};

3
src/lang/locale/it.ts Normal file
View File

@ -0,0 +1,3 @@
// Italiano
export default {};

3
src/lang/locale/ja.ts Normal file
View File

@ -0,0 +1,3 @@
// 日本語
export default {};

3
src/lang/locale/ko.ts Normal file
View File

@ -0,0 +1,3 @@
// 한국어
export default {};

3
src/lang/locale/nl.ts Normal file
View File

@ -0,0 +1,3 @@
// Nederlands
export default {};

3
src/lang/locale/no.ts Normal file
View File

@ -0,0 +1,3 @@
// Norsk
export default {};

3
src/lang/locale/pl.ts Normal file
View File

@ -0,0 +1,3 @@
// język polski
export default {};

4
src/lang/locale/pt-br.ts Normal file
View File

@ -0,0 +1,4 @@
// Português do Brasil
// Brazilian Portuguese
export default {};

3
src/lang/locale/pt.ts Normal file
View File

@ -0,0 +1,3 @@
// Português
export default {};

3
src/lang/locale/ro.ts Normal file
View File

@ -0,0 +1,3 @@
// Română
export default {};

3
src/lang/locale/ru.ts Normal file
View File

@ -0,0 +1,3 @@
// русский
export default {};

3
src/lang/locale/tr.ts Normal file
View File

@ -0,0 +1,3 @@
// Türkçe
export default {};

31
src/lang/locale/zh-cn.ts Normal file
View File

@ -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附件文件夹中的文件",
};

3
src/lang/locale/zh-tw.ts Normal file
View File

@ -0,0 +1,3 @@
// 繁體中文
export default {};

648
src/main.ts Normal file
View File

@ -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",
`<svg t="1636630783429" class="icon" viewBox="0 0 100 100" version="1.1" p-id="4649" xmlns="http://www.w3.org/2000/svg">
<path d="M 71.638 35.336 L 79.408 35.336 C 83.7 35.336 87.178 38.662 87.178 42.765 L 87.178 84.864 C 87.178 88.969 83.7 92.295 79.408 92.295 L 17.249 92.295 C 12.957 92.295 9.479 88.969 9.479 84.864 L 9.479 42.765 C 9.479 38.662 12.957 35.336 17.249 35.336 L 25.019 35.336 L 25.019 42.765 L 17.249 42.765 L 17.249 84.864 L 79.408 84.864 L 79.408 42.765 L 71.638 42.765 L 71.638 35.336 Z M 49.014 10.179 L 67.326 27.688 L 61.835 32.942 L 52.849 24.352 L 52.849 59.731 L 45.078 59.731 L 45.078 24.455 L 36.194 32.947 L 30.702 27.692 L 49.012 10.181 Z" p-id="4650" fill="#8a8a8a"></path>
</svg>`
);
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;i<num;i++) {
absoPath = absoPath.substring(0,absoPath.lastIndexOf("/"))
}
}
file = this.app.vault.getAbstractFileByPath(absoPath);
}
// 尽可能短路径
if (!file) {
file = this.getFile(fileName, fileMap);
}
if (file) {
const abstractImageFile = join(basePath, file.path);
if (isAssetTypeAnImage(abstractImageFile)) {
let pushObj = {
path: abstractImageFile,
obspath: file.path,
name: imageName,
source: match.source,
};
//如果文件中有重复引用的图片,只上传一次
if (!imageList.find(item=>item.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;
}
}
}
}

194
src/setting.ts Normal file
View File

@ -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();
})
);
}
}

201
src/uploader.ts Normal file
View File

@ -0,0 +1,201 @@
import { PluginSettings } from "./setting";
import { App, TFile, Notice } from "obsidian";
// 添加返回值类型接口
interface UploadResponse {
code: number;
msg: string;
data: string;
fullResult?: Array<any>;
}
//兰空上传器
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<Response> {
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<UploadResponse> {
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<String>): Promise<any> {
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<File>): Promise<any> {
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<any> {
let files = evt.clipboardData.files;
let file = files[0];
return this.promiseRequest(file);
}
}

79
src/utils.ts Normal file
View File

@ -0,0 +1,79 @@
import { extname } from "path";
import { Readable } from "stream";
export interface IStringKeyMap<T> {
[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<T extends AnyObj>(
arr: T[],
key: string
): { [key: string]: T } {
const obj: { [key: string]: T } = {};
arr.forEach(element => {
obj[element[key]] = element;
});
return obj;
}

16
tsconfig.json Normal file
View File

@ -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"]
}

3
versions.json Normal file
View File

@ -0,0 +1,3 @@
{
"1.0.0": "0.0.1"
}