Skip to content

Commit 314d71a

Browse files
authored
feat: add Gyazo uploader (#52)
Add Gyazo as a new upload provider with access token authentication, configurable access policy and description metadata. Also normalizes ImageStore IDs across the codebase for consistency.
1 parent 5ab2b12 commit 314d71a

10 files changed

Lines changed: 379 additions & 20 deletions

README.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,12 @@
3838
-**Web Image Upload** - Download and re-upload web images to your storage (optional)
3939
-**Mermaid Conversion** - Automatically convert mermaid diagrams to PNG images during publish (optional)
4040

41-
### Supported Storage Services (9 providers)
41+
### Supported Storage Services (10 providers)
4242
| Service | Free Tier | Rating | Best For |
4343
|---------|-----------|---------|----------|
4444
| Imgur | Limited | ⭐⭐⭐ | Personal blogs |
4545
| GitHub | Unlimited | ⭐⭐⭐⭐ | Open source projects |
46+
| Gyazo | Unlimited | ⭐⭐⭐⭐ | Fast sharing workflows |
4647
| Cloudflare R2 | Pay-as-you-go | ⭐⭐⭐⭐⭐ | Professional use |
4748
| AWS S3 | Pay-as-you-go | ⭐⭐⭐⭐ | Enterprise |
4849
| Aliyun OSS | Pay-as-you-go | ⭐⭐⭐⭐ | Chinese users |
@@ -95,6 +96,7 @@ Select your preferred storage service from the dropdown. See [Storage Service Co
9596
### Service Selection Guide
9697
- **Personal Use**: Imgur (simple and free)
9798
- **Open Source**: GitHub (version control integration)
99+
- **Quick Sharing**: Gyazo (simple token-based upload)
98100
- **Professional**: Cloudflare R2 (high performance)
99101
- **Enterprise**: AWS S3 (full-featured)
100102
- **Chinese Users**: Aliyun OSS (optimized for China)
@@ -118,6 +120,19 @@ Select your preferred storage service from the dropdown. See [Storage Service Co
118120
Note: Images are committed as regular files to the repository
119121
```
120122

123+
#### Gyazo
124+
```markdown
125+
1. Visit https://gyazo.com/oauth/applications
126+
2. Create a Gyazo application
127+
3. Issue an access token from the application dashboard
128+
4. No OAuth callback server is required for this plugin. It uses the issued access token directly.
129+
5. Configure in plugin:
130+
- Access Token
131+
- Access Policy: anyone or only_me
132+
- Common Description: optional shared desc value for every upload
133+
Note: only_me uploads may not be usable for public publishing workflows
134+
```
135+
121136
#### Cloudflare R2 (Recommended for Professional Use)
122137
```markdown
123138
1. Sign up at https://dash.cloudflare.com/sign-up

src/imageStore.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ export default class ImageStore {
3939
"GITHUB",
4040
"GitHub Repository"
4141
)
42+
43+
static readonly GYAZO = new ImageStore(
44+
"GYAZO",
45+
"Gyazo"
46+
)
4247

4348
static readonly CLOUDFLARE_R2 = new ImageStore(
4449
"CLOUDFLARE_R2",
@@ -50,7 +55,38 @@ export default class ImageStore {
5055
"Backblaze B2"
5156
)
5257

58+
private static readonly aliases: Record<string, string> = {
59+
imgur: ImageStore.IMGUR.id,
60+
gyazo: ImageStore.GYAZO.id,
61+
oss: ImageStore.ALIYUN_OSS.id,
62+
aliyun_oss: ImageStore.ALIYUN_OSS.id,
63+
imagekit: ImageStore.ImageKit.id,
64+
s3: ImageStore.AWS_S3.id,
65+
aws_s3: ImageStore.AWS_S3.id,
66+
cos: ImageStore.TENCENTCLOUD_COS.id,
67+
tencentcloud_cos: ImageStore.TENCENTCLOUD_COS.id,
68+
qiniu: ImageStore.QINIU_KUDO.id,
69+
qiniu_kudo: ImageStore.QINIU_KUDO.id,
70+
github: ImageStore.GITHUB.id,
71+
r2: ImageStore.CLOUDFLARE_R2.id,
72+
cloudflare_r2: ImageStore.CLOUDFLARE_R2.id,
73+
b2: ImageStore.BACKBLAZE_B2.id,
74+
backblaze_b2: ImageStore.BACKBLAZE_B2.id,
75+
};
76+
5377
private constructor(readonly id: string, readonly description: string) {
5478
ImageStore.values.push(this)
5579
}
56-
}
80+
81+
static normalizeId(id: string | undefined | null): string {
82+
if (!id) {
83+
return ImageStore.IMGUR.id;
84+
}
85+
86+
if (ImageStore.values.some((store) => store.id === id)) {
87+
return id;
88+
}
89+
90+
return ImageStore.aliases[id.toLowerCase()] || id;
91+
}
92+
}

src/publish.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type {KodoSetting} from "./uploader/qiniu/kodoUploader";
1818
import type {GitHubSetting} from "./uploader/github/gitHubUploader";
1919
import type {R2Setting} from "./uploader/r2/r2Uploader";
2020
import type {B2Setting} from "./uploader/b2/b2Uploader";
21+
import type {GyazoSetting} from "./uploader/gyazo/gyazoUploader";
2122

2223
export interface PublishSettings {
2324
imageAltText: boolean;
@@ -31,6 +32,7 @@ export interface PublishSettings {
3132
mermaidTheme: string; // Mermaid render theme: default, dark, forest, neutral, base
3233
//Imgur Anonymous setting
3334
imgurAnonymousSetting: ImgurAnonymousSetting;
35+
gyazoSetting: GyazoSetting;
3436
ossSetting: OssSetting;
3537
imagekitSetting: ImagekitSetting;
3638
awsS3Setting: AwsS3Setting;
@@ -52,6 +54,11 @@ const DEFAULT_SETTINGS: PublishSettings = {
5254
mermaidScale: 2, // 2x for crisp output on retina displays
5355
mermaidTheme: "default",
5456
imgurAnonymousSetting: {clientId: IMGUR_PLUGIN_CLIENT_ID},
57+
gyazoSetting: {
58+
accessToken: "",
59+
accessPolicy: "anyone",
60+
desc: "",
61+
},
5562
ossSetting: {
5663
region: "oss-cn-hangzhou",
5764
accessKeyId: "",
@@ -144,7 +151,10 @@ export default class ObsidianPublish extends Plugin {
144151
}
145152

146153
async loadSettings() {
147-
this.settings = Object.assign({}, DEFAULT_SETTINGS, (await this.loadData()) as PublishSettings);
154+
const loadedData = (await this.loadData()) as Partial<PublishSettings> | null;
155+
this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedData);
156+
this.settings.imageStore = ImageStore.normalizeId(this.settings.imageStore);
157+
this.settings.gyazoSetting = Object.assign({}, DEFAULT_SETTINGS.gyazoSetting, loadedData?.gyazoSetting);
148158
}
149159

150160
async saveSettings() {
@@ -174,4 +184,4 @@ export default class ObsidianPublish extends Plugin {
174184
console.log(`Failed to setup image uploader: ${e}`)
175185
}
176186
}
177-
}
187+
}

src/ui/publishSettingTab.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export default class PublishSettingTab extends PluginSettingTab {
1616
display(): any {
1717
const {containerEl} = this;
1818
containerEl.empty()
19+
this.plugin.settings.imageStore = ImageStore.normalizeId(this.plugin.settings.imageStore);
1920

2021
// ── General ──
2122
containerEl.createEl("h2", {text: "General"});
@@ -137,10 +138,13 @@ export default class PublishSettingTab extends PluginSettingTab {
137138

138139
private async drawImageStoreSettings(parentEL: HTMLDivElement) {
139140
parentEL.empty();
140-
switch (this.plugin.settings.imageStore) {
141+
switch (ImageStore.normalizeId(this.plugin.settings.imageStore)) {
141142
case ImageStore.IMGUR.id:
142143
this.drawImgurSetting(parentEL);
143144
break;
145+
case ImageStore.GYAZO.id:
146+
this.drawGyazoSetting(parentEL);
147+
break;
144148
case ImageStore.ALIYUN_OSS.id:
145149
this.drawOSSSetting(parentEL);
146150
break;
@@ -196,6 +200,50 @@ export default class PublishSettingTab extends PluginSettingTab {
196200
return fragment;
197201
}
198202

203+
private drawGyazoSetting(parentEL: HTMLDivElement) {
204+
new Setting(parentEL)
205+
.setName("Access Token")
206+
.setDesc(PublishSettingTab.gyazoTokenSettingDescription())
207+
.addText(text =>
208+
text
209+
.setPlaceholder("Enter access token")
210+
.setValue(this.plugin.settings.gyazoSetting.accessToken)
211+
.onChange(value => this.plugin.settings.gyazoSetting.accessToken = value)
212+
);
213+
214+
new Setting(parentEL)
215+
.setName("Access Policy")
216+
.setDesc("Set image visibility. Choose 'only_me' only if you do not need other people or external sites to access the uploaded image URL.")
217+
.addDropdown(dropdown =>
218+
dropdown
219+
.addOption("anyone", "anyone")
220+
.addOption("only_me", "only_me")
221+
.setValue(this.plugin.settings.gyazoSetting.accessPolicy)
222+
.onChange((value: "anyone" | "only_me") => this.plugin.settings.gyazoSetting.accessPolicy = value)
223+
);
224+
225+
new Setting(parentEL)
226+
.setName("Common Description")
227+
.setDesc("A fixed Gyazo description applied to every upload. Leave it empty to skip the desc field.")
228+
.addText(text =>
229+
text
230+
.setPlaceholder("Enter a shared description (optional)")
231+
.setValue(this.plugin.settings.gyazoSetting.desc)
232+
.onChange(value => this.plugin.settings.gyazoSetting.desc = value)
233+
);
234+
}
235+
236+
private static gyazoTokenSettingDescription() {
237+
const fragment = document.createDocumentFragment();
238+
const a = document.createElement("a");
239+
const url = "https://gyazo.com/oauth/applications";
240+
a.textContent = url;
241+
a.setAttribute("href", url);
242+
fragment.append("Create an application and issue an access token at ");
243+
fragment.append(a);
244+
return fragment;
245+
}
246+
199247
// Aliyun OSS Setting
200248
private drawOSSSetting(parentEL: HTMLDivElement) {
201249
new Setting(parentEL)
@@ -622,4 +670,4 @@ export default class PublishSettingTab extends PluginSettingTab {
622670
.setValue(this.plugin.settings.b2Setting.customDomainName)
623671
.onChange(value => this.plugin.settings.b2Setting.customDomainName = value));
624672
}
625-
}
673+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import {requestUrl} from "obsidian";
2+
import ApiError from "../apiError";
3+
import ImageUploader from "../imageUploader";
4+
5+
const GYAZO_UPLOAD_URL = "https://upload.gyazo.com/api/upload";
6+
7+
export interface GyazoSetting {
8+
accessToken: string;
9+
accessPolicy: "anyone" | "only_me";
10+
desc: string;
11+
}
12+
13+
interface GyazoUploadResponse {
14+
url?: string;
15+
message?: string;
16+
}
17+
18+
export default class GyazoUploader implements ImageUploader {
19+
private readonly setting: GyazoSetting;
20+
21+
constructor(setting: GyazoSetting) {
22+
this.setting = setting;
23+
}
24+
25+
async upload(image: File, fullPath: string): Promise<string> {
26+
const boundary = "----ObsidianGyazo" + Date.now().toString(36);
27+
const body = await this.buildMultipartBody(boundary, image, fullPath);
28+
const response = await requestUrl({
29+
url: GYAZO_UPLOAD_URL,
30+
method: "POST",
31+
headers: {
32+
Authorization: `Bearer ${this.setting.accessToken}`,
33+
"Content-Type": `multipart/form-data; boundary=${boundary}`,
34+
},
35+
body,
36+
});
37+
38+
if (response.status !== 200) {
39+
const errorData = response.json as GyazoUploadResponse | undefined;
40+
const message = errorData?.message || response.text || `Gyazo upload failed (${response.status})`;
41+
throw new ApiError(`Gyazo upload failed (${response.status}): ${message}`);
42+
}
43+
44+
const url = (response.json as GyazoUploadResponse | undefined)?.url;
45+
if (!url) {
46+
throw new ApiError("Gyazo upload succeeded but response missing 'url' field");
47+
}
48+
49+
return url;
50+
}
51+
52+
private async buildMultipartBody(boundary: string, image: File, fullPath: string): Promise<ArrayBuffer> {
53+
const encoder = new TextEncoder();
54+
const parts: Uint8Array[] = [];
55+
const mimeType = image.type || "application/octet-stream";
56+
const filename = this.getFilename(image, fullPath);
57+
58+
parts.push(encoder.encode(
59+
`--${boundary}\r\n` +
60+
`Content-Disposition: form-data; name="imagedata"; filename="${filename}"\r\n` +
61+
`Content-Type: ${mimeType}\r\n\r\n`
62+
));
63+
parts.push(new Uint8Array(await image.arrayBuffer()));
64+
parts.push(encoder.encode("\r\n"));
65+
66+
parts.push(this.encodeField(boundary, "access_policy", this.setting.accessPolicy));
67+
68+
if (this.setting.desc.trim()) {
69+
parts.push(this.encodeField(boundary, "desc", this.setting.desc.trim()));
70+
}
71+
72+
parts.push(encoder.encode(`--${boundary}--\r\n`));
73+
74+
return this.concatParts(parts).buffer;
75+
}
76+
77+
private encodeField(boundary: string, name: string, value: string): Uint8Array {
78+
const encoder = new TextEncoder();
79+
return encoder.encode(
80+
`--${boundary}\r\n` +
81+
`Content-Disposition: form-data; name="${name}"\r\n\r\n` +
82+
`${value}\r\n`
83+
);
84+
}
85+
86+
private concatParts(parts: Uint8Array[]): Uint8Array {
87+
const totalLength = parts.reduce((sum, part) => sum + part.length, 0);
88+
const merged = new Uint8Array(totalLength);
89+
let offset = 0;
90+
91+
for (const part of parts) {
92+
merged.set(part, offset);
93+
offset += part.length;
94+
}
95+
96+
return merged;
97+
}
98+
99+
private getFilename(image: File, fullPath: string): string {
100+
if (image.name) {
101+
return image.name;
102+
}
103+
104+
const pathSegments = fullPath.split(/[\\/]/);
105+
return pathSegments[pathSegments.length - 1] || "upload.bin";
106+
}
107+
}

src/uploader/imageTagProcessor.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {PublishSettings} from "../publish";
55
import UploadProgressModal from "../ui/uploadProgressModal";
66
import {WebImageDownloader} from "./webImageDownloader";
77
import MermaidProcessor from "./mermaidProcessor";
8+
import ImageStore from "../imageStore";
89

910
export const MD_REGEX = /\!\[(.*)\]\((.*?\.(png|jpg|jpeg|gif|svg|webp|excalidraw))\)/g;
1011
export const WIKI_REGEX = /\!\[\[(.*?\.(png|jpg|jpeg|gif|svg|webp|excalidraw))(|.*)?\]\]/g;
@@ -15,38 +16,40 @@ export function isAlreadyHosted(url: string, settings: PublishSettings): boolean
1516
const urlObj = new URL(url);
1617
const hostname = urlObj.hostname;
1718

18-
switch (settings.imageStore) {
19-
case 'imgur':
19+
switch (ImageStore.normalizeId(settings.imageStore)) {
20+
case ImageStore.IMGUR.id:
2021
return hostname.includes('imgur.com') || hostname.includes('i.imgur.com');
21-
case 'github':
22+
case ImageStore.GYAZO.id:
23+
return hostname.includes('gyazo.com') || hostname.includes('i.gyazo.com') || hostname.includes('thumb.gyazo.com');
24+
case ImageStore.GITHUB.id:
2225
if (settings.githubSetting?.repositoryName) {
2326
return url.includes('github.com') &&
2427
url.includes(settings.githubSetting.repositoryName);
2528
}
2629
return hostname.includes('github.com') || hostname.includes('githubusercontent.com');
27-
case 'oss':
30+
case ImageStore.ALIYUN_OSS.id:
2831
if (settings.ossSetting?.customDomainName) {
2932
return hostname.includes(settings.ossSetting.customDomainName);
3033
}
3134
return hostname.includes('aliyuncs.com');
32-
case 's3':
35+
case ImageStore.AWS_S3.id:
3336
if (settings.awsS3Setting?.customDomainName) {
3437
return hostname.includes(settings.awsS3Setting.customDomainName);
3538
}
3639
return hostname.includes('amazonaws.com') || hostname.includes('s3');
37-
case 'cos':
40+
case ImageStore.TENCENTCLOUD_COS.id:
3841
if (settings.cosSetting?.customDomainName) {
3942
return hostname.includes(settings.cosSetting.customDomainName);
4043
}
4144
return hostname.includes('myqcloud.com');
42-
case 'qiniu':
45+
case ImageStore.QINIU_KUDO.id:
4346
if (settings.kodoSetting?.customDomainName) {
4447
return hostname.includes(settings.kodoSetting.customDomainName);
4548
}
4649
return hostname.includes('qiniudn.com') || hostname.includes('clouddn.com');
47-
case 'imagekit':
50+
case ImageStore.ImageKit.id:
4851
return hostname.includes('imagekit.io');
49-
case 'r2':
52+
case ImageStore.CLOUDFLARE_R2.id:
5053
if (settings.r2Setting?.customDomainName) {
5154
return hostname.includes(settings.r2Setting.customDomainName);
5255
}
@@ -361,4 +364,4 @@ export default class ImageTagProcessor {
361364
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
362365
return activeView ? activeView.editor : null;
363366
}
364-
}
367+
}

0 commit comments

Comments
 (0)