feat: implement image hoisting core logic with ImgBB support, Vault integration, and Vitest testing suite

This commit is contained in:
Juan Carlos Garcia Esquivel 2026-04-29 22:36:40 -06:00
parent 42353451fb
commit 14ae1c1c43
No known key found for this signature in database
GPG key ID: AE72E3F3B26486E1
14 changed files with 1534 additions and 23 deletions

1332
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View 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
View 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),
});
}

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

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

View file

@ -0,0 +1,3 @@
export interface ImageUploader {
upload(fileData: ArrayBuffer, fileName: string): Promise<string>;
}

4
src/core/ports/vault.ts Normal file
View file

@ -0,0 +1,4 @@
export interface VaultService {
readBinary(path: string): Promise<ArrayBuffer>;
deleteFile(path: string): Promise<void>;
}

View 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('![](https://imgbb.com/image123.png)');
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('![](https://imgbb.com/image123.png)');
expect(uploadMock).toHaveBeenCalledWith(new ArrayBuffer(0), 'test.png');
});
});

View 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 `![](${url})`;
}
}

View file

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

View file

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

View file

@ -28,6 +28,7 @@
]
},
"include": [
"src/**/*.ts"
"src/**/*.ts",
"vitest.config.ts"
]
}

9
vitest.config.ts Normal file
View file

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.{test,spec}.ts'],
},
});