mirror of
https://github.com/jcmexdev/obsidian-image-hoist.git
synced 2026-07-22 06:51:04 +00:00
feat: implement image hoisting core logic with ImgBB support, Vault integration, and Vitest testing suite
This commit is contained in:
parent
42353451fb
commit
14ae1c1c43
14 changed files with 1534 additions and 23 deletions
1332
package-lock.json
generated
1332
package-lock.json
generated
File diff suppressed because it is too large
Load diff
10
package.json
10
package.json
|
|
@ -8,7 +8,9 @@
|
|||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||
"lint": "eslint ."
|
||||
"lint": "eslint .",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui"
|
||||
},
|
||||
"keywords": [],
|
||||
"license": "0-BSD",
|
||||
|
|
@ -16,7 +18,8 @@
|
|||
"repository": "jcmexdev/obsidian-image-hoist",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.30.1",
|
||||
"@types/node": "^16.11.6",
|
||||
"@types/node": "^25.6.0",
|
||||
"@vitest/ui": "^4.1.5",
|
||||
"esbuild": "0.25.5",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-plugin-obsidianmd": "0.1.9",
|
||||
|
|
@ -25,6 +28,7 @@
|
|||
"obsidian": "^1.12.3",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "8.35.1"
|
||||
"typescript-eslint": "8.35.1",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
src/commands/hoist-images.ts
Normal file
11
src/commands/hoist-images.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { MarkdownView, Notice } from "obsidian";
|
||||
import { ImageProcessor } from "../core/use-cases/image-processor";
|
||||
import ImageHoistPlugin from "../main";
|
||||
|
||||
export async function hoistImagesInCurrentNote(plugin: ImageHoistPlugin, processor: ImageProcessor) {
|
||||
const view = plugin.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (!view) {
|
||||
new Notice("No active markdown note found.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
11
src/commands/index.ts
Normal file
11
src/commands/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { ImageProcessor } from "../core/use-cases/image-processor";
|
||||
import ImageHoistPlugin from "../main";
|
||||
import { hoistImagesInCurrentNote } from "./hoist-images";
|
||||
|
||||
export function registerCommands(plugin: ImageHoistPlugin, processor: ImageProcessor) {
|
||||
plugin.addCommand({
|
||||
id: "hoist-images-in-note",
|
||||
name: "Hoist all images in current note",
|
||||
callback: () => hoistImagesInCurrentNote(plugin, processor),
|
||||
});
|
||||
}
|
||||
54
src/core/adapters/imgbb-uploader.ts
Normal file
54
src/core/adapters/imgbb-uploader.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { requestUrl } from "obsidian";
|
||||
import { ImageUploader } from "../ports/uploader";
|
||||
|
||||
interface ImgBBResponse {
|
||||
data: {
|
||||
url: string;
|
||||
};
|
||||
success: boolean;
|
||||
status: number;
|
||||
error?: {
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class ImgBBUploaderAdapter implements ImageUploader {
|
||||
constructor(private apiKey: string) {}
|
||||
|
||||
async upload(fileData: ArrayBuffer, fileName: string): Promise<string> {
|
||||
if (!this.apiKey) {
|
||||
throw new Error("API key is missing");
|
||||
}
|
||||
|
||||
const base64Image = this.arrayBufferToBase64(fileData);
|
||||
|
||||
const formData = new URLSearchParams();
|
||||
formData.append("image", base64Image);
|
||||
|
||||
|
||||
const response = await requestUrl({
|
||||
url: `https://api.imgbb.com/1/upload?key=${this.apiKey}`,
|
||||
method: "POST",
|
||||
contentType: "application/x-www-form-urlencoded",
|
||||
body: formData.toString(),
|
||||
});
|
||||
|
||||
const json = response.json as ImgBBResponse;
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Upload failed: ${json.error?.message || response.text}`);
|
||||
}
|
||||
|
||||
return json.data.url;
|
||||
}
|
||||
|
||||
private arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
let binary = "";
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const len = bytes.byteLength;
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(bytes[i]!);
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
}
|
||||
21
src/core/adapters/obsidian-vault.ts
Normal file
21
src/core/adapters/obsidian-vault.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { VaultService } from "core/ports/vault";
|
||||
import { App, TFile } from "obsidian";
|
||||
|
||||
export class ObsidianVaultAdapter implements VaultService {
|
||||
constructor(private app: App) {}
|
||||
|
||||
async readBinary(path: string): Promise<ArrayBuffer> {
|
||||
const file = this.app.vault.getAbstractFileByPath(path);
|
||||
if (file instanceof TFile) {
|
||||
return await this.app.vault.readBinary(file);
|
||||
}
|
||||
throw new Error("File not found");
|
||||
}
|
||||
|
||||
async deleteFile(path: string): Promise<void> {
|
||||
const file = this.app.vault.getAbstractFileByPath(path);
|
||||
if (file instanceof TFile) {
|
||||
await this.app.fileManager.trashFile(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/core/ports/uploader.ts
Normal file
3
src/core/ports/uploader.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export interface ImageUploader {
|
||||
upload(fileData: ArrayBuffer, fileName: string): Promise<string>;
|
||||
}
|
||||
4
src/core/ports/vault.ts
Normal file
4
src/core/ports/vault.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export interface VaultService {
|
||||
readBinary(path: string): Promise<ArrayBuffer>;
|
||||
deleteFile(path: string): Promise<void>;
|
||||
}
|
||||
46
src/core/use-cases/image-processor.test.ts
Normal file
46
src/core/use-cases/image-processor.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { VaultService } from 'core/ports/vault';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ImageUploader } from '../ports/uploader';
|
||||
import { ImageProcessor } from './image-processor';
|
||||
|
||||
describe('ImageProcessor', () => {
|
||||
it('should format a remote link correctly after upload', async () => {
|
||||
// Arrange
|
||||
const uploadMock = vi.fn().mockResolvedValue('https://imgbb.com/image123.png');
|
||||
const mockVault: VaultService = {
|
||||
readBinary: vi.fn().mockResolvedValue(new ArrayBuffer(10)),
|
||||
deleteFile: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const mockUploader: ImageUploader = {
|
||||
upload: uploadMock
|
||||
};
|
||||
const processor = new ImageProcessor(mockUploader, mockVault);
|
||||
const dummyData = new ArrayBuffer(0);
|
||||
|
||||
// Act
|
||||
const result = await processor.processImage(dummyData, 'test.png');
|
||||
|
||||
// Assert
|
||||
expect(result).toBe('');
|
||||
expect(uploadMock).toHaveBeenCalledWith(dummyData, 'test.png');
|
||||
});
|
||||
|
||||
it('should read file and upload', async () => {
|
||||
// Arrange
|
||||
const uploadMock = vi.fn().mockResolvedValue('https://imgbb.com/image123.png');
|
||||
const mockVault: VaultService = {
|
||||
readBinary: vi.fn().mockResolvedValue(new ArrayBuffer(10)),
|
||||
deleteFile: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const mockUploader: ImageUploader = {
|
||||
upload: uploadMock
|
||||
};
|
||||
|
||||
const processor = new ImageProcessor(mockUploader, mockVault);
|
||||
const result = await processor.processImage(new ArrayBuffer(0), 'test.png');
|
||||
|
||||
// Assert
|
||||
expect(result).toBe('');
|
||||
expect(uploadMock).toHaveBeenCalledWith(new ArrayBuffer(0), 'test.png');
|
||||
});
|
||||
});
|
||||
14
src/core/use-cases/image-processor.ts
Normal file
14
src/core/use-cases/image-processor.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { ImageUploader } from '../ports/uploader';
|
||||
import { VaultService } from '../ports/vault';
|
||||
|
||||
export class ImageProcessor {
|
||||
constructor(
|
||||
private uploader: ImageUploader,
|
||||
private vault: VaultService
|
||||
) {}
|
||||
|
||||
async processImage(fileData: ArrayBuffer, fileName: string): Promise<string> {
|
||||
const url = await this.uploader.upload(fileData, fileName);
|
||||
return ``;
|
||||
}
|
||||
}
|
||||
33
src/main.ts
33
src/main.ts
|
|
@ -1,16 +1,35 @@
|
|||
import { Plugin } from "obsidian";
|
||||
import { ImgBBUploaderAdapter } from "core/adapters/imgbb-uploader";
|
||||
import { ObsidianVaultAdapter } from "core/adapters/obsidian-vault";
|
||||
import { ImageProcessor } from "core/use-cases/image-processor";
|
||||
import { Notice, Plugin } from "obsidian";
|
||||
import { registerCommands } from "./commands";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
ImgHoistSettings,
|
||||
ImgHoistSettingTab
|
||||
ImageHoistSettings,
|
||||
ImageHoistSettingTab
|
||||
} from "./settings";
|
||||
|
||||
export default class ImgHoistPlugin extends Plugin {
|
||||
settings: ImgHoistSettings;
|
||||
export default class ImageHoistPlugin extends Plugin {
|
||||
settings: ImageHoistSettings;
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
this.addSettingTab(new ImgHoistSettingTab(this.app, this));
|
||||
this.addSettingTab(new ImageHoistSettingTab(this.app, this));
|
||||
|
||||
const vaultAdapter = new ObsidianVaultAdapter(this.app);
|
||||
|
||||
// Fallback to settings if secretStorage is not yet populated
|
||||
const apiKey = this.settings.imgbbApiKey;
|
||||
|
||||
if (!apiKey) {
|
||||
new Notice("ImgBB API key is missing. Please configure it in the plugin settings.");
|
||||
// We still register commands, but they might fail if the key is missing later
|
||||
}
|
||||
|
||||
const uploaderAdapter = new ImgBBUploaderAdapter(apiKey);
|
||||
const processor = new ImageProcessor(uploaderAdapter, vaultAdapter);
|
||||
|
||||
registerCommands(this, processor);
|
||||
}
|
||||
|
||||
onunload() {}
|
||||
|
|
@ -20,7 +39,7 @@ export default class ImgHoistPlugin extends Plugin {
|
|||
this.settings = Object.assign(
|
||||
{},
|
||||
DEFAULT_SETTINGS,
|
||||
(await this.loadData()) as Partial<ImgHoistSettings>,
|
||||
(await this.loadData()) as Partial<ImageHoistSettings>,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
import { App, Notice, PluginSettingTab, SecretComponent, Setting } from "obsidian";
|
||||
import ImgHoistPlugin from "./main";
|
||||
|
||||
export interface ImgHoistSettings {
|
||||
export interface ImageHoistSettings {
|
||||
imgbbApiKey: string;
|
||||
deleteAfterUpload: boolean;
|
||||
bulkUploadLimit: number;
|
||||
uploadCache: Record<string, string>;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: ImgHoistSettings = {
|
||||
export const DEFAULT_SETTINGS: ImageHoistSettings = {
|
||||
imgbbApiKey: "",
|
||||
deleteAfterUpload: true,
|
||||
bulkUploadLimit: 10,
|
||||
uploadCache: {},
|
||||
};
|
||||
|
||||
export class ImgHoistSettingTab extends PluginSettingTab {
|
||||
export class ImageHoistSettingTab extends PluginSettingTab {
|
||||
plugin: ImgHoistPlugin;
|
||||
|
||||
constructor(app: App, plugin: ImgHoistPlugin) {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
"src/**/*.ts",
|
||||
"vitest.config.ts"
|
||||
]
|
||||
}
|
||||
9
vitest.config.ts
Normal file
9
vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.ts'],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue