├── .github └── workflows │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── manifest.json ├── package.json ├── src ├── imageStore.ts ├── publish.ts ├── styles.css ├── ui │ ├── publishSettingTab.ts │ └── uploadProgressModal.ts └── uploader │ ├── apiError.ts │ ├── cos │ ├── common.ts │ └── cosUploader.ts │ ├── github │ └── gitHubUploader.ts │ ├── imageTagProcessor.ts │ ├── imageUploader.ts │ ├── imageUploaderBuilder.ts │ ├── imagekit │ └── imagekitUploader.ts │ ├── imgur │ ├── constants.ts │ ├── imgurAnonymousUploader.ts │ └── imgurResponseTypes.ts │ ├── oss │ ├── common.ts │ └── ossUploader.ts │ ├── qiniu │ └── kodoUploader.ts │ ├── r2 │ └── r2Uploader.ts │ ├── s3 │ └── awsS3Uploader.ts │ └── uploaderUtils.ts ├── tsconfig.json └── types.d.ts /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | env: 9 | PLUGIN_NAME: obsidian-publish-plugin # Change this to match the id of your plugin. 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: "14.x" 21 | 22 | - name: Build 23 | id: build 24 | run: | 25 | npm install 26 | npm run build 27 | mkdir ${{ env.PLUGIN_NAME }} 28 | cp dist/main.js dist/manifest.json ${{ env.PLUGIN_NAME }} 29 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 30 | ls 31 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 32 | 33 | - name: Create Release 34 | id: create_release 35 | uses: actions/create-release@v1 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | VERSION: ${{ github.ref }} 39 | with: 40 | tag_name: ${{ github.ref }} 41 | release_name: ${{ github.ref }} 42 | draft: false 43 | prerelease: false 44 | 45 | - name: Upload zip file 46 | id: upload-zip 47 | uses: actions/upload-release-asset@v1 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | with: 51 | upload_url: ${{ steps.create_release.outputs.upload_url }} 52 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 53 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 54 | asset_content_type: application/zip 55 | 56 | - name: Upload main.js 57 | id: upload-main 58 | uses: actions/upload-release-asset@v1 59 | env: 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | with: 62 | upload_url: ${{ steps.create_release.outputs.upload_url }} 63 | asset_path: ./dist/main.js 64 | asset_name: main.js 65 | asset_content_type: text/javascript 66 | 67 | - name: Upload manifest.json 68 | id: upload-manifest 69 | uses: actions/upload-release-asset@v1 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | with: 73 | upload_url: ${{ steps.create_release.outputs.upload_url }} 74 | asset_path: ./dist/manifest.json 75 | asset_name: manifest.json 76 | asset_content_type: application/json 77 | 78 | # - name: Upload styles.css 79 | # id: upload-css 80 | # uses: actions/upload-release-asset@v1 81 | # env: 82 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 83 | # with: 84 | # upload_url: ${{ steps.create_release.outputs.upload_url }} 85 | # asset_path: ./styles.css 86 | # asset_name: styles.css 87 | # asset_content_type: text/css -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | *-error.log 4 | package-lock.json 5 | yarn.lock 6 | .npmrc 7 | data.json 8 | dist 9 | .idea -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Addo Zhang 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📸 Obsidian Image Upload Toolkit 2 | 3 | > Seamlessly upload and manage images for your Obsidian notes across multiple cloud platforms 4 | 5 | ## 🚀 Overview 6 | 7 | This plugin automatically uploads local images embedded in your markdown files to your preferred remote image storage service, then exports the markdown with updated image URLs to your clipboard. The original markdown files in your vault remain unchanged, preserving your local image references. 8 | 9 | ### Supported Image Stores 10 | 11 | - [Imgur](https://imgur.com) - Simple, free image hosting 12 | - [AliYun OSS](https://www.alibabacloud.com/product/object-storage-service) - Alibaba Cloud Object Storage 13 | - [Imagekit](https://imagekit.io) - Image CDN and optimization 14 | - [Amazon S3](https://aws.amazon.com/s3/) - Scalable cloud storage 15 | - [TencentCloud COS](https://cloud.tencent.com/product/cos) - Cloud Object Storage 16 | - [Qiniu Kodo](https://www.qiniu.com/products/kodo) - Object storage service 17 | - [GitHub Repository](https://github.com) - Git-based storage 18 | - [Cloudflare R2](https://www.cloudflare.com/products/r2/) - S3-compatible storage 19 | 20 | Perfect for publishing to static sites like [GitHub Pages](https://pages.github.com) or any platform that requires externally hosted images. 21 | 22 | ## ✨ Features 23 | 24 | - **One-Click Upload** - Upload all local images with a single command 25 | - **Multiple Storage Options** - Choose from 8 different cloud storage providers 26 | - **Clipboard Ready** - Updated markdown copied directly to clipboard 27 | - **Preservation** - Keep your original notes unchanged in your vault 28 | - **Customizable Paths** - Configure target paths for your uploaded images 29 | - **Optional In-Place Replacement** - Option to update original files directly if preferred 30 | 31 | ## 🔍 Usage 32 | 33 | 1. Open command palette (Ctrl/Cmd + P) 34 | 2. Type "publish page" and select the command 35 | 3. All local images will be uploaded to your configured remote storage 36 | 4. The markdown with updated image URLs will be copied to your clipboard with a notification 37 | 38 | ![screenshot](https://github.com/user-attachments/assets/d20abcac-78a3-4275-b391-818ad781c219) 39 | 40 | ## 🛠️ Configuration 41 | 42 | Each image store requires specific configuration in the plugin settings. Detailed setup guides are available in the [Appendix](#appendix) section below. 43 | 44 | ## 📋 Roadmap 45 | 46 | - [ ] support uploading images to more storages 47 | - [x] Imgur 48 | - [x] Aliyun Oss 49 | - [x] ImageKit 50 | - [x] Amazon S3 51 | - [x] TencentCloud COS 52 | - [x] Qiniu Kodo 53 | - [x] GitHub Repository 54 | - [x] Cloudflare R2 55 | - [ ] more... 56 | - [x] setting for replacing images embedded in origin markdown directly 57 | 58 | ## 👥 Contributing 59 | 60 | Contributions to enhance this plugin are welcome! Here's how to get started: 61 | 62 | ### Prerequisites 63 | 64 | - [Node.js](https://nodejs.org/) (v14 or newer recommended) 65 | - [npm](https://www.npmjs.com/) or [yarn](https://yarnpkg.com/) 66 | 67 | ### Setup 68 | 69 | 1. Clone the repository 70 | 2. Install dependencies: 71 | 72 | ```shell 73 | npm install 74 | ``` 75 | 76 | ### Development Workflow 77 | 78 | Start a development build with hot-reload enabled: 79 | 80 | ```shell 81 | npm run dev 82 | ``` 83 | 84 | > **Note**: If you haven't already installed the hot-reload-plugin, you'll be prompted to. Enable that plugin in your Obsidian vault before hot-reloading will start. You might need to refresh your plugin list for it to show up. 85 | 86 | ### Building for Release 87 | 88 | Generate a production build: 89 | 90 | ```shell 91 | npm run build 92 | ``` 93 | 94 | ### Testing 95 | 96 | Before submitting a pull request, please test your changes thoroughly and ensure they work across different platforms if possible. 97 | 98 | --- 99 | 100 | ## 🙏 Acknowledgements 101 | 102 | This plugin was inspired by the powerful markdown editor [MWeb Pro](https://www.mweb.im) and builds upon the work of several exceptional projects: 103 | 104 | - [obsidian-imgur-plugin](https://github.com/gavvvr/obsidian-imgur-plugin) - Reference implementation for Imgur upload functionality 105 | - [obsidian-image-auto-upload-plugin](https://github.com/renmu123/obsidian-image-auto-upload-plugin) - Inspiration for additional features 106 | - [create-obsidian-plugin](https://www.npmjs.com/package/create-obsidian-plugin) - Tooling for plugin development 107 | 108 | --- 109 | 110 | ## Appendix 111 | 112 | ### Imgur Users: Obtaining your own Imgur Client ID 113 | 114 | Imgur service usually has a daily [upload limits](https://apidocs.imgur.com/#rate-limits). To overcome this, create and use your own Client ID. This is generally easy, by following the steps below : 115 | 116 | 1. If you do not have an imgur.com account, [create one](https://imgur.com/register) first. 117 | 118 | 2. Visit [https://api.imgur.com/oauth2/addclient](https://api.imgur.com/oauth2/addclient) and generate **Client ID** for Obsidian with following settings: 119 | 120 | - provide any application name, i.e. "Obsidian" 121 | - choose "OAuth 2 authorization without a callback URL" (**important**) 122 | - Add your E-Mail 123 | 124 | 3. Copy the Client ID. (Note: You only need the **Client ID**. The Client secret is confidential information not required by this plugin. Keep it secure.) 125 | 4. Paste this Client ID in the plugin settings. 126 | 127 | ### 🌩️ Cloudflare R2 Setup Guide 128 | 129 | To configure Cloudflare R2 as your image storage solution: 130 | 131 | 1. Sign up for a [Cloudflare account](https://dash.cloudflare.com/sign-up) if you don't already have one. 132 | 2. Enable R2 storage in your Cloudflare dashboard. 133 | 3. Create an R2 bucket to store your images. 134 | 4. Generate API credentials for your R2 bucket: 135 | - Navigate to `R2 > Overview > Manage R2 API Tokens` 136 | - Create a new API token with read and write permissions 137 | - Copy your Access Key ID and Secret Access Key 138 | 5. In the plugin settings, select "Cloudflare R2" as your image store and configure: 139 | - **Access Key ID and Secret Access Key** from step 4 140 | - **Endpoint**: Your R2 endpoint URL (typically `https://.r2.cloudflarestorage.com`) 141 | - **Bucket Name**: The name of your bucket created in step 3 142 | - **Target Path**: Optional path template for organizing your uploaded images 143 | - **Custom Domain Name**: Choose either: 144 | - Your own custom domain (if configured for your bucket) 145 | - The free R2.dev URL (format: `https://..r2.dev`) provided by Cloudflare for public assets 146 | 147 | ### 🐙 GitHub Repository Setup 148 | 149 | To use GitHub as your image hosting solution: 150 | 151 | 1. Create a personal access token: 152 | - Go to [GitHub Settings > Developer Settings > Personal Access Tokens](https://github.com/settings/tokens) 153 | - Create a new token with `repo` scope 154 | - Copy your token 155 | 2. In the plugin settings, select "GitHub Repository" as your image store and configure: 156 | - **GitHub Token**: The personal access token from step 1 157 | - **Repository Owner**: Your GitHub username or organization name 158 | - **Repository Name**: Name of the repository to store images 159 | - **Branch Name**: Branch to upload images to (default is `main`) 160 | - **Target Path**: Path within the repository for storing images 161 | - **Custom Domain**: Optional custom domain if you're using GitHub Pages with a custom domain 162 | 163 | ### ☁️ Amazon S3 Setup 164 | 165 | To configure Amazon S3 for image hosting: 166 | 167 | 1. Create an [AWS account](https://aws.amazon.com) if you don't have one 168 | 2. Create an S3 bucket with appropriate permissions: 169 | - Go to [S3 Console](https://console.aws.amazon.com/s3/) 170 | - Create a new bucket with public read access (if you want images to be publicly accessible) 171 | 3. Create IAM credentials: 172 | - Navigate to [IAM Console](https://console.aws.amazon.com/iam/) 173 | - Create a new user with programmatic access 174 | - Attach S3 permissions policies 175 | - Copy the Access Key ID and Secret Access Key 176 | 4. In the plugin settings, select "Amazon S3" as your image store and configure: 177 | - **Access Key ID and Secret Access Key**: Credentials from step 3 178 | - **Region**: AWS region where your bucket is located 179 | - **Bucket Name**: Name of your S3 bucket 180 | - **Target Path**: Optional folder path for your images 181 | - **Custom Domain**: Optional CDN domain if you've set one up for your bucket 182 | 183 | ### 🌐 AliYun OSS Setup 184 | 185 | To use Alibaba Cloud Object Storage Service: 186 | 187 | 1. Create an [Alibaba Cloud account](https://www.alibabacloud.com) if needed 188 | 2. Create an OSS bucket: 189 | - Go to the [OSS console](https://oss.console.aliyun.com) 190 | - Create a new bucket with appropriate access settings 191 | 3. Generate access credentials: 192 | - Go to [AccessKey Management](https://ram.console.aliyun.com/manage/ak) 193 | - Create a new access key pair 194 | - Copy your AccessKey ID and AccessKey Secret 195 | 4. In the plugin settings, select "Aliyun OSS" as your image store and configure: 196 | - **Access Key ID and Secret**: From step 3 197 | - **Region**: OSS region (e.g., `oss-cn-hangzhou`) 198 | - **Bucket Name**: Your OSS bucket name 199 | - **Target Path**: Optional directory for images 200 | - **Custom Domain**: Optional CDN domain if configured 201 | 202 | ### 🖼️ ImageKit Setup 203 | 204 | To use ImageKit for optimization and delivery: 205 | 206 | 1. Create an [ImageKit account](https://imagekit.io/registration/) if needed 207 | 2. From your ImageKit dashboard: 208 | - Get your private API key from Account > Developer Options 209 | - Note your ImageKit URL endpoint 210 | 3. In the plugin settings, select "ImageKit" as your image store and configure: 211 | - **Public Key**: Your ImageKit public key 212 | - **Private Key**: Your ImageKit private API key 213 | - **URL Endpoint**: Your ImageKit URL endpoint (e.g., `https://ik.imagekit.io/yourusername`) 214 | - **Target Path**: Optional folder structure for organizing uploads 215 | 216 | ### ☁️ TencentCloud COS Setup 217 | 218 | To use Tencent Cloud Object Storage: 219 | 220 | 1. Create a [Tencent Cloud account](https://cloud.tencent.com) if needed 221 | 2. Create a COS bucket: 222 | - Go to the [COS console](https://console.cloud.tencent.com/cos) 223 | - Create a new bucket with appropriate permissions 224 | 3. Generate API credentials: 225 | - Go to [CAM console](https://console.cloud.tencent.com/cam/capi) 226 | - Create a new SecretId and SecretKey 227 | 4. In the plugin settings, select "TencentCloud COS" as your image store and configure: 228 | - **Secret ID and Secret Key**: From step 3 229 | - **Region**: COS region (e.g., `ap-guangzhou`) 230 | - **Bucket Name**: Your COS bucket name 231 | - **Target Path**: Optional folder for organizing images 232 | - **Custom Domain**: Optional CDN domain if configured 233 | 234 | ### 🗄️ Qiniu Kodo Setup 235 | 236 | To use Qiniu Kodo object storage: 237 | 238 | 1. Create a [Qiniu Cloud account](https://www.qiniu.com) if needed 239 | 2. Create a Kodo bucket: 240 | - Go to the Kodo console 241 | - Create a new bucket 242 | 3. Generate access credentials: 243 | - Go to Key Management and create a new key pair 244 | - Copy your Access Key and Secret Key 245 | 4. In the plugin settings, select "Qiniu Kodo" as your image store and configure: 246 | - **Access Key and Secret Key**: From step 3 247 | - **Bucket Name**: Your Kodo bucket name 248 | - **Domain Name**: The domain bound to your bucket for access 249 | - **Target Path**: Optional folder for organizing images 250 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "image-upload-toolkit", 3 | "name": "Image Upload Toolkit", 4 | "description": "Upload local images to remote store (Imgur, AliYun OSS, Imagekit, Amazon S3, TencentCloud COS and Qiniu Kodo).", 5 | "author": "Addo Zhang", 6 | "authorUrl": "https://atbug.com", 7 | "isDesktopOnly": true, 8 | "minAppVersion": "0.11.0", 9 | "version": "1.1.0" 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-image-upload-toolkit", 3 | "version": "1.1.0", 4 | "description": "", 5 | "author": "addozhang", 6 | "main": "main.js", 7 | "license": "MIT", 8 | "scripts": { 9 | "build": "obsidian-plugin build src/publish.ts", 10 | "dev": "obsidian-plugin dev src/publish.ts" 11 | }, 12 | "devDependencies": { 13 | "@types/node": "^14.14.2", 14 | "obsidian": "obsidianmd/obsidian-api", 15 | "obsidian-plugin-cli": "^0.4.3", 16 | "typescript": "^4.0.3" 17 | }, 18 | "dependencies": { 19 | "@octokit/rest": "^19.0.4", 20 | "ali-oss": "^6.17.1", 21 | "aws-sdk": "^2.1664.0", 22 | "cos-nodejs-sdk-v5": "^2.14.6", 23 | "imagekit": "^5.0.0", 24 | "proxy-agent": "^5.0.0", 25 | "qiniu": "^7.14.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/imageStore.ts: -------------------------------------------------------------------------------- 1 | export default class ImageStore { 2 | private static readonly values: ImageStore[] = []; 3 | 4 | static get lists(): ReadonlyArray { 5 | return this.values; 6 | } 7 | 8 | static readonly IMGUR = new ImageStore( 9 | "IMGUR", 10 | "Imgur upload" 11 | ) 12 | 13 | static readonly ALIYUN_OSS = new ImageStore( 14 | "ALIYUN_OSS", 15 | "AliYun OSS" 16 | ) 17 | 18 | static readonly ImageKit = new ImageStore( 19 | "Imagekit", 20 | "Imagekit" 21 | ); 22 | 23 | static readonly AWS_S3 = new ImageStore( 24 | "AWS_S3", 25 | "AWS S3" 26 | ) 27 | 28 | static readonly TENCENTCLOUD_COS = new ImageStore( 29 | "TENCENTCLOUD_COS", 30 | "TencentCloud COS" 31 | ) 32 | 33 | static readonly QINIU_KUDO = new ImageStore( 34 | "QINIU_KUDO", 35 | "Qiniu KuDo" 36 | ) 37 | 38 | static readonly GITHUB = new ImageStore( 39 | "GITHUB", 40 | "GitHub Repository" 41 | ) 42 | 43 | static readonly CLOUDFLARE_R2 = new ImageStore( 44 | "CLOUDFLARE_R2", 45 | "Cloudflare R2" 46 | ) 47 | 48 | private constructor(readonly id: string, readonly description: string) { 49 | ImageStore.values.push(this) 50 | } 51 | } -------------------------------------------------------------------------------- /src/publish.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Notice, 3 | Plugin, 4 | } from "obsidian"; 5 | 6 | import ImageTagProcessor, {ACTION_PUBLISH} from "./uploader/imageTagProcessor"; 7 | import ImageUploader from "./uploader/imageUploader"; 8 | import {ImgurAnonymousSetting} from "./uploader/imgur/imgurAnonymousUploader"; 9 | import {IMGUR_PLUGIN_CLIENT_ID} from "./uploader/imgur/constants"; 10 | import ImageStore from "./imageStore"; 11 | import buildUploader from "./uploader/imageUploaderBuilder"; 12 | import PublishSettingTab from "./ui/publishSettingTab"; 13 | import {OssSetting} from "./uploader/oss/ossUploader"; 14 | import {ImagekitSetting} from "./uploader/imagekit/imagekitUploader"; 15 | import {AwsS3Setting} from "./uploader/s3/awsS3Uploader"; 16 | import {CosSetting} from "./uploader/cos/cosUploader"; 17 | import {KodoSetting} from "./uploader/qiniu/kodoUploader"; 18 | import {GitHubSetting} from "./uploader/github/gitHubUploader"; 19 | import {R2Setting} from "./uploader/r2/r2Uploader"; 20 | 21 | export interface PublishSettings { 22 | imageAltText: boolean; 23 | replaceOriginalDoc: boolean; 24 | ignoreProperties: boolean; 25 | attachmentLocation: string; 26 | imageStore: string; 27 | showProgressModal: boolean; // New setting to control progress modal display 28 | //Imgur Anonymous setting 29 | imgurAnonymousSetting: ImgurAnonymousSetting; 30 | ossSetting: OssSetting; 31 | imagekitSetting: ImagekitSetting; 32 | awsS3Setting: AwsS3Setting; 33 | cosSetting: CosSetting; 34 | kodoSetting: KodoSetting; 35 | githubSetting: GitHubSetting; 36 | r2Setting: R2Setting; 37 | } 38 | 39 | const DEFAULT_SETTINGS: PublishSettings = { 40 | imageAltText: true, 41 | replaceOriginalDoc: false, 42 | ignoreProperties: true, 43 | attachmentLocation: ".", 44 | imageStore: ImageStore.IMGUR.id, 45 | showProgressModal: true, // Default to showing the modal 46 | imgurAnonymousSetting: {clientId: IMGUR_PLUGIN_CLIENT_ID}, 47 | ossSetting: { 48 | region: "oss-cn-hangzhou", 49 | accessKeyId: "", 50 | accessKeySecret: "", 51 | bucket: "", 52 | endpoint: "https://oss-cn-hangzhou.aliyuncs.com/", 53 | path: "", 54 | customDomainName: "", 55 | }, 56 | imagekitSetting: { 57 | endpoint: "", 58 | imagekitID: "", 59 | privateKey: "", 60 | publicKey: "", 61 | folder: "", 62 | }, 63 | awsS3Setting: { 64 | accessKeyId: "", 65 | secretAccessKey: "", 66 | region: "", 67 | bucketName: "", 68 | path: "", 69 | customDomainName: "", 70 | }, 71 | cosSetting: { 72 | region: "", 73 | bucket: "", 74 | secretId: "", 75 | secretKey: "", 76 | path: "", 77 | customDomainName: "", 78 | }, 79 | kodoSetting: { 80 | accessKey: "", 81 | secretKey: "", 82 | bucket: "", 83 | customDomainName: "", 84 | path: "" 85 | }, 86 | githubSetting: { 87 | repositoryName: "", 88 | branchName: "main", 89 | token: "", 90 | path: "images" 91 | }, 92 | r2Setting: { 93 | accessKeyId: "", 94 | secretAccessKey: "", 95 | endpoint: "", 96 | bucketName: "", 97 | path: "", 98 | customDomainName: "", 99 | }, 100 | }; 101 | export default class ObsidianPublish extends Plugin { 102 | settings: PublishSettings; 103 | imageTagProcessor: ImageTagProcessor; 104 | imageUploader: ImageUploader; 105 | statusBarItem: HTMLElement; 106 | 107 | async onload() { 108 | await this.loadSettings(); 109 | // Create status bar item that will be used if modal is disabled 110 | this.statusBarItem = this.addStatusBarItem(); 111 | this.setupImageUploader(); 112 | 113 | this.addCommand({ 114 | id: "publish-page", 115 | name: "Publish Page", 116 | checkCallback: (checking: boolean) => { 117 | if (!checking) { 118 | this.publish() 119 | } 120 | return true; 121 | } 122 | }); 123 | this.addSettingTab(new PublishSettingTab(this.app, this)); 124 | } 125 | 126 | onunload() { 127 | // console.log("unloading plugin"); 128 | } 129 | 130 | async loadSettings() { 131 | this.settings = Object.assign({}, DEFAULT_SETTINGS, (await this.loadData()) as PublishSettings); 132 | } 133 | 134 | async saveSettings() { 135 | await this.saveData(this.settings); 136 | } 137 | 138 | private publish(): void { 139 | if (!this.imageUploader) { 140 | new Notice("Image uploader setup failed, please check setting.") 141 | } else { 142 | this.imageTagProcessor.process(ACTION_PUBLISH).then(() => { 143 | }); 144 | } 145 | } 146 | 147 | setupImageUploader(): void { 148 | try { 149 | this.imageUploader = buildUploader(this.settings); 150 | // Create ImageTagProcessor with the user's preference for modal vs status bar 151 | this.imageTagProcessor = new ImageTagProcessor( 152 | this.app, 153 | this.settings, 154 | this.imageUploader, 155 | this.settings.showProgressModal, // Use modal based on setting 156 | ); 157 | } catch (e) { 158 | console.log(`Failed to setup image uploader: ${e}`) 159 | } 160 | } 161 | } -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Add any styles you want to be included w/ your plugin here 3 | */ 4 | -------------------------------------------------------------------------------- /src/ui/publishSettingTab.ts: -------------------------------------------------------------------------------- 1 | import {App, Notice, PluginSettingTab, Setting} from "obsidian"; 2 | import ObsidianPublish from "../publish"; 3 | import ImageStore from "../imageStore"; 4 | import {AliYunRegionList} from "../uploader/oss/common"; 5 | import {TencentCloudRegionList} from "../uploader/cos/common"; 6 | 7 | export default class PublishSettingTab extends PluginSettingTab { 8 | private plugin: ObsidianPublish; 9 | private imageStoreDiv: HTMLDivElement; 10 | 11 | constructor(app: App, plugin: ObsidianPublish) { 12 | super(app, plugin); 13 | this.plugin = plugin; 14 | } 15 | 16 | display(): any { 17 | const {containerEl} = this; 18 | containerEl.empty() 19 | containerEl.createEl("h1", {text: "Upload Settings"}); 20 | 21 | const imageStoreTypeDiv = containerEl.createDiv(); 22 | this.imageStoreDiv = containerEl.createDiv(); 23 | 24 | // Attachment location 25 | new Setting(imageStoreTypeDiv) 26 | .setName("Attachment location") 27 | .setDesc("The location storing images which will upload images from.") 28 | .addText(text => 29 | text 30 | .setPlaceholder("Enter folder name") 31 | .setValue(this.plugin.settings.attachmentLocation) 32 | .onChange(async (value) => { 33 | if ((await this.app.vault.getAbstractFileByPath(value)) == null) { 34 | new Notice(`Attachment location "${value}" not exist!`) 35 | return 36 | } 37 | this.plugin.settings.attachmentLocation = value; 38 | 39 | }) 40 | ); 41 | 42 | new Setting(imageStoreTypeDiv) 43 | .setName("Use image name as Alt Text") 44 | .setDesc("Whether to use image name as Alt Text with '-' and '_' replaced with space.") 45 | .addToggle(toggle => 46 | toggle 47 | .setValue(this.plugin.settings.imageAltText) 48 | .onChange(value => this.plugin.settings.imageAltText = value) 49 | ); 50 | 51 | new Setting(imageStoreTypeDiv) 52 | .setName("Update original document") 53 | .setDesc("Whether to replace internal link with store link.") 54 | .addToggle(toggle => 55 | toggle 56 | .setValue(this.plugin.settings.replaceOriginalDoc) 57 | .onChange(value => this.plugin.settings.replaceOriginalDoc = value) 58 | ); 59 | 60 | new Setting(imageStoreTypeDiv) 61 | .setName("Ignore note properties") 62 | .setDesc("Where to ignore note properties when copying to clipboard. This won't affect original note.") 63 | .addToggle(toggle => 64 | toggle 65 | .setValue(this.plugin.settings.ignoreProperties) 66 | .onChange(value => this.plugin.settings.ignoreProperties = value) 67 | ); 68 | 69 | // Add new setting for controlling progress modal display 70 | new Setting(imageStoreTypeDiv) 71 | .setName("Show progress modal") 72 | .setDesc("Show a modal dialog with detailed progress when uploading images (auto close in 3s). If disabled, a simpler status indicator will be used.") 73 | .addToggle(toggle => 74 | toggle 75 | .setValue(this.plugin.settings.showProgressModal) 76 | .onChange(value => this.plugin.settings.showProgressModal = value) 77 | ); 78 | 79 | // Image Store 80 | new Setting(imageStoreTypeDiv) 81 | .setName("Image store") 82 | .setDesc("Remote image store for upload images to.") 83 | .addDropdown(dd => { 84 | ImageStore.lists.forEach(s => { 85 | dd.addOption(s.id, s.description); 86 | }); 87 | dd.setValue(this.plugin.settings.imageStore); 88 | dd.onChange(async (v) => { 89 | this.plugin.settings.imageStore = v; 90 | this.plugin.setupImageUploader(); 91 | await this.drawImageStoreSettings(this.imageStoreDiv); 92 | }); 93 | }); 94 | this.drawImageStoreSettings(this.imageStoreDiv); 95 | } 96 | 97 | async hide(): Promise { 98 | await this.plugin.saveSettings(); 99 | this.plugin.setupImageUploader(); 100 | } 101 | 102 | private async drawImageStoreSettings(parentEL: HTMLDivElement) { 103 | parentEL.empty(); 104 | switch (this.plugin.settings.imageStore) { 105 | case ImageStore.IMGUR.id: 106 | this.drawImgurSetting(parentEL); 107 | break; 108 | case ImageStore.ALIYUN_OSS.id: 109 | this.drawOSSSetting(parentEL); 110 | break; 111 | case ImageStore.ImageKit.id: 112 | this.drawImageKitSetting(parentEL); 113 | break; 114 | case ImageStore.AWS_S3.id: 115 | this.drawAwsS3Setting(parentEL); 116 | break; 117 | case ImageStore.TENCENTCLOUD_COS.id: 118 | this.drawTencentCloudCosSetting(parentEL); 119 | break; 120 | case ImageStore.QINIU_KUDO.id: 121 | this.drawQiniuSetting(parentEL); 122 | break 123 | case ImageStore.GITHUB.id: 124 | this.drawGitHubSetting(parentEL); 125 | break; 126 | case ImageStore.CLOUDFLARE_R2.id: 127 | this.drawR2Setting(parentEL); 128 | break; 129 | default: 130 | throw new Error( 131 | "Should not reach here!" 132 | ) 133 | } 134 | } 135 | 136 | // Imgur Setting 137 | private drawImgurSetting(parentEL: HTMLDivElement) { 138 | new Setting(parentEL) 139 | .setName("Client ID") 140 | .setDesc(PublishSettingTab.clientIdSettingDescription()) 141 | .addText(text => 142 | text 143 | .setPlaceholder("Enter client_id") 144 | .setValue(this.plugin.settings.imgurAnonymousSetting.clientId) 145 | .onChange(value => this.plugin.settings.imgurAnonymousSetting.clientId = value) 146 | ) 147 | } 148 | 149 | private static clientIdSettingDescription() { 150 | const fragment = document.createDocumentFragment(); 151 | const a = document.createElement("a"); 152 | const url = "https://api.imgur.com/oauth2/addclient"; 153 | a.textContent = url; 154 | a.setAttribute("href", url); 155 | fragment.append("Generate your own Client ID at "); 156 | fragment.append(a); 157 | return fragment; 158 | } 159 | 160 | // Aliyun OSS Setting 161 | private drawOSSSetting(parentEL: HTMLDivElement) { 162 | new Setting(parentEL) 163 | .setName("Region") 164 | .setDesc("OSS data center region.") 165 | .addDropdown(dropdown => 166 | dropdown 167 | .addOptions(AliYunRegionList) 168 | .setValue(this.plugin.settings.ossSetting.region) 169 | .onChange(value => { 170 | this.plugin.settings.ossSetting.region = value; 171 | this.plugin.settings.ossSetting.endpoint = `https://${value}.aliyuncs.com/`; 172 | }) 173 | ) 174 | new Setting(parentEL) 175 | .setName("Access Key Id") 176 | .setDesc("The access key id of AliYun RAM.") 177 | .addText(text => 178 | text 179 | .setPlaceholder("Enter access key id") 180 | .setValue(this.plugin.settings.ossSetting.accessKeyId) 181 | .onChange(value => this.plugin.settings.ossSetting.accessKeyId = value)) 182 | new Setting(parentEL) 183 | .setName("Access Key Secret") 184 | .setDesc("The access key secret of AliYun RAM.") 185 | .addText(text => 186 | text 187 | .setPlaceholder("Enter access key secret") 188 | .setValue(this.plugin.settings.ossSetting.accessKeySecret) 189 | .onChange(value => this.plugin.settings.ossSetting.accessKeySecret = value)) 190 | new Setting(parentEL) 191 | .setName("Access Bucket Name") 192 | .setDesc("The name of bucket to store images.") 193 | .addText(text => 194 | text 195 | .setPlaceholder("Enter bucket name") 196 | .setValue(this.plugin.settings.ossSetting.bucket) 197 | .onChange(value => this.plugin.settings.ossSetting.bucket = value)) 198 | 199 | new Setting(parentEL) 200 | .setName("Target Path") 201 | .setDesc("The path to store image.\nSupport {year} {mon} {day} {random} {filename} vars. For example, /{year}/{mon}/{day}/{filename} with uploading pic.jpg, it will store as /2023/06/08/pic.jpg.") 202 | .addText(text => 203 | text 204 | .setPlaceholder("Enter path") 205 | .setValue(this.plugin.settings.ossSetting.path) 206 | .onChange(value => this.plugin.settings.ossSetting.path = value)) 207 | 208 | //custom domain 209 | new Setting(parentEL) 210 | .setName("Custom Domain Name") 211 | .setDesc("If the custom domain name is example.com, you can use https://example.com/pic.jpg to access pic.img.") 212 | .addText(text => 213 | text 214 | .setPlaceholder("Enter path") 215 | .setValue(this.plugin.settings.ossSetting.customDomainName) 216 | .onChange(value => this.plugin.settings.ossSetting.customDomainName = value)) 217 | } 218 | 219 | private drawImageKitSetting(parentEL: HTMLDivElement) { 220 | new Setting(parentEL) 221 | .setName("Imagekit ID") 222 | .setDesc(PublishSettingTab.imagekitSettingDescription()) 223 | .addText(text => 224 | text 225 | .setPlaceholder("Enter your ImagekitID") 226 | .setValue(this.plugin.settings.imagekitSetting.imagekitID) 227 | .onChange(value => { 228 | this.plugin.settings.imagekitSetting.imagekitID = value 229 | this.plugin.settings.imagekitSetting.endpoint = `https://ik.imagekit.io/${value}/` 230 | })) 231 | 232 | new Setting(parentEL) 233 | .setName("Folder name") 234 | .setDesc("Please enter the directory name, otherwise leave it blank") 235 | .addText(text => 236 | text 237 | .setPlaceholder("Enter the folder name") 238 | .setValue(this.plugin.settings.imagekitSetting.folder) 239 | .onChange(value => this.plugin.settings.imagekitSetting.folder = value)) 240 | 241 | new Setting(parentEL) 242 | .setName("Public Key") 243 | .addText(text => 244 | text 245 | .setPlaceholder("Enter your Public Key") 246 | .setValue(this.plugin.settings.imagekitSetting.publicKey) 247 | .onChange(value => this.plugin.settings.imagekitSetting.publicKey = value)) 248 | 249 | new Setting(parentEL) 250 | .setName("Private Key") 251 | .addText(text => 252 | text 253 | .setPlaceholder("Enter your Private Key") 254 | .setValue(this.plugin.settings.imagekitSetting.privateKey) 255 | .onChange(value => this.plugin.settings.imagekitSetting.privateKey = value)) 256 | } 257 | 258 | private static imagekitSettingDescription() { 259 | const fragment = document.createDocumentFragment(); 260 | const a = document.createElement("a"); 261 | const url = "https://imagekit.io/dashboard/developer/api-keys"; 262 | a.textContent = url; 263 | a.setAttribute("href", url); 264 | fragment.append("Obtain id and keys from "); 265 | fragment.append(a); 266 | return fragment; 267 | } 268 | 269 | private drawAwsS3Setting(parentEL: HTMLDivElement) { 270 | // Add AWS S3 configuration section 271 | new Setting(parentEL) 272 | .setName('AWS S3 Access Key ID') 273 | .setDesc('Your AWS S3 access key ID') 274 | .addText(text => text 275 | .setPlaceholder('Enter your access key ID') 276 | .setValue(this.plugin.settings.awsS3Setting?.accessKeyId || '') 277 | .onChange(value => this.plugin.settings.awsS3Setting.accessKeyId = value 278 | )); 279 | 280 | new Setting(parentEL) 281 | .setName('AWS S3 Secret Access Key') 282 | .setDesc('Your AWS S3 secret access key') 283 | .addText(text => text 284 | .setPlaceholder('Enter your secret access key') 285 | .setValue(this.plugin.settings.awsS3Setting?.secretAccessKey || '') 286 | .onChange(value => this.plugin.settings.awsS3Setting.secretAccessKey = value)); 287 | 288 | new Setting(parentEL) 289 | .setName('AWS S3 Region') 290 | .setDesc('Your AWS S3 region') 291 | .addText(text => text 292 | .setPlaceholder('Enter your region') 293 | .setValue(this.plugin.settings.awsS3Setting?.region || '') 294 | .onChange(value => this.plugin.settings.awsS3Setting.region = value)); 295 | 296 | new Setting(parentEL) 297 | .setName('AWS S3 Bucket Name') 298 | .setDesc('Your AWS S3 bucket name') 299 | .addText(text => text 300 | .setPlaceholder('Enter your bucket name') 301 | .setValue(this.plugin.settings.awsS3Setting?.bucketName || '') 302 | .onChange(value => this.plugin.settings.awsS3Setting.bucketName = value)); 303 | new Setting(parentEL) 304 | .setName("Target Path") 305 | .setDesc("The path to store image.\nSupport {year} {mon} {day} {random} {filename} vars. For example, /{year}/{mon}/{day}/{filename} with uploading pic.jpg, it will store as /2023/06/08/pic.jpg.") 306 | .addText(text => 307 | text 308 | .setPlaceholder("Enter path") 309 | .setValue(this.plugin.settings.awsS3Setting.path) 310 | .onChange(value => this.plugin.settings.awsS3Setting.path = value)) 311 | 312 | //custom domain 313 | new Setting(parentEL) 314 | .setName("Custom Domain Name") 315 | .setDesc("If the custom domain name is example.com, you can use https://example.com/pic.jpg to access pic.img.") 316 | .addText(text => 317 | text 318 | .setPlaceholder("Enter path") 319 | .setValue(this.plugin.settings.awsS3Setting.customDomainName) 320 | .onChange(value => this.plugin.settings.awsS3Setting.customDomainName = value)) 321 | } 322 | 323 | private drawTencentCloudCosSetting(parentEL: HTMLDivElement) { 324 | new Setting(parentEL) 325 | .setName("Region") 326 | .setDesc("COS data center region.") 327 | .addDropdown(dropdown => 328 | dropdown 329 | .addOptions(TencentCloudRegionList) 330 | .setValue(this.plugin.settings.cosSetting.region) 331 | .onChange(value => { 332 | this.plugin.settings.cosSetting.region = value; 333 | }) 334 | ) 335 | new Setting(parentEL) 336 | .setName("Secret Id") 337 | .setDesc("The secret id of TencentCloud.") 338 | .addText(text => 339 | text 340 | .setPlaceholder("Enter access key id") 341 | .setValue(this.plugin.settings.cosSetting.secretId) 342 | .onChange(value => this.plugin.settings.cosSetting.secretId = value)) 343 | new Setting(parentEL) 344 | .setName("Secret Key") 345 | .setDesc("The secret key of TencentCloud.") 346 | .addText(text => 347 | text 348 | .setPlaceholder("Enter access key secret") 349 | .setValue(this.plugin.settings.cosSetting.secretKey) 350 | .onChange(value => this.plugin.settings.cosSetting.secretKey = value)) 351 | new Setting(parentEL) 352 | .setName("Access Bucket Name") 353 | .setDesc("The name of bucket to store images.") 354 | .addText(text => 355 | text 356 | .setPlaceholder("Enter bucket name") 357 | .setValue(this.plugin.settings.cosSetting.bucket) 358 | .onChange(value => this.plugin.settings.cosSetting.bucket = value)) 359 | 360 | new Setting(parentEL) 361 | .setName("Target Path") 362 | .setDesc("The path to store image.\nSupport {year} {mon} {day} {random} {filename} vars. For example, /{year}/{mon}/{day}/{filename} with uploading pic.jpg, it will store as /2023/06/08/pic.jpg.") 363 | .addText(text => 364 | text 365 | .setPlaceholder("Enter path") 366 | .setValue(this.plugin.settings.cosSetting.path) 367 | .onChange(value => this.plugin.settings.cosSetting.path = value)) 368 | 369 | //custom domain 370 | new Setting(parentEL) 371 | .setName("Custom Domain Name") 372 | .setDesc("If the custom domain name is example.com, you can use https://example.com/pic.jpg to access pic.img.") 373 | .addText(text => 374 | text 375 | .setPlaceholder("Enter path") 376 | .setValue(this.plugin.settings.cosSetting.customDomainName) 377 | .onChange(value => this.plugin.settings.cosSetting.customDomainName = value)) 378 | } 379 | 380 | private drawQiniuSetting(parentEL: HTMLDivElement) { 381 | new Setting(parentEL) 382 | .setName("Access Key") 383 | .setDesc("The access key of Qiniu.") 384 | .addText(text => 385 | text 386 | .setPlaceholder("Enter access key") 387 | .setValue(this.plugin.settings.kodoSetting.accessKey) 388 | .onChange(value => this.plugin.settings.kodoSetting.accessKey = value)) 389 | new Setting(parentEL) 390 | .setName("Secret Key") 391 | .setDesc("The secret key of Qiniu.") 392 | .addText(text => 393 | text 394 | .setPlaceholder("Enter secret key") 395 | .setValue(this.plugin.settings.kodoSetting.secretKey) 396 | .onChange(value => this.plugin.settings.kodoSetting.secretKey = value)) 397 | new Setting(parentEL) 398 | .setName("Bucket Name") 399 | .setDesc("The name of bucket to store images.") 400 | .addText(text => 401 | text 402 | .setPlaceholder("Enter bucket name") 403 | .setValue(this.plugin.settings.kodoSetting.bucket) 404 | .onChange(value => this.plugin.settings.kodoSetting.bucket = value)) 405 | 406 | // new Setting(parentEL) 407 | // .setName("Target Path") 408 | // .setDesc("The path to store image.\nSupport {year} {mon} {day} {random} {filename} vars. For example, /{year}/{mon}/{day}/{filename} with uploading pic.jpg, it will store as /2023/06/08/pic.jpg.") 409 | // .addText(text => 410 | // text 411 | // .setPlaceholder("Enter path") 412 | // .setValue(this.plugin.settings.kodoSetting.path) 413 | // .onChange(value => this.plugin.settings.kodoSetting.path = value)) 414 | 415 | //custom domain 416 | new Setting(parentEL) 417 | .setName("Custom Domain Name") 418 | .setDesc("If the custom domain name is example.com, you can use https://example.com/pic.jpg to access pic.img.") 419 | .addText(text => 420 | text 421 | .setPlaceholder("Enter path") 422 | .setValue(this.plugin.settings.kodoSetting.customDomainName) 423 | .onChange(value => this.plugin.settings.kodoSetting.customDomainName = value)) 424 | } 425 | 426 | private drawGitHubSetting(parentEL: HTMLDivElement) { 427 | new Setting(parentEL) 428 | .setName("Repository Name") 429 | .setDesc("The name of the GitHub repository to store images (format: owner/repo).") 430 | .addText(text => 431 | text 432 | .setPlaceholder("Enter repository name (e.g., username/repo)") 433 | .setValue(this.plugin.settings.githubSetting.repositoryName) 434 | .onChange(value => this.plugin.settings.githubSetting.repositoryName = value) 435 | ); 436 | 437 | new Setting(parentEL) 438 | .setName("Branch Name") 439 | .setDesc("The branch to store images in (defaults to 'main').") 440 | .addText(text => 441 | text 442 | .setPlaceholder("Enter branch name") 443 | .setValue(this.plugin.settings.githubSetting.branchName) 444 | .onChange(value => this.plugin.settings.githubSetting.branchName = value) 445 | ); 446 | 447 | new Setting(parentEL) 448 | .setName("Personal Access Token") 449 | .setDesc(PublishSettingTab.githubTokenDescription()) 450 | .addText(text => 451 | text 452 | .setPlaceholder("Enter your GitHub personal access token") 453 | .setValue(this.plugin.settings.githubSetting.token) 454 | .onChange(value => this.plugin.settings.githubSetting.token = value) 455 | ); 456 | 457 | /*new Setting(parentEL) 458 | .setName("Target Path") 459 | .setDesc("The path to store images within the repository.\nSupport {year} {mon} {day} {random} {filename} vars. For example, images/{year}/{mon}/{day}/{filename} with uploading pic.jpg, it will store as images/2023/06/08/pic.jpg.") 460 | .addText(text => 461 | text 462 | .setPlaceholder("Enter path") 463 | .setValue(this.plugin.settings.githubSetting.path) 464 | .onChange(value => this.plugin.settings.githubSetting.path = value) 465 | );*/ 466 | } 467 | 468 | private static githubTokenDescription() { 469 | const fragment = document.createDocumentFragment(); 470 | const a = document.createElement("a"); 471 | const url = "https://github.com/settings/tokens"; 472 | a.textContent = url; 473 | a.setAttribute("href", url); 474 | fragment.append("Generate a personal access token with 'repo' scope at "); 475 | fragment.append(a); 476 | return fragment; 477 | } 478 | 479 | private drawR2Setting(parentEL: HTMLDivElement) { 480 | new Setting(parentEL) 481 | .setName('Cloudflare R2 Access Key ID') 482 | .setDesc('Your Cloudflare R2 access key ID') 483 | .addText(text => text 484 | .setPlaceholder('Enter your access key ID') 485 | .setValue(this.plugin.settings.r2Setting?.accessKeyId || '') 486 | .onChange(value => this.plugin.settings.r2Setting.accessKeyId = value 487 | )); 488 | 489 | new Setting(parentEL) 490 | .setName('Cloudflare R2 Secret Access Key') 491 | .setDesc('Your Cloudflare R2 secret access key') 492 | .addText(text => text 493 | .setPlaceholder('Enter your secret access key') 494 | .setValue(this.plugin.settings.r2Setting?.secretAccessKey || '') 495 | .onChange(value => this.plugin.settings.r2Setting.secretAccessKey = value)); 496 | 497 | new Setting(parentEL) 498 | .setName('Cloudflare R2 Endpoint') 499 | .setDesc('Your Cloudflare R2 endpoint URL (e.g., https://account-id.r2.cloudflarestorage.com)') 500 | .addText(text => text 501 | .setPlaceholder('Enter your R2 endpoint') 502 | .setValue(this.plugin.settings.r2Setting?.endpoint || '') 503 | .onChange(value => this.plugin.settings.r2Setting.endpoint = value)); 504 | 505 | new Setting(parentEL) 506 | .setName('Cloudflare R2 Bucket Name') 507 | .setDesc('Your Cloudflare R2 bucket name') 508 | .addText(text => text 509 | .setPlaceholder('Enter your bucket name') 510 | .setValue(this.plugin.settings.r2Setting?.bucketName || '') 511 | .onChange(value => this.plugin.settings.r2Setting.bucketName = value)); 512 | 513 | new Setting(parentEL) 514 | .setName("Target Path") 515 | .setDesc("The path to store image.\nSupport {year} {mon} {day} {random} {filename} vars. For example, /{year}/{mon}/{day}/{filename} with uploading pic.jpg, it will store as /2023/06/08/pic.jpg.") 516 | .addText(text => 517 | text 518 | .setPlaceholder("Enter path") 519 | .setValue(this.plugin.settings.r2Setting.path) 520 | .onChange(value => this.plugin.settings.r2Setting.path = value)); 521 | 522 | //custom domain 523 | new Setting(parentEL) 524 | .setName("R2.dev URL, Custom Domain Name") 525 | .setDesc("You can use the R2.dev URL such as https://pub-xxxx.r2.dev here, or custom domain. If the custom domain name is example.com, you can use https://example.com/pic.jpg to access pic.img.") 526 | .addText(text => 527 | text 528 | .setPlaceholder("Enter domain name") 529 | .setValue(this.plugin.settings.r2Setting.customDomainName) 530 | .onChange(value => this.plugin.settings.r2Setting.customDomainName = value)); 531 | } 532 | } -------------------------------------------------------------------------------- /src/ui/uploadProgressModal.ts: -------------------------------------------------------------------------------- 1 | import {Modal, setIcon} from "obsidian"; 2 | 3 | export default class UploadProgressModal extends Modal { 4 | private totalImages: number = 0; 5 | private completedImages: number = 0; 6 | private progressBarEl: HTMLElement; 7 | private progressTextEl: HTMLElement; 8 | private imageListEl: HTMLElement; 9 | private statusEl: HTMLElement; 10 | private imageStatus: Map = new Map(); 11 | 12 | constructor(app) { 13 | super(app); 14 | this.titleEl.setText("Uploading Images"); 15 | } 16 | 17 | /** 18 | * Initialize the modal with the total number of images to upload 19 | * @param images Array of image objects or total count of images 20 | */ 21 | public initialize(images: any[] | number): void { 22 | if (typeof images === 'number') { 23 | this.totalImages = images; 24 | } else { 25 | this.totalImages = images.length; 26 | // Initialize image status map 27 | images.forEach(img => { 28 | if (img.name) { 29 | this.imageStatus.set(img.name, false); 30 | } 31 | }); 32 | } 33 | 34 | this.completedImages = 0; 35 | this.modalEl.classList.add("upload-progress-modal"); 36 | 37 | // Main content container 38 | const contentEl = this.contentEl.createDiv({cls: "upload-progress-content"}); 39 | 40 | // Progress section 41 | const progressSection = contentEl.createDiv({cls: "progress-section"}); 42 | 43 | // Status indicator (uploading/complete) 44 | this.statusEl = progressSection.createDiv({cls: "status-indicator"}); 45 | const statusIconContainer = this.statusEl.createSpan({cls: "status-icon"}); 46 | setIcon(statusIconContainer, "upload-cloud"); 47 | this.statusEl.createSpan({text: "Uploading...", cls: "status-text"}); 48 | 49 | // Progress bar container 50 | const progressBarContainer = progressSection.createDiv({cls: "progress-bar-container"}); 51 | this.progressBarEl = progressBarContainer.createDiv({cls: "progress-bar"}); 52 | 53 | // Progress text (e.g., "3/10 (30%)") 54 | this.progressTextEl = progressSection.createDiv({cls: "progress-text"}); 55 | this.updateProgressText(); 56 | 57 | // Image list (if we have image names) 58 | if (this.imageStatus.size > 0) { 59 | const imageListContainer = contentEl.createDiv({cls: "image-list-container"}); 60 | imageListContainer.createEl("h3", {text: "Images"}); 61 | this.imageListEl = imageListContainer.createDiv({cls: "image-list"}); 62 | this.renderImageList(); 63 | } 64 | 65 | // Style the modal 66 | this.addStyles(); 67 | } 68 | 69 | /** 70 | * Update progress for a specific image or increment the overall progress 71 | * @param imageName Optional image name 72 | * @param success Whether the upload was successful 73 | */ 74 | public updateProgress(imageName?: string, success: boolean = true): void { 75 | if (imageName && this.imageStatus.has(imageName)) { 76 | this.imageStatus.set(imageName, success); 77 | } 78 | 79 | this.completedImages++; 80 | 81 | // Update progress bar 82 | const percent = this.totalImages > 0 ? (this.completedImages / this.totalImages) * 100 : 0; 83 | this.progressBarEl.style.width = `${percent}%`; 84 | 85 | // Update progress text 86 | this.updateProgressText(); 87 | 88 | // Update image list if we have it 89 | if (this.imageListEl && imageName) { 90 | this.renderImageList(); 91 | } 92 | 93 | // If complete, update the status indicator 94 | if (this.completedImages >= this.totalImages) { 95 | this.statusEl.empty(); 96 | const statusIconContainer = this.statusEl.createSpan({cls: "status-icon"}); 97 | setIcon(statusIconContainer, "check"); 98 | this.statusEl.createSpan({text: "Complete", cls: "status-text"}); 99 | 100 | // Auto-close after 3 seconds 101 | setTimeout(() => { 102 | this.close(); 103 | }, 3000); 104 | } 105 | } 106 | 107 | /** 108 | * Update the progress text display 109 | */ 110 | private updateProgressText(): void { 111 | const percent = this.totalImages > 0 ? Math.round((this.completedImages / this.totalImages) * 100) : 0; 112 | this.progressTextEl.setText(`${this.completedImages}/${this.totalImages} (${percent}%)`); 113 | } 114 | 115 | /** 116 | * Render the list of images with their status 117 | */ 118 | private renderImageList(): void { 119 | if (!this.imageListEl) return; 120 | 121 | this.imageListEl.empty(); 122 | 123 | for (const [name, status] of this.imageStatus.entries()) { 124 | const itemEl = this.imageListEl.createDiv({cls: "image-item"}); 125 | 126 | // Status icon 127 | const iconContainer = itemEl.createSpan({cls: "image-status-icon"}); 128 | if (status) { 129 | setIcon(iconContainer, "check-circle"); 130 | iconContainer.classList.add("success"); 131 | } else { 132 | setIcon(iconContainer, "circle"); 133 | iconContainer.classList.add("pending"); 134 | } 135 | 136 | // Image name 137 | itemEl.createSpan({text: name, cls: "image-name"}); 138 | } 139 | } 140 | 141 | /** 142 | * Add custom styles to the modal 143 | */ 144 | private addStyles(): void { 145 | // Add custom styles to the progress bar 146 | this.progressBarEl.style.width = "0%"; 147 | this.progressBarEl.style.height = "8px"; 148 | this.progressBarEl.style.backgroundColor = "var(--interactive-accent)"; 149 | this.progressBarEl.style.borderRadius = "4px"; 150 | this.progressBarEl.style.transition = "width 0.3s ease-in-out"; 151 | 152 | const modalContent = this.contentEl; 153 | 154 | // Progress bar container styling 155 | const progressBarContainer = modalContent.querySelector(".progress-bar-container"); 156 | if (progressBarContainer instanceof HTMLElement) { 157 | progressBarContainer.style.width = "100%"; 158 | progressBarContainer.style.height = "8px"; 159 | progressBarContainer.style.backgroundColor = "var(--background-modifier-border)"; 160 | progressBarContainer.style.borderRadius = "4px"; 161 | progressBarContainer.style.marginBottom = "10px"; 162 | } 163 | 164 | // Progress section styling 165 | const progressSection = modalContent.querySelector(".progress-section"); 166 | if (progressSection instanceof HTMLElement) { 167 | progressSection.style.marginBottom = "20px"; 168 | progressSection.style.textAlign = "center"; 169 | } 170 | 171 | // Image list styling 172 | const imageList = modalContent.querySelector(".image-list"); 173 | if (imageList instanceof HTMLElement) { 174 | imageList.style.maxHeight = "200px"; 175 | imageList.style.overflowY = "auto"; 176 | imageList.style.border = "1px solid var(--background-modifier-border)"; 177 | imageList.style.borderRadius = "4px"; 178 | imageList.style.padding = "8px"; 179 | } 180 | } 181 | } -------------------------------------------------------------------------------- /src/uploader/apiError.ts: -------------------------------------------------------------------------------- 1 | export default class ApiError extends Error {} 2 | -------------------------------------------------------------------------------- /src/uploader/cos/common.ts: -------------------------------------------------------------------------------- 1 | export const TencentCloudRegionList: Record = { 2 | "ap-beijing": "China (Beijing)", 3 | "ap-nanjing": "China (Nanjing)", 4 | "ap-shanghai": "China (Shanghai)", 5 | "ap-guangzhou": "China (Guangzhou)", 6 | "ap-chengdu": "China (Chengdu)", 7 | "ap-chongqing": "China (Chongqing)", 8 | "ap-hongkong": "China (Hong Kong)", 9 | "ap-singapore": "Singapore", 10 | "ap-mumbai": "Mumbai", 11 | "ap-jakarta": "Jakarta", 12 | "ap-seoul": "Seoul", 13 | "ap-bangkok": "Bangkok", 14 | "ap-tokyo": "Tokyo", 15 | "na-siliconvalley": "North America (Silicon Valley - West US)", 16 | "na-ashburn": "North America (Virginia - East US)", 17 | "sa-saopaulo": "South America (São Paulo)", 18 | "eu-frankfurt": "Europe (Frankfurt)", 19 | } -------------------------------------------------------------------------------- /src/uploader/cos/cosUploader.ts: -------------------------------------------------------------------------------- 1 | import COS from "cos-nodejs-sdk-v5" 2 | import ImageUploader from "../imageUploader"; 3 | import {UploaderUtils} from "../uploaderUtils"; 4 | 5 | export default class CosUploader implements ImageUploader { 6 | 7 | private readonly client!: COS; 8 | private readonly pathTmpl: String; 9 | private readonly customDomainName: String; 10 | private readonly region: string; 11 | private readonly bucket: string; 12 | 13 | constructor(setting: CosSetting) { 14 | this.client = new COS({ 15 | SecretId: setting.secretId, 16 | SecretKey: setting.secretKey, 17 | }) 18 | this.pathTmpl = setting.path; 19 | this.customDomainName = setting.customDomainName; 20 | this.bucket = setting.bucket; 21 | this.region = setting.region; 22 | } 23 | 24 | async upload(image: File, fullPath: string): Promise { 25 | const result = this.client.putObject({ 26 | Body: Buffer.from((await image.arrayBuffer())), 27 | Bucket: this.bucket, 28 | Region: this.region, 29 | Key: UploaderUtils.generateName(this.pathTmpl, image.name), 30 | }); 31 | var url = 'https://' + (await result).Location; 32 | return UploaderUtils.customizeDomainName(url, this.customDomainName); 33 | } 34 | 35 | } 36 | 37 | export interface CosSetting { 38 | region: string; 39 | bucket: string; 40 | secretId: string; 41 | secretKey: string; 42 | path: string; 43 | customDomainName: string; 44 | } -------------------------------------------------------------------------------- /src/uploader/github/gitHubUploader.ts: -------------------------------------------------------------------------------- 1 | import ImageUploader from "../imageUploader"; 2 | import { Octokit } from "@octokit/rest"; 3 | 4 | export default class GitHubUploader implements ImageUploader { 5 | private readonly octokit: Octokit; 6 | private readonly owner: string; 7 | private readonly repo: string; 8 | private readonly branch: string; 9 | private readonly path: string; 10 | 11 | constructor(setting: GitHubSetting) { 12 | this.octokit = new Octokit({ 13 | auth: setting.token 14 | }); 15 | 16 | // Parse owner and repo from repository name (format: owner/repo) 17 | const [owner, repo] = setting.repositoryName.split('/'); 18 | this.owner = owner; 19 | this.repo = repo; 20 | this.branch = setting.branchName || 'main'; 21 | this.path = setting.path; 22 | } 23 | 24 | async upload(image: File, fullPath: string): Promise { 25 | try { 26 | const arrayBuffer = await this.readFileAsArrayBuffer(image); 27 | const base64Content = this.arrayBufferToBase64(arrayBuffer); 28 | 29 | const filePath = image.name.replace(/^\/+/, ''); // Remove leading slashes 30 | 31 | // Get the SHA of the file if it exists (needed for updating) 32 | let fileSha: string | undefined; 33 | try { 34 | const { data } = await this.octokit.repos.getContent({ 35 | owner: this.owner, 36 | repo: this.repo, 37 | path: filePath, 38 | ref: this.branch 39 | }); 40 | 41 | if (!Array.isArray(data)) { 42 | fileSha = data.sha; 43 | } 44 | } catch (error) { 45 | // File doesn't exist yet, which is fine 46 | } 47 | 48 | // Create or update the file in the repository 49 | const response = await this.octokit.repos.createOrUpdateFileContents({ 50 | owner: this.owner, 51 | repo: this.repo, 52 | path: filePath, 53 | message: `Upload image: ${image.name}`, 54 | content: base64Content, 55 | branch: this.branch, 56 | sha: fileSha 57 | }); 58 | 59 | // Return the URL to the uploaded image 60 | // Format: https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{path} 61 | return `https://raw.githubusercontent.com/${this.owner}/${this.repo}/${this.branch}/${filePath}`; 62 | } catch (error) { 63 | console.error("Error uploading to GitHub:", error); 64 | throw error; 65 | } 66 | } 67 | 68 | private readFileAsArrayBuffer(file: File): Promise { 69 | return new Promise((resolve, reject) => { 70 | const reader = new FileReader(); 71 | reader.onload = () => resolve(reader.result as ArrayBuffer); 72 | reader.onerror = reject; 73 | reader.readAsArrayBuffer(file); 74 | }); 75 | } 76 | 77 | private arrayBufferToBase64(buffer: ArrayBuffer): string { 78 | let binary = ''; 79 | const bytes = new Uint8Array(buffer); 80 | const len = bytes.byteLength; 81 | 82 | for (let i = 0; i < len; i++) { 83 | binary += String.fromCharCode(bytes[i]); 84 | } 85 | 86 | return btoa(binary); 87 | } 88 | } 89 | 90 | export interface GitHubSetting { 91 | repositoryName: string; // Format: owner/repo 92 | branchName: string; 93 | token: string; 94 | path: string; 95 | } -------------------------------------------------------------------------------- /src/uploader/imageTagProcessor.ts: -------------------------------------------------------------------------------- 1 | import {App, Editor, FileSystemAdapter, MarkdownView, normalizePath, Notice, setIcon} from "obsidian"; 2 | import path from "path"; 3 | import ImageUploader from "./imageUploader"; 4 | import {PublishSettings} from "../publish"; 5 | import UploadProgressModal from "../ui/uploadProgressModal"; 6 | 7 | const MD_REGEX = /\!\[(.*)\]\((.*?\.(png|jpg|jpeg|gif|svg|webp|excalidraw))\)/g; 8 | const WIKI_REGEX = /\!\[\[(.*?\.(png|jpg|jpeg|gif|svg|webp|excalidraw))(|.*)?\]\]/g; 9 | const PROPERTIES_REGEX = /^---[\s\S]+?---\n/; 10 | 11 | interface Image { 12 | name: string; 13 | path: string; 14 | url: string; 15 | source: string; 16 | } 17 | 18 | // Return type for resolveImagePath method 19 | interface ResolvedImagePath { 20 | resolvedPath: string; 21 | name: string; 22 | } 23 | 24 | export const ACTION_PUBLISH: string = "PUBLISH"; 25 | 26 | export default class ImageTagProcessor { 27 | private app: App; 28 | private readonly imageUploader: ImageUploader; 29 | private settings: PublishSettings; 30 | private adapter: FileSystemAdapter; 31 | private progressModal: UploadProgressModal | null = null; 32 | private useModal: boolean = true; // Set to true to use modal, false to use status bar 33 | 34 | constructor(app: App, settings: PublishSettings, imageUploader: ImageUploader, useModal: boolean = true) { 35 | this.app = app; 36 | this.adapter = this.app.vault.adapter as FileSystemAdapter; 37 | this.settings = settings; 38 | this.imageUploader = imageUploader; 39 | this.useModal = useModal; 40 | } 41 | 42 | public async process(action: string): Promise { 43 | let value = this.getValue(); 44 | const basePath = this.adapter.getBasePath(); 45 | const promises: Promise[] = []; 46 | const images = this.getImageLists(value); 47 | const uploader = this.imageUploader; 48 | 49 | // Initialize progress display 50 | if (this.useModal && images.length > 0) { 51 | this.progressModal = new UploadProgressModal(this.app); 52 | this.progressModal.open(); 53 | this.progressModal.initialize(images); 54 | } 55 | 56 | for (const image of images) { 57 | if (this.app.vault.getAbstractFileByPath(normalizePath(image.path)) == null) { 58 | new Notice(`Can NOT locate ${image.name} with ${image.path}, please check image path or attachment option in plugin setting!`, 10000); 59 | console.log(`${normalizePath(image.path)} not exist`); 60 | // Update the progress modal with the failure 61 | if (this.progressModal) { 62 | this.progressModal.updateProgress(image.name, false); 63 | } 64 | continue; // Skip to the next image 65 | } 66 | 67 | try { 68 | const buf = await this.adapter.readBinary(image.path); 69 | promises.push(new Promise((resolve, reject) => { 70 | uploader.upload(new File([buf], image.name), basePath + '/' + image.path) 71 | .then(imgUrl => { 72 | image.url = imgUrl; 73 | // Update progress on successful upload 74 | if (this.progressModal) { 75 | this.progressModal.updateProgress(image.name, true); 76 | } 77 | resolve(image); 78 | }) 79 | .catch(e => { 80 | // Also update progress on failed upload 81 | if (this.progressModal) { 82 | this.progressModal.updateProgress(image.name, false); 83 | } 84 | const errorMessage = `Upload ${image.path} failed, remote server returned an error: ${e.error || e.message || e}`; 85 | new Notice(errorMessage, 10000); 86 | reject(new Error(errorMessage)); 87 | }); 88 | })); 89 | } catch (error) { 90 | console.error(`Failed to read file: ${image.path}`, error); 91 | new Notice(`Failed to read file: ${image.path}`, 5000); 92 | } 93 | } 94 | 95 | if (promises.length === 0) { 96 | if (this.progressModal) { 97 | this.progressModal.close(); 98 | } 99 | new Notice("No images found or all images failed to process", 3000); 100 | return; 101 | } 102 | 103 | return Promise.all(promises.map(p => p.catch(e => { 104 | console.error(e); 105 | return null; // Return null for failed promises to continue processing 106 | }))).then(results => { 107 | // Modal will auto-close when all uploads complete 108 | // Filter out null results from failed promises 109 | const successfulImages = results.filter(img => img !== null) as Image[]; 110 | 111 | let altText; 112 | for (const image of successfulImages) { 113 | altText = this.settings.imageAltText ? 114 | path.parse(image.name)?.name?.replaceAll("-", " ")?.replaceAll("_", " ") : 115 | ''; 116 | value = value.replaceAll(image.source, `![${altText}](${image.url})`); 117 | } 118 | 119 | if (this.settings.replaceOriginalDoc && this.getEditor()) { 120 | this.getEditor()?.setValue(value); 121 | } 122 | 123 | if (this.settings.ignoreProperties) { 124 | value = value.replace(PROPERTIES_REGEX, ''); 125 | } 126 | 127 | switch (action) { 128 | case ACTION_PUBLISH: 129 | navigator.clipboard.writeText(value); 130 | new Notice("Copied to clipboard"); 131 | break; 132 | // more cases 133 | default: 134 | throw new Error("invalid action!"); 135 | } 136 | }); 137 | } 138 | 139 | private getImageLists(value: string): Image[] { 140 | const images: Image[] = []; 141 | 142 | try { 143 | const wikiMatches = value.matchAll(WIKI_REGEX); 144 | for (const match of wikiMatches) { 145 | this.processMatched(match[1], match[0], images); 146 | } 147 | 148 | const mdMatches = value.matchAll(MD_REGEX); 149 | for (const match of mdMatches) { 150 | // Skip external images 151 | if (match[2].startsWith('http://') || match[2].startsWith('https://')) { 152 | continue; 153 | } 154 | const decodedName = decodeURI(match[2]); 155 | this.processMatched(decodedName, match[0], images); 156 | 157 | } 158 | } catch (error) { 159 | console.error("Error processing image lists:", error); 160 | } 161 | 162 | return images; 163 | } 164 | 165 | private processMatched(path: string, src: string, images: Image[]){ 166 | try { 167 | const {resolvedPath, name} = this.resolveImagePath(path); 168 | // check the item with same resolvedPath 169 | const existingImage = images.find(image => image.path === resolvedPath); 170 | if (!existingImage) { 171 | images.push({ 172 | name, 173 | path: resolvedPath, 174 | source: src, 175 | url: '', 176 | }); 177 | } 178 | }catch (error) { 179 | console.error(`Failed to process image: ${src}`, error); 180 | } 181 | } 182 | 183 | private resolveImagePath(imageName: string): ResolvedImagePath { 184 | let pathName = imageName.endsWith('.excalidraw') ? 185 | imageName + '.png' : 186 | imageName; 187 | 188 | if(imageName.indexOf('/') < 0) { 189 | pathName = path.join(this.app.vault.config.attachmentFolderPath, pathName); 190 | if (this.app.vault.config.attachmentFolderPath.startsWith('.')) { 191 | pathName = './' + pathName; 192 | } 193 | } 194 | 195 | if (pathName.startsWith('./')) { 196 | pathName = pathName.substring(2); 197 | const activeFile = this.app.workspace.getActiveFile(); 198 | if (!activeFile || !activeFile.parent) { 199 | throw new Error("No active file found"); 200 | } 201 | const parentPath = activeFile.parent.path; 202 | return {resolvedPath: path.join(parentPath, pathName), name: pathName}; 203 | } else { 204 | return {resolvedPath: pathName, name: pathName}; 205 | } 206 | } 207 | 208 | private getValue(): string { 209 | const editor = this.getEditor(); 210 | return editor ? editor.getValue() : ""; 211 | } 212 | 213 | private getEditor(): Editor | null { 214 | const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); 215 | return activeView ? activeView.editor : null; 216 | } 217 | } -------------------------------------------------------------------------------- /src/uploader/imageUploader.ts: -------------------------------------------------------------------------------- 1 | export default interface ImageUploader { 2 | upload(image: File, fullPath: string): Promise; 3 | } 4 | 5 | -------------------------------------------------------------------------------- /src/uploader/imageUploaderBuilder.ts: -------------------------------------------------------------------------------- 1 | import {PublishSettings} from "../publish"; 2 | import ImageUploader from "./imageUploader"; 3 | import ImageStore from "../imageStore"; 4 | import ImgurAnonymousUploader from "./imgur/imgurAnonymousUploader"; 5 | import OssUploader from "./oss/ossUploader"; 6 | import ImagekitUploader from "./imagekit/imagekitUploader"; 7 | import AwsS3Uploader from "./s3/awsS3Uploader"; 8 | import CosUploader from "./cos/cosUploader"; 9 | import KodoUploader from "./qiniu/kodoUploader"; 10 | import GitHubUploader from "./github/gitHubUploader"; 11 | import R2Uploader from "./r2/r2Uploader"; 12 | 13 | export default function buildUploader(settings: PublishSettings): ImageUploader { 14 | switch (settings.imageStore) { 15 | case ImageStore.IMGUR.id: 16 | return new ImgurAnonymousUploader(settings.imgurAnonymousSetting.clientId); 17 | case ImageStore.ALIYUN_OSS.id: 18 | return new OssUploader(settings.ossSetting); 19 | case ImageStore.ImageKit.id: 20 | return new ImagekitUploader(settings.imagekitSetting); 21 | case ImageStore.AWS_S3.id: 22 | return new AwsS3Uploader(settings.awsS3Setting); 23 | case ImageStore.TENCENTCLOUD_COS.id: 24 | return new CosUploader(settings.cosSetting); 25 | case ImageStore.QINIU_KUDO.id: 26 | return new KodoUploader(settings.kodoSetting); 27 | case ImageStore.GITHUB.id: 28 | return new GitHubUploader(settings.githubSetting); 29 | case ImageStore.CLOUDFLARE_R2.id: 30 | return new R2Uploader(settings.r2Setting); 31 | //todo more cases 32 | default: 33 | throw new Error('should not reach here!') 34 | } 35 | } -------------------------------------------------------------------------------- /src/uploader/imagekit/imagekitUploader.ts: -------------------------------------------------------------------------------- 1 | import ImageUploader from "../imageUploader"; 2 | import Imagekit from "imagekit"; 3 | 4 | export default class ImagekitUploader implements ImageUploader { 5 | private readonly imagekit!: Imagekit; 6 | private readonly setting!: ImagekitSetting; 7 | 8 | constructor(setting: ImagekitSetting) { 9 | this.imagekit = new Imagekit({ 10 | publicKey: setting.publicKey, 11 | privateKey: setting.privateKey, 12 | urlEndpoint: setting.endpoint, 13 | }); 14 | this.setting = setting; 15 | } 16 | async upload(image: File, fullPath: string): Promise { 17 | const result = await this.imagekit.upload({ 18 | file : Buffer.from((await image.arrayBuffer())).toString('base64'), //required 19 | fileName : image.name, //required 20 | folder: this.setting.folder || '/', 21 | extensions: [ 22 | { 23 | name: "google-auto-tagging", 24 | maxTags: 5, 25 | minConfidence: 95 26 | } 27 | ] 28 | }); 29 | 30 | return result.url; 31 | } 32 | } 33 | 34 | 35 | export interface ImagekitSetting { 36 | folder: string; 37 | imagekitID: string; 38 | publicKey: string; 39 | privateKey: string; 40 | endpoint: string; 41 | } -------------------------------------------------------------------------------- /src/uploader/imgur/constants.ts: -------------------------------------------------------------------------------- 1 | export const IMGUR_PLUGIN_CLIENT_ID = "fb4a95c4bc8432a"; 2 | export const IMGUR_API_BASE = "https://api.imgur.com/3/"; 3 | export const IMGUR_ACCESS_TOKEN_LOCALSTORAGE_KEY = "imgur-access_token"; 4 | -------------------------------------------------------------------------------- /src/uploader/imgur/imgurAnonymousUploader.ts: -------------------------------------------------------------------------------- 1 | import ImageUploader from "../imageUploader"; 2 | import {IMGUR_API_BASE} from "./constants"; 3 | import {ImgurErrorData, ImgurPostData} from "./imgurResponseTypes"; 4 | import {requestUrl, RequestUrlResponse} from "obsidian"; 5 | import ApiError from "../apiError"; 6 | 7 | export default class ImgurAnonymousUploader implements ImageUploader { 8 | private readonly clientId!: string; 9 | 10 | constructor(clientId: string) { 11 | this.clientId = clientId; 12 | } 13 | 14 | async upload(image: File, path: string): Promise { 15 | const requestData = new FormData(); 16 | requestData.append("image", image); 17 | const resp = await requestUrl({ 18 | body: await image.arrayBuffer(), 19 | headers: {Authorization: `Client-ID ${this.clientId}`}, 20 | method: "POST", 21 | url: `${IMGUR_API_BASE}image`}) 22 | 23 | if ((await resp).status != 200) { 24 | await handleImgurErrorResponse(resp); 25 | } 26 | return ((await resp.json) as ImgurPostData).data.link; 27 | } 28 | } 29 | 30 | export interface ImgurAnonymousSetting { 31 | clientId: string; 32 | } 33 | 34 | export async function handleImgurErrorResponse(resp: RequestUrlResponse): Promise { 35 | if ((await resp).headers["Content-Type"] === "application/json") { 36 | throw new ApiError(((await resp.json) as ImgurErrorData).data.error); 37 | } 38 | throw new Error(resp.text); 39 | } -------------------------------------------------------------------------------- /src/uploader/imgur/imgurResponseTypes.ts: -------------------------------------------------------------------------------- 1 | export type ImgurErrorData = { 2 | success: boolean; 3 | status: number; 4 | data: { 5 | request: string; 6 | method: string; 7 | error: string; 8 | }; 9 | }; 10 | 11 | export type AccountInfo = { 12 | success: boolean; 13 | status: number; 14 | data: { 15 | id: number; 16 | created: number; 17 | 18 | url: string; 19 | bio: string; 20 | avatar: string; 21 | avatar_name: string; 22 | cover: string; 23 | cover_name: string; 24 | reputation: number; 25 | reputation_name: string; 26 | 27 | pro_expiration: boolean; 28 | 29 | user_flow: { 30 | status: boolean; 31 | }; 32 | 33 | is_blocked: boolean; 34 | }; 35 | }; 36 | 37 | export type ImgurPostData = { 38 | success: boolean; 39 | status: number; 40 | data: { 41 | datetime: number; 42 | id: string; 43 | link: string; 44 | deletehash: string; 45 | 46 | size: number; 47 | width: number; 48 | height: number; 49 | 50 | type: string; 51 | animated: boolean; 52 | has_sound: boolean; 53 | }; 54 | }; 55 | -------------------------------------------------------------------------------- /src/uploader/oss/common.ts: -------------------------------------------------------------------------------- 1 | export const AliYunRegionList: Record = { 2 | "oss-cn-hangzhou": "China (Hangzhou)", 3 | "oss-cn-shanghai": "China (Shanghai)", 4 | "oss-cn-nanjing": "China (Nanjing - Local Region)", 5 | "oss-cn-qingdao": "China (Qingdao)", 6 | "oss-cn-beijing": "China (Beijing)", 7 | "oss-cn-zhangjiakou": "China (Zhangjiakou)", 8 | "oss-cn-huhehaote": "China (Hohhot)", 9 | "oss-cn-wulanchabu": "China (Ulanqab)", 10 | "oss-cn-shenzhen": "China (Shenzhen)", 11 | "oss-cn-heyuan": "China (Heyuan)", 12 | "oss-cn-guangzhou": "China (Guangzhou)", 13 | "oss-cn-chengdu": "China (Chengdu)", 14 | "oss-cn-hongkong": "China (Hong Kong)", 15 | "oss-us-west-1": "US (Silicon Valley) *", 16 | "oss-us-east-1": "US (Virginia) *", 17 | "oss-ap-northeast-1": "Japan (Tokyo) *", 18 | "oss-ap-northeast-2": "South Korea (Seoul)", 19 | "oss-ap-southeast-1": "Singapore *", 20 | "oss-ap-southeast-2": "Australia (Sydney) *", 21 | "oss-ap-southeast-3": "Malaysia (Kuala Lumpur) *", 22 | "oss-ap-southeast-5": "Indonesia (Jakarta) *", 23 | "oss-ap-southeast-6": "Philippines (Manila)", 24 | "oss-ap-southeast-7": "Thailand (Bangkok)", 25 | "oss-ap-south-1": "India (Mumbai) *", 26 | "oss-eu-central-1": "Germany (Frankfurt) *", 27 | "oss-eu-west-1": "UK (London)", 28 | "oss-me-east-1": "UAE (Dubai) *" 29 | } 30 | 31 | export const QiniuZoneList: Record = { 32 | 33 | } -------------------------------------------------------------------------------- /src/uploader/oss/ossUploader.ts: -------------------------------------------------------------------------------- 1 | import ImageUploader from "../imageUploader"; 2 | import {UploaderUtils} from "../uploaderUtils"; 3 | import OSS from "ali-oss" 4 | 5 | export default class OssUploader implements ImageUploader { 6 | private readonly client!: OSS; 7 | private readonly pathTmpl: String; 8 | private readonly customDomainName: String; 9 | 10 | constructor(setting: OssSetting) { 11 | this.client = new OSS({ 12 | region: setting.region, 13 | accessKeyId: setting.accessKeyId, 14 | accessKeySecret: setting.accessKeySecret, 15 | bucket: setting.bucket, 16 | secure: true, 17 | }); 18 | this.client.agent = this.client.urllib.agent; 19 | this.client.httpsAgent = this.client.urllib.httpsAgent; 20 | this.pathTmpl = setting.path; 21 | this.customDomainName = setting.customDomainName; 22 | } 23 | 24 | async upload(image: File, path: string): Promise { 25 | const result = this.client.put(UploaderUtils.generateName(this.pathTmpl, image.name), path); 26 | return UploaderUtils.customizeDomainName((await result).url, this.customDomainName); 27 | } 28 | 29 | } 30 | 31 | export interface OssSetting { 32 | region: string; 33 | accessKeyId: string; 34 | accessKeySecret: string; 35 | bucket: string; 36 | endpoint: string; 37 | path: string; 38 | customDomainName: string; 39 | } -------------------------------------------------------------------------------- /src/uploader/qiniu/kodoUploader.ts: -------------------------------------------------------------------------------- 1 | import ImageUploader from "../imageUploader"; 2 | import qiniu from "qiniu"; 3 | import {UploaderUtils} from "../uploaderUtils"; 4 | 5 | export default class KodoUploader implements ImageUploader { 6 | 7 | private uploadToken: string; 8 | private tokenExpireTime: number; 9 | private setting: KodoSetting; 10 | 11 | constructor(setting: KodoSetting) { 12 | this.setting = setting; 13 | } 14 | 15 | async upload(image: File, path: string): Promise { 16 | //check custom domain name 17 | if (!this.setting.customDomainName || this.setting.customDomainName.trim() === "") { 18 | throw new Error("Custom domain name is required for Qiniu Kodo.") 19 | } 20 | this.updateToken(); 21 | const config = new qiniu.conf.Config(); 22 | const formUploader = new qiniu.form_up.FormUploader(config); 23 | const putExtra = new qiniu.form_up.PutExtra(); 24 | let key = UploaderUtils.generateName(this.setting.path, image.name.replaceAll(' ', '_')); //replace space with _ in file name 25 | return formUploader 26 | .putFile(this.uploadToken, key, path, putExtra) 27 | .then(({data, resp}) => { 28 | if (resp.statusCode === 200) { 29 | return this.setting.customDomainName + '/' + data.key; 30 | } else { 31 | throw data; 32 | } 33 | }) 34 | .catch((err) => { 35 | throw err 36 | }); 37 | } 38 | 39 | updateToken(): void { 40 | if (this.tokenExpireTime && this.tokenExpireTime > Date.now()) { 41 | return; 42 | } 43 | const mac = new qiniu.auth.digest.Mac(this.setting.accessKey, this.setting.secretKey); 44 | const expires = 3600; 45 | this.tokenExpireTime = Date.now() + expires * 1000; 46 | const options = { 47 | scope: this.setting.bucket, 48 | expires: expires, 49 | returnBody: 50 | '{"key":"$(key)","hash":"$(etag)","bucket":"$(bucket)","name":"$(x:name)","age":$(x:age)}', 51 | }; 52 | const putPolicy = new qiniu.rs.PutPolicy(options); 53 | this.uploadToken = putPolicy.uploadToken(mac); 54 | } 55 | } 56 | 57 | export interface KodoSetting { 58 | accessKey: string; 59 | secretKey: string; 60 | bucket: string; 61 | customDomainName: string; 62 | path: string; 63 | } -------------------------------------------------------------------------------- /src/uploader/r2/r2Uploader.ts: -------------------------------------------------------------------------------- 1 | import ImageUploader from "../imageUploader"; 2 | import AWS from 'aws-sdk'; 3 | import {UploaderUtils} from "../uploaderUtils"; 4 | 5 | export default class R2Uploader implements ImageUploader { 6 | private readonly r2!: AWS.S3; 7 | private readonly bucket!: string; 8 | private pathTmpl: string; 9 | private customDomainName: string; 10 | 11 | constructor(setting: R2Setting) { 12 | this.r2 = new AWS.S3({ 13 | accessKeyId: setting.accessKeyId, 14 | secretAccessKey: setting.secretAccessKey, 15 | endpoint: setting.endpoint, 16 | region: 'auto', // Cloudflare R2 uses 'auto' region 17 | s3ForcePathStyle: true, // Needed for Cloudflare R2 18 | signatureVersion: 'v4', // Cloudflare R2 uses v4 signatures 19 | }); 20 | this.bucket = setting.bucketName; 21 | this.pathTmpl = setting.path; 22 | this.customDomainName = setting.customDomainName; 23 | } 24 | 25 | async upload(image: File, fullPath: string): Promise { 26 | const arrayBuffer = await this.readFileAsArrayBuffer(image); 27 | const uint8Array = new Uint8Array(arrayBuffer); 28 | var path = UploaderUtils.generateName(this.pathTmpl, image.name); 29 | path = path.replace(/^\/+/, ''); // remove the / 30 | const params = { 31 | Bucket: this.bucket, 32 | Key: path, 33 | Body: uint8Array, 34 | ContentType: `image/${image.name.split('.').pop()}`, 35 | }; 36 | return new Promise((resolve, reject) => { 37 | this.r2.upload(params, (err, data) => { 38 | if (err) { 39 | reject(err); 40 | } else { 41 | const dst = data.Location.split(`/${this.bucket}/`).pop(); 42 | resolve(UploaderUtils.customizeDomainName(dst, this.customDomainName)); 43 | } 44 | }); 45 | }); 46 | } 47 | 48 | private readFileAsArrayBuffer(file: File): Promise { 49 | return new Promise((resolve, reject) => { 50 | const reader = new FileReader(); 51 | reader.onload = () => resolve(reader.result as ArrayBuffer); 52 | reader.onerror = reject; 53 | reader.readAsArrayBuffer(file); 54 | }); 55 | } 56 | } 57 | 58 | export interface R2Setting { 59 | accessKeyId: string; 60 | secretAccessKey: string; 61 | endpoint: string; 62 | bucketName: string; 63 | path: string; 64 | customDomainName: string; 65 | } -------------------------------------------------------------------------------- /src/uploader/s3/awsS3Uploader.ts: -------------------------------------------------------------------------------- 1 | import ImageUploader from "../imageUploader"; 2 | import AWS from 'aws-sdk'; 3 | import {UploaderUtils} from "../uploaderUtils"; 4 | 5 | export default class AwsS3Uploader implements ImageUploader { 6 | private readonly s3!: AWS.S3; 7 | private readonly bucket!: string; 8 | private pathTmpl: string; 9 | private customDomainName: string; 10 | 11 | 12 | constructor(setting: AwsS3Setting) { 13 | this.s3 = new AWS.S3({ 14 | accessKeyId: setting.accessKeyId, 15 | secretAccessKey: setting.secretAccessKey, 16 | region: setting.region 17 | }); 18 | this.bucket = setting.bucketName; 19 | this.pathTmpl = setting.path; 20 | this.customDomainName = setting.customDomainName; 21 | } 22 | 23 | async upload(image: File, fullPath: string): Promise { 24 | const arrayBuffer = await this.readFileAsArrayBuffer(image); 25 | const uint8Array = new Uint8Array(arrayBuffer); 26 | var path = UploaderUtils.generateName(this.pathTmpl, image.name); 27 | path = path.replace(/^\/+/, ''); // remove the / 28 | const params = { 29 | Bucket: this.bucket, 30 | Key: path, 31 | Body: uint8Array, 32 | }; 33 | return new Promise((resolve, reject) => { 34 | this.s3.upload(params, (err, data) => { 35 | if (err) { 36 | reject(err); 37 | } else { 38 | resolve(UploaderUtils.customizeDomainName(data.Location, this.customDomainName)); 39 | } 40 | }); 41 | }); 42 | } 43 | 44 | private readFileAsArrayBuffer(file: File): Promise { 45 | return new Promise((resolve, reject) => { 46 | const reader = new FileReader(); 47 | reader.onload = () => resolve(reader.result as ArrayBuffer); 48 | reader.onerror = reject; 49 | reader.readAsArrayBuffer(file); 50 | }); 51 | } 52 | } 53 | export interface AwsS3Setting { 54 | accessKeyId: string; 55 | secretAccessKey: string; 56 | region: string; 57 | bucketName: string; 58 | path: string; 59 | customDomainName: string; 60 | } -------------------------------------------------------------------------------- /src/uploader/uploaderUtils.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | export class UploaderUtils { 4 | static generateName(pathTmpl,imageName: string): string { 5 | const date = new Date(); 6 | const year = date.getFullYear().toString(); 7 | const month = (date.getMonth() + 1).toString().padStart(2, '0'); 8 | const day = date.getDate().toString().padStart(2, '0'); 9 | const random = this.generateRandomString(20); 10 | 11 | return pathTmpl != undefined && pathTmpl.trim().length > 0 ? pathTmpl 12 | .replace('{year}', year) 13 | .replace('{mon}', month) 14 | .replace('{day}', day) 15 | .replace('{random}', random) 16 | .replace('{filename}', imageName) 17 | : imageName 18 | ; 19 | } 20 | 21 | private static generateRandomString(length: number): string { 22 | const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 23 | let result = ''; 24 | 25 | for (let i = 0; i < length; i++) { 26 | const randomIndex = Math.floor(Math.random() * characters.length); 27 | result += characters.charAt(randomIndex); 28 | } 29 | 30 | return result; 31 | } 32 | 33 | static customizeDomainName(url, customDomainName) { 34 | const regex = /https?:\/\/([^/]+)/; 35 | customDomainName = customDomainName.replaceAll('https://', '') 36 | if (customDomainName && customDomainName.trim() !== "") { 37 | if (url.match(regex) != null) { 38 | return url.replace(regex, (match, domain) => { 39 | return match.replace(domain, customDomainName); 40 | }) 41 | } else { 42 | return `https://${customDomainName}/${url}`; 43 | } 44 | } 45 | return url; 46 | } 47 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021", 4 | "module": "commonjs", 5 | "emitDeclarationOnly": true, 6 | "declaration": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "moduleResolution": "node", 10 | "forceConsistentCasingInFileNames": true 11 | }, 12 | "include": ["src/**/*.ts", "types.d.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | // Empty declaration to allow for css imports 2 | declare module "*.css" {} 3 | --------------------------------------------------------------------------------