chore: update template

This commit is contained in:
Michael Naumov 2026-06-09 06:22:44 -06:00
parent dc4a4477af
commit 753e8c44e8
42 changed files with 6239 additions and 5497 deletions

3
.czrc
View file

@ -1,3 +0,0 @@
{
"path": "./node_modules/cz-conventional-changelog"
}

View file

@ -0,0 +1,24 @@
name: Attest Release Assets
on:
release:
types: [published]
permissions:
id-token: write
contents: read
attestations: write
jobs:
attest:
runs-on: ubuntu-latest
steps:
- name: Download release assets
env:
GH_TOKEN: ${{ github.token }}
run: gh release download "${{ github.event.release.tag_name }}" --repo "${{ github.repository }}" --dir assets
- name: Generate artifact attestation
uses: actions/attest@v4
with:
subject-path: assets/*

1
.gitignore vendored
View file

@ -25,3 +25,4 @@ dist
.env
/tsconfig.tsbuildinfo
.claude/settings.local.json
coverage

71
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,71 @@
# Contributing
Contributions are welcome! Here's how to get started.
## Prerequisites
- [Node.js](https://nodejs.org/) (latest LTS recommended)
- npm (comes with Node.js)
## Setup
```bash
git clone https://github.com/mnaoumov/obsidian-insert-multiple-attachments.git
cd obsidian-insert-multiple-attachments
npm install
```
## Development Workflow
### Build
```bash
npm run build
```
### Dev Mode
```bash
npm run dev
```
### Commit
This project uses [Conventional Commits](https://www.conventionalcommits.org/). Use the interactive commit prompt:
```bash
npm run commit
```
### Lint
```bash
npm run lint
npm run lint:fix
```
### Format
```bash
npm run format:check
npm run format
```
### Spellcheck
```bash
npm run spellcheck
```
### Test
```bash
npm run test
npm run test:coverage
```
## Pull Requests
- Base your PR on the `master` branch.
- Ensure all checks pass (`lint`, `format:check`, `spellcheck`, `test`).
- Use [Conventional Commits](https://www.conventionalcommits.org/) for your commit messages.

View file

@ -3,6 +3,7 @@
[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?logo=buy-me-a-coffee&logoColor=black)](https://www.buymeacoffee.com/mnaoumov)
[![GitHub release](https://img.shields.io/github/v/release/mnaoumov/obsidian-insert-multiple-attachments)](https://github.com/mnaoumov/obsidian-insert-multiple-attachments/releases)
[![GitHub downloads](https://img.shields.io/github/downloads/mnaoumov/obsidian-insert-multiple-attachments/total)](https://github.com/mnaoumov/obsidian-insert-multiple-attachments/releases)
[![Coverage: 100%](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/mnaoumov/obsidian-insert-multiple-attachments)
This is a plugin for [Obsidian](https://obsidian.md/) that allows to insert multiple attachments at a time.

View file

@ -1,6 +1,7 @@
{
"version": "0.2",
"ignorePaths": [
"coverage",
"dist",
"node_modules",
"tsconfig.tsbuildinfo"

View file

@ -1,11 +1,11 @@
{
"id": "insert-multiple-attachments",
"name": "Insert Multiple Attachments",
"version": "1.3.7",
"version": "1.3.8",
"description": "Allows to insert multiple attachments at a time",
"author": "mnaoumov",
"authorUrl": "https://github.com/mnaoumov/",
"isDesktopOnly": false,
"fundingUrl": "https://www.buymeacoffee.com/mnaoumov",
"minAppVersion": "1.12.4"
"minAppVersion": "1.12.7"
}

9802
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,14 @@
"name": "insert-multiple-attachments",
"version": "1.3.7",
"description": "Allows to insert multiple attachments at a time",
"keywords": [],
"homepage": "https://github.com/mnaoumov/obsidian-insert-multiple-attachments#readme",
"bugs": "https://github.com/mnaoumov/obsidian-insert-multiple-attachments/issues",
"repository": "git+https://github.com/mnaoumov/obsidian-insert-multiple-attachments.git",
"license": "MIT",
"author": "mnaoumov",
"type": "module",
"main": "src/main.ts",
"scripts": {
"build": "jiti scripts/build.ts",
"build:clean": "jiti scripts/build-clean.ts",
@ -17,36 +25,40 @@
"lint:md:fix": "jiti scripts/lint-md-fix.ts",
"prepare": "jiti scripts/prepare.ts",
"spellcheck": "jiti scripts/spellcheck.ts",
"test": "jiti scripts/test.ts",
"test:coverage": "jiti scripts/test-coverage.ts",
"test:integration": "jiti scripts/test-integration.ts",
"test:integration:android": "jiti scripts/test-integration-android.ts",
"test:integration:desktop": "jiti scripts/test-integration-desktop.ts",
"test:integration:no-app": "jiti scripts/test-integration-no-app.ts",
"test:watch": "jiti scripts/test-watch.ts",
"version": "jiti scripts/version.ts"
},
"keywords": [],
"author": "mnaoumov",
"license": "MIT",
"devDependencies": {
"@commitlint/cli": "latest",
"@commitlint/config-conventional": "latest",
"@commitlint/types": "latest",
"@total-typescript/ts-reset": "latest",
"@tsconfig/strictest": "latest",
"@types/node": "25.0.3",
"better-typescript-lib": "latest",
"commitizen": "latest",
"cz-conventional-changelog": "latest",
"eslint": "latest",
"husky": "latest",
"jiti": "latest",
"nano-staged": "latest",
"obsidian": "latest",
"obsidian-dev-utils": "latest",
"obsidian-typings": "obsidian-public-latest"
},
"overrides": {
"@antfu/utils": "9.2.0",
"boolean": "npm:dry-uninstall",
"dompurify": "3.0.1",
"eslint": "latest",
"type-fest": "latest",
"typescript": "latest"
"eslint": "$eslint",
"type-fest": "^5.6.0",
"typescript": "^6.0.3"
},
"type": "module"
"devDependencies": {
"@commitlint/cli": "^21.0.2",
"@commitlint/config-conventional": "^21.0.2",
"@commitlint/types": "^21.0.1",
"@obsidian-typings/obsidian-public-latest": "^6.13.0",
"@total-typescript/ts-reset": "^0.6.1",
"@tsconfig/strictest": "^2.0.8",
"@types/node": "^25.9.2",
"@vitest/coverage-v8": "^4.1.8",
"better-typescript-lib": "^2.12.0",
"czg": "^1.13.1",
"eslint": "^10.4.1",
"husky": "^9.1.7",
"jiti": "^2.7.0",
"jsdom": "^29.1.1",
"nano-staged": "^1.0.2",
"obsidian": "^1.13.0",
"obsidian-dev-utils": "^70.0.1",
"obsidian-integration-testing": "^4.2.3",
"obsidian-test-mocks": "^3.1.0",
"vitest": "^4.1.8"
}
}

View file

@ -1,3 +1,3 @@
import { exec } from 'obsidian-dev-utils/script-utils/exec';
await exec('cz');
await exec('czg');

11
scripts/test-coverage.ts Normal file
View file

@ -0,0 +1,11 @@
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
import { testCoverage } from 'obsidian-dev-utils/script-utils/test-runners/vitest';
const FULL_COVERAGE_PERCENT = 100;
await wrapCliTask(() =>
testCoverage({
minCoverageInPercents: FULL_COVERAGE_PERCENT,
projects: ['unit-tests']
})
);

View file

@ -0,0 +1,8 @@
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
import { test } from 'obsidian-dev-utils/script-utils/test-runners/vitest';
await wrapCliTask(() =>
test({
projects: ['integration-tests:android']
})
);

View file

@ -0,0 +1,8 @@
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
import { test } from 'obsidian-dev-utils/script-utils/test-runners/vitest';
await wrapCliTask(() =>
test({
projects: ['integration-tests:desktop']
})
);

View file

@ -0,0 +1,8 @@
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
import { test } from 'obsidian-dev-utils/script-utils/test-runners/vitest';
await wrapCliTask(() =>
test({
projects: ['integration-tests:no-app']
})
);

View file

@ -0,0 +1,14 @@
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
import { test } from 'obsidian-dev-utils/script-utils/test-runners/vitest';
await wrapCliTask(async () => {
await test({
projects: ['integration-tests:no-app']
});
await test({
projects: ['integration-tests:android']
});
await test({
projects: ['integration-tests:desktop']
});
});

8
scripts/test-watch.ts Normal file
View file

@ -0,0 +1,8 @@
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
import { testWatch } from 'obsidian-dev-utils/script-utils/test-runners/vitest';
await wrapCliTask(() =>
testWatch({
projects: ['unit-tests']
})
);

8
scripts/test.ts Normal file
View file

@ -0,0 +1,8 @@
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
import { test } from 'obsidian-dev-utils/script-utils/test-runners/vitest';
await wrapCliTask(() =>
test({
projects: ['unit-tests']
})
);

88
scripts/vitest-config.ts Normal file
View file

@ -0,0 +1,88 @@
import { defineConfig } from 'vitest/config';
const SHARED_EXCLUDE = ['node_modules', 'dist'];
const BIG_TIMEOUT_IN_MILLISECONDS = 30_000;
const ANDROID_TIMEOUT_IN_MILLISECONDS = 60_000;
const HOOK_TIMEOUT_MULTIPLIER = 4;
export const config = defineConfig({
test: {
coverage: {
exclude: [
'src/**/*.test.ts'
],
include: ['src/**/*.ts'],
provider: 'v8',
reporter: ['text', 'lcov', 'html'],
reportsDirectory: './coverage'
},
exclude: ['node_modules', 'dist'],
globals: false,
include: ['src/**/*.test.ts'],
passWithNoTests: true,
projects: [
{
resolve: {
alias: {
obsidian: 'obsidian-test-mocks/obsidian'
}
},
test: {
environment: 'jsdom',
exclude: [...SHARED_EXCLUDE, 'src/**/*.integration.test.ts'],
execArgv: ['--no-webstorage'],
include: ['src/**/*.test.ts'],
name: 'unit-tests',
server: {
deps: {
inline: ['@obsidian-typings', 'obsidian-dev-utils']
}
},
setupFiles: [
'obsidian-test-mocks/vitest-setup',
'obsidian-test-mocks/obsidian-typings/vitest-setup'
]
}
},
{
test: {
environment: 'node',
fileParallelism: false,
hookTimeout: BIG_TIMEOUT_IN_MILLISECONDS * HOOK_TIMEOUT_MULTIPLIER,
include: ['src/**/*.no-app.integration.test.ts'],
name: 'integration-tests:no-app',
testTimeout: BIG_TIMEOUT_IN_MILLISECONDS
}
},
{
test: {
environment: 'node',
fileParallelism: false,
globalSetup: ['obsidian-integration-testing/vitest-global-setup'],
hookTimeout: BIG_TIMEOUT_IN_MILLISECONDS * HOOK_TIMEOUT_MULTIPLIER,
include: ['src/**/*.desktop.integration.test.ts'],
name: 'integration-tests:desktop',
testTimeout: BIG_TIMEOUT_IN_MILLISECONDS
}
},
{
test: {
environment: 'node',
environmentOptions: {
obsidianTransport: {
appiumUrl: 'http://localhost:4723',
avdName: 'obsidian_test',
type: 'obsidian-android-appium'
}
},
fileParallelism: false,
globalSetup: ['obsidian-integration-testing/vitest-global-setup'],
hookTimeout: ANDROID_TIMEOUT_IN_MILLISECONDS * HOOK_TIMEOUT_MULTIPLIER,
include: ['src/**/*.android.integration.test.ts'],
name: 'integration-tests:android',
testTimeout: ANDROID_TIMEOUT_IN_MILLISECONDS
}
}
]
}
});

View file

@ -1,38 +0,0 @@
import type {
Editor,
MarkdownFileInfo
} from 'obsidian';
import {
EditorCommandBase,
EditorCommandInvocationBase
} from 'obsidian-dev-utils/obsidian/commands/editor-command-base';
import type { Plugin } from '../Plugin.ts';
import { InsertAttachmentsControl } from '../InsertAttachmentsControl.ts';
class InvokeCommandInvocation extends EditorCommandInvocationBase<Plugin> {
public constructor(plugin: Plugin, editor: Editor, ctx: MarkdownFileInfo) {
super(plugin, editor, ctx);
}
public override async execute(): Promise<void> {
new InsertAttachmentsControl(this.plugin, this.editor);
}
}
export class InvokeCommand extends EditorCommandBase<Plugin> {
public constructor(plugin: Plugin) {
super({
icon: 'lucide-paperclip',
id: 'invoke',
name: 'Invoke',
plugin
});
}
protected override createEditorCommandInvocation(editor: Editor, ctx: MarkdownFileInfo): EditorCommandInvocationBase<Plugin> {
return new InvokeCommandInvocation(this.plugin, editor, ctx);
}
}

View file

@ -1,22 +0,0 @@
import { PluginBase } from 'obsidian-dev-utils/obsidian/plugin/plugin-base';
import type { PluginTypes } from './PluginTypes.ts';
import { InvokeCommand } from './Commands/InvokeCommand.ts';
import { PluginSettingsManager } from './PluginSettingsManager.ts';
import { PluginSettingsTab } from './PluginSettingsTab.ts';
export class Plugin extends PluginBase<PluginTypes> {
protected override createSettingsManager(): PluginSettingsManager {
return new PluginSettingsManager(this);
}
protected override createSettingsTab(): null | PluginSettingsTab {
return new PluginSettingsTab(this);
}
protected override async onLayoutReady(): Promise<void> {
await super.onLayoutReady();
new InvokeCommand(this).register();
}
}

View file

@ -1,23 +0,0 @@
import { PluginSettingsManagerBase } from 'obsidian-dev-utils/obsidian/plugin/plugin-settings-manager-base';
import type { PluginTypes } from './PluginTypes.ts';
import { PluginSettings } from './PluginSettings.ts';
class LegacySettings {
public shouldInsertDoubleLinesBetweenAttachmentLinks = true;
}
export class PluginSettingsManager extends PluginSettingsManagerBase<PluginTypes> {
public createDefaultSettings(): PluginSettings {
return new PluginSettings();
}
protected override registerLegacySettingsConverters(): void {
this.registerLegacySettingsConverter(LegacySettings, (legacySettings) => {
if (legacySettings.shouldInsertDoubleLinesBetweenAttachmentLinks !== undefined) {
legacySettings.attachmentLinksDelimiter = legacySettings.shouldInsertDoubleLinesBetweenAttachmentLinks ? '\n\n' : '\n';
}
});
}
}

View file

@ -1,13 +0,0 @@
import type { PluginTypesBase } from 'obsidian-dev-utils/obsidian/plugin/plugin-types-base';
import type { Plugin } from './Plugin.ts';
import type { PluginSettings } from './PluginSettings.ts';
import type { PluginSettingsManager } from './PluginSettingsManager.ts';
import type { PluginSettingsTab } from './PluginSettingsTab.ts';
export interface PluginTypes extends PluginTypesBase {
plugin: Plugin;
pluginSettings: PluginSettings;
pluginSettingsManager: PluginSettingsManager;
pluginSettingsTab: PluginSettingsTab;
}

View file

@ -0,0 +1,114 @@
import type {
App,
Editor,
MarkdownFileInfo
} from 'obsidian';
import type { ReadonlyDeep } from 'type-fest';
import { strictProxy } from 'obsidian-dev-utils/strict-proxy';
import {
describe,
expect,
it,
vi
} from 'vitest';
import type { PluginSettings } from '../plugin-settings.ts';
import { InvokeCommandHandler } from './invoke-command-handler.ts';
interface InternalExecuteEditor {
executeEditor(editor: Editor, ctx: MarkdownFileInfo): void;
}
interface MockEditorCommandHandlerConstructorParams {
readonly icon: string;
readonly id: string;
readonly name: string;
}
const hoisted = vi.hoisted(() => ({
mockInsertAttachmentsControlConstructor: vi.fn()
}));
vi.mock('../insert-attachments-control.ts', () => ({
InsertAttachmentsControl: hoisted.mockInsertAttachmentsControlConstructor
}));
vi.mock('obsidian-dev-utils/obsidian/command-handlers/editor-command-handler', () => ({
EditorCommandHandler: class MockEditorCommandHandler {
public readonly icon: string;
public readonly id: string;
public readonly name: string;
public constructor(params: MockEditorCommandHandlerConstructorParams) {
this.icon = params.icon;
this.id = params.id;
this.name = params.name;
}
}
}));
describe('InvokeCommandHandler', () => {
it('should create an instance', (): void => {
const handler = new InvokeCommandHandler({
app: strictProxy<App>({}),
getPluginSettings: (): ReadonlyDeep<PluginSettings> => strictProxy<PluginSettings>({}),
pluginName: 'test-plugin'
});
expect(handler).toBeInstanceOf(InvokeCommandHandler);
});
it('should set correct id', (): void => {
const handler = new InvokeCommandHandler({
app: strictProxy<App>({}),
getPluginSettings: (): ReadonlyDeep<PluginSettings> => strictProxy<PluginSettings>({}),
pluginName: 'test-plugin'
});
expect(handler.id).toBe('invoke');
});
it('should set correct icon', (): void => {
const handler = new InvokeCommandHandler({
app: strictProxy<App>({}),
getPluginSettings: (): ReadonlyDeep<PluginSettings> => strictProxy<PluginSettings>({}),
pluginName: 'test-plugin'
});
expect(handler.icon).toBe('lucide-paperclip');
});
it('should set correct name', (): void => {
const handler = new InvokeCommandHandler({
app: strictProxy<App>({}),
getPluginSettings: (): ReadonlyDeep<PluginSettings> => strictProxy<PluginSettings>({}),
pluginName: 'test-plugin'
});
expect(handler.name).toBe('Invoke');
});
it('should create InsertAttachmentsControl when executeEditor is called', (): void => {
const mockApp = strictProxy<App>({});
const mockSettings = strictProxy<PluginSettings>({});
const handler = new InvokeCommandHandler({
app: mockApp,
getPluginSettings: (): ReadonlyDeep<PluginSettings> => mockSettings,
pluginName: 'test-plugin'
});
const mockEditor = strictProxy<Editor>({});
const mockCtx = strictProxy<MarkdownFileInfo>({});
// eslint-disable-next-line no-restricted-syntax -- test helper accesses protected executeEditor method.
(handler as unknown as InternalExecuteEditor).executeEditor(mockEditor, mockCtx);
expect(hoisted.mockInsertAttachmentsControlConstructor).toHaveBeenCalledWith({
app: mockApp,
editor: mockEditor,
pluginSettings: mockSettings
});
});
});

View file

@ -0,0 +1,55 @@
import type {
App,
Editor,
MarkdownFileInfo
} from 'obsidian';
import type { ReadonlyDeep } from 'type-fest';
import { EditorCommandHandler } from 'obsidian-dev-utils/obsidian/command-handlers/editor-command-handler';
import type { PluginSettings } from '../plugin-settings.ts';
import { InsertAttachmentsControl } from '../insert-attachments-control.ts';
/**
* Params for creating an {@link InvokeCommandHandler}.
*/
export interface InvokeCommandHandlerConstructorParams {
/**
* The Obsidian app instance.
*/
readonly app: App;
/**
* A function that returns the current plugin settings.
*/
getPluginSettings(): ReadonlyDeep<PluginSettings>;
/**
* The name of the plugin.
*/
readonly pluginName: string;
}
export class InvokeCommandHandler extends EditorCommandHandler {
private readonly app: App;
private readonly getPluginSettingsFn: () => ReadonlyDeep<PluginSettings>;
public constructor(params: InvokeCommandHandlerConstructorParams) {
super({
icon: 'lucide-paperclip',
id: 'invoke',
name: 'Invoke'
});
this.app = params.app;
this.getPluginSettingsFn = params.getPluginSettings.bind(params);
}
protected override executeEditor(editor: Editor, _ctx: MarkdownFileInfo): void {
new InsertAttachmentsControl({
app: this.app,
editor,
pluginSettings: this.getPluginSettingsFn()
});
}
}

View file

@ -0,0 +1,404 @@
import type {
App,
Editor
} from 'obsidian';
import type { ReadonlyDeep } from 'type-fest';
import { castTo } from 'obsidian-dev-utils/object-utils';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import type { PluginSettings } from './plugin-settings.ts';
import { InsertAttachmentsControl } from './insert-attachments-control.ts';
const hoisted = vi.hoisted(() => ({
mockBasename: vi.fn(),
mockConvertAsyncToSync: vi.fn(),
mockExtname: vi.fn()
}));
vi.mock('obsidian-dev-utils/async', () => ({
convertAsyncToSync: hoisted.mockConvertAsyncToSync
}));
vi.mock('obsidian-dev-utils/path', () => ({
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-unsafe-return -- mock factory delegates to hoisted fn.
basename: (...args: unknown[]) => hoisted.mockBasename(...args),
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-unsafe-return -- mock factory delegates to hoisted fn.
extname: (...args: unknown[]) => hoisted.mockExtname(...args)
}));
interface CreateControlParams {
readonly app?: App;
readonly settings?: ReadonlyDeep<PluginSettings>;
}
interface MockActiveFile {
readonly path: string;
}
interface MockAppOverrides {
readonly activeFile?: MockActiveFile | null;
readonly generateLink?: string;
}
interface MockFile {
arrayBuffer(): Promise<ArrayBuffer>;
readonly name: string;
}
interface MockFileInput {
addEventListener: ReturnType<typeof vi.fn>;
click: ReturnType<typeof vi.fn>;
detach: ReturnType<typeof vi.fn>;
files: MockFileList | null;
focus: ReturnType<typeof vi.fn>;
triggerChange(): Promise<void>;
}
interface MockFileList {
[Symbol.iterator](): Iterator<MockFile>;
}
let mockFileEl: MockFileInput;
let capturedChangeHandler: (() => Promise<void>) | undefined;
let mockDocumentAddEventListener: ReturnType<typeof vi.fn>;
let mockDocumentRemoveEventListener: ReturnType<typeof vi.fn>;
let mockSetTimeout: ReturnType<typeof vi.fn>;
let mockClearTimeout: ReturnType<typeof vi.fn>;
function createControl(params?: CreateControlParams): InsertAttachmentsControl {
const app = params?.app ?? createMockApp();
const editor = castTo<Editor>({ replaceSelection: vi.fn() });
const settings = params?.settings ?? createMockSettings();
return new InsertAttachmentsControl({
app,
editor,
pluginSettings: settings
});
}
function createMockApp(overrides?: MockAppOverrides): App {
const activeFile = 'activeFile' in (overrides ?? {}) ? overrides?.activeFile : { path: 'note.md' };
const generateLink = overrides?.generateLink ?? '[[attachment.png]]';
return castTo<App>({
fileManager: {
generateMarkdownLink: vi.fn().mockReturnValue(generateLink)
},
saveAttachment: vi.fn().mockResolvedValue({ path: 'attachments/attachment.png' }),
workspace: {
getActiveFile: vi.fn().mockReturnValue(activeFile)
}
});
}
function createMockFileEl(): MockFileInput {
const el: MockFileInput = {
addEventListener: vi.fn(),
click: vi.fn(),
detach: vi.fn(),
files: null,
focus: vi.fn(),
triggerChange: async () => {
if (capturedChangeHandler) {
await capturedChangeHandler();
}
}
};
return el;
}
function createMockSettings(overrides?: Partial<PluginSettings>): ReadonlyDeep<PluginSettings> {
return {
attachmentLinksDelimiter: '\n\n',
attachmentLinksPrefix: '',
attachmentLinksSuffix: '',
...overrides
};
}
describe('InsertAttachmentsControl', () => {
beforeEach(() => {
capturedChangeHandler = undefined;
mockDocumentAddEventListener = vi.fn();
mockDocumentRemoveEventListener = vi.fn();
mockSetTimeout = vi.fn().mockReturnValue(42);
mockClearTimeout = vi.fn();
mockFileEl = createMockFileEl();
vi.spyOn(activeDocument.body, 'createEl').mockReturnValue(castTo<HTMLInputElement>(mockFileEl));
Object.defineProperty(activeDocument, 'addEventListener', {
configurable: true,
value: mockDocumentAddEventListener
});
Object.defineProperty(activeDocument, 'removeEventListener', {
configurable: true,
value: mockDocumentRemoveEventListener
});
Object.defineProperty(window, 'setTimeout', {
configurable: true,
value: mockSetTimeout
});
Object.defineProperty(window, 'clearTimeout', {
configurable: true,
value: mockClearTimeout
});
hoisted.mockConvertAsyncToSync.mockImplementation((fn: () => Promise<void>) => {
capturedChangeHandler = fn;
return fn;
});
hoisted.mockBasename.mockImplementation((name: string, ext?: string) => {
if (ext) {
return name.replace(ext, '');
}
return name;
});
hoisted.mockExtname.mockReturnValue('.png');
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
it('should create a file input element on construction', (): void => {
createControl();
expect(activeDocument.body.createEl).toHaveBeenCalledWith(
'input',
expect.objectContaining({
cls: 'insert-multiple-attachments',
type: 'file'
})
);
});
it('should focus and click the file input on construction', (): void => {
createControl();
expect(mockFileEl.focus).toHaveBeenCalled();
expect(mockFileEl.click).toHaveBeenCalled();
});
it('should register focus and click handlers after a setTimeout', (): void => {
createControl();
const EXPECTED_SETTIMEOUT_CALLS = 1;
expect(mockSetTimeout).toHaveBeenCalledTimes(EXPECTED_SETTIMEOUT_CALLS);
const setTimeoutCallback = mockSetTimeout.mock.calls[0]?.[0] as (() => void) | undefined;
expect(setTimeoutCallback).toBeDefined();
setTimeoutCallback?.();
expect(mockDocumentAddEventListener).toHaveBeenCalledWith('focus', expect.any(Function));
expect(mockDocumentAddEventListener).toHaveBeenCalledWith('click', expect.any(Function));
});
it('should detach file element when no files are selected', async (): Promise<void> => {
createControl();
mockFileEl.files = null;
await mockFileEl.triggerChange();
expect(mockFileEl.detach).toHaveBeenCalled();
});
it('should detach file element when no active file', async (): Promise<void> => {
const app = createMockApp({ activeFile: null });
createControl({ app });
const mockFiles: MockFile[] = [{ arrayBuffer: vi.fn(), name: 'photo.png' }];
mockFileEl.files = {
[Symbol.iterator]: (): IterableIterator<MockFile> => mockFiles[Symbol.iterator]()
};
await mockFileEl.triggerChange();
expect(mockFileEl.detach).toHaveBeenCalled();
});
it('should insert link for each selected file', async (): Promise<void> => {
const app = createMockApp({ generateLink: '![[attachment.png]]' });
const editor = castTo<Editor>({ replaceSelection: vi.fn() });
const settings = createMockSettings();
new InsertAttachmentsControl({ app, editor, pluginSettings: settings });
const mockData = new ArrayBuffer(8);
const mockFiles: MockFile[] = [
{ arrayBuffer: vi.fn().mockResolvedValue(mockData), name: 'photo.png' }
];
mockFileEl.files = {
[Symbol.iterator]: (): IterableIterator<MockFile> => mockFiles[Symbol.iterator]()
};
await mockFileEl.triggerChange();
expect(editor.replaceSelection).toHaveBeenCalledWith('![[attachment.png]]');
});
it('should prefix link with ! when it does not start with !', async (): Promise<void> => {
const app = createMockApp({ generateLink: '[[attachment.png]]' });
const editor = castTo<Editor>({ replaceSelection: vi.fn() });
const settings = createMockSettings();
new InsertAttachmentsControl({ app, editor, pluginSettings: settings });
const mockData = new ArrayBuffer(8);
const mockFiles: MockFile[] = [
{ arrayBuffer: vi.fn().mockResolvedValue(mockData), name: 'photo.png' }
];
mockFileEl.files = {
[Symbol.iterator]: (): IterableIterator<MockFile> => mockFiles[Symbol.iterator]()
};
await mockFileEl.triggerChange();
expect(editor.replaceSelection).toHaveBeenCalledWith('![[attachment.png]]');
});
it('should not double-prefix ! when link already starts with !', async (): Promise<void> => {
const app = createMockApp({ generateLink: '![[attachment.png]]' });
const editor = castTo<Editor>({ replaceSelection: vi.fn() });
const settings = createMockSettings();
new InsertAttachmentsControl({ app, editor, pluginSettings: settings });
const mockData = new ArrayBuffer(8);
const mockFiles: MockFile[] = [
{ arrayBuffer: vi.fn().mockResolvedValue(mockData), name: 'photo.png' }
];
mockFileEl.files = {
[Symbol.iterator]: (): IterableIterator<MockFile> => mockFiles[Symbol.iterator]()
};
await mockFileEl.triggerChange();
expect(editor.replaceSelection).toHaveBeenCalledWith('![[attachment.png]]');
});
it('should apply prefix and suffix from settings', async (): Promise<void> => {
const app = createMockApp({ generateLink: '![[photo.png]]' });
const editor = castTo<Editor>({ replaceSelection: vi.fn() });
const settings = createMockSettings({
attachmentLinksPrefix: 'START\n',
attachmentLinksSuffix: '\nEND'
});
new InsertAttachmentsControl({ app, editor, pluginSettings: settings });
const mockData = new ArrayBuffer(8);
const mockFiles: MockFile[] = [
{ arrayBuffer: vi.fn().mockResolvedValue(mockData), name: 'photo.png' }
];
mockFileEl.files = {
[Symbol.iterator]: (): IterableIterator<MockFile> => mockFiles[Symbol.iterator]()
};
await mockFileEl.triggerChange();
expect(editor.replaceSelection).toHaveBeenCalledWith('START\n![[photo.png]]\nEND');
});
it('should join multiple links with delimiter', async (): Promise<void> => {
const app = createMockApp({ generateLink: '![[file.png]]' });
const editor = castTo<Editor>({ replaceSelection: vi.fn() });
const settings = createMockSettings({ attachmentLinksDelimiter: '---' });
new InsertAttachmentsControl({ app, editor, pluginSettings: settings });
const mockData = new ArrayBuffer(8);
const mockFiles: MockFile[] = [
{ arrayBuffer: vi.fn().mockResolvedValue(mockData), name: 'file1.png' },
{ arrayBuffer: vi.fn().mockResolvedValue(mockData), name: 'file2.png' }
];
mockFileEl.files = {
[Symbol.iterator]: (): IterableIterator<MockFile> => mockFiles[Symbol.iterator]()
};
await mockFileEl.triggerChange();
expect(editor.replaceSelection).toHaveBeenCalledWith('![[file.png]]---![[file.png]]');
});
it('should detach file element after inserting links', async (): Promise<void> => {
const app = createMockApp();
createControl({ app });
const mockData = new ArrayBuffer(8);
const mockFiles: MockFile[] = [
{ arrayBuffer: vi.fn().mockResolvedValue(mockData), name: 'photo.png' }
];
mockFileEl.files = {
[Symbol.iterator]: (): IterableIterator<MockFile> => mockFiles[Symbol.iterator]()
};
await mockFileEl.triggerChange();
expect(mockFileEl.detach).toHaveBeenCalled();
});
it('should remove handlers when change fires', async (): Promise<void> => {
createControl();
const setTimeoutCallback = mockSetTimeout.mock.calls[0]?.[0] as (() => void) | undefined;
setTimeoutCallback?.();
mockFileEl.files = null;
await mockFileEl.triggerChange();
expect(mockDocumentRemoveEventListener).toHaveBeenCalledWith('focus', expect.any(Function));
expect(mockDocumentRemoveEventListener).toHaveBeenCalledWith('click', expect.any(Function));
});
it('should schedule detach after focus or click event', (): void => {
createControl();
const setTimeoutCallback = mockSetTimeout.mock.calls[0]?.[0] as (() => void) | undefined;
setTimeoutCallback?.();
const focusHandler = mockDocumentAddEventListener.mock.calls.find(
(call: unknown[]) => call[0] === 'focus'
)?.[1] as (() => void) | undefined;
expect(focusHandler).toBeDefined();
mockSetTimeout.mockClear();
focusHandler?.();
const TIMEOUT_IN_MILLISECONDS = 5000;
expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), TIMEOUT_IN_MILLISECONDS);
});
it('should clear existing timeout when focus or click fires again', (): void => {
createControl();
const setTimeoutCallback = mockSetTimeout.mock.calls[0]?.[0] as (() => void) | undefined;
setTimeoutCallback?.();
const focusHandler = mockDocumentAddEventListener.mock.calls.find(
(call: unknown[]) => call[0] === 'focus'
)?.[1] as (() => void) | undefined;
expect(focusHandler).toBeDefined();
focusHandler?.();
expect(mockClearTimeout).toHaveBeenCalled();
});
});

View file

@ -2,6 +2,7 @@ import type {
App,
Editor
} from 'obsidian';
import type { ReadonlyDeep } from 'type-fest';
import { convertAsyncToSync } from 'obsidian-dev-utils/async';
import {
@ -9,18 +10,41 @@ import {
extname
} from 'obsidian-dev-utils/path';
import type { Plugin } from './Plugin.ts';
import type { PluginSettings } from './plugin-settings.ts';
/**
* Params for creating an {@link InsertAttachmentsControl}.
*/
export interface InsertAttachmentsControlConstructorParams {
/**
* The Obsidian app instance.
*/
readonly app: App;
/**
* The editor to insert attachment links into.
*/
readonly editor: Editor;
/**
* The plugin settings.
*/
readonly pluginSettings: ReadonlyDeep<PluginSettings>;
}
export class InsertAttachmentsControl {
private readonly app: App;
private readonly currentActiveDocument: Document;
private readonly editor: Editor;
private readonly fileEl: HTMLInputElement;
private readonly handleFocusClickBound: (this: void) => void;
private readonly pluginSettings: ReadonlyDeep<PluginSettings>;
private timeoutId = 0;
public constructor(private readonly plugin: Plugin, private readonly editor: Editor) {
this.app = this.plugin.app;
public constructor(params: InsertAttachmentsControlConstructorParams) {
this.app = params.app;
this.editor = params.editor;
this.pluginSettings = params.pluginSettings;
this.currentActiveDocument = activeDocument;
this.handleFocusClickBound = this.handleFocusClick.bind(this);
this.fileEl = this.currentActiveDocument.body.createEl('input', {
@ -70,9 +94,9 @@ export class InsertAttachmentsControl {
links.push(link);
}
const linksStr = this.plugin.settings.attachmentLinksPrefix
+ links.join(this.plugin.settings.attachmentLinksDelimiter)
+ this.plugin.settings.attachmentLinksSuffix;
const linksStr = this.pluginSettings.attachmentLinksPrefix
+ links.join(this.pluginSettings.attachmentLinksDelimiter)
+ this.pluginSettings.attachmentLinksSuffix;
this.editor.replaceSelection(linksStr);
this.detachFileEl();
}
@ -86,6 +110,6 @@ export class InsertAttachmentsControl {
private removeHandlers(): void {
this.currentActiveDocument.removeEventListener('focus', this.handleFocusClickBound);
this.currentActiveDocument.removeEventListener('click', this.handleFocusClickBound);
clearTimeout(this.timeoutId);
window.clearTimeout(this.timeoutId);
}
}

82
src/main.test.ts Normal file
View file

@ -0,0 +1,82 @@
import {
describe,
expect,
it,
vi
} from 'vitest';
vi.mock('obsidian-dev-utils/obsidian/active-file-provider', () => ({
AppActiveFileProvider: vi.fn()
}));
vi.mock('obsidian-dev-utils/obsidian/command-handlers/command-handler-component', () => ({
CommandHandlerComponent: vi.fn()
}));
vi.mock('obsidian-dev-utils/obsidian/command-registrar', () => ({
PluginCommandRegistrar: vi.fn()
}));
vi.mock('obsidian-dev-utils/obsidian/components/layout-ready-component', () => ({
CallbackLayoutReadyComponent: vi.fn()
}));
vi.mock('obsidian-dev-utils/obsidian/components/menu-event-registrar-component', () => ({
MenuEventRegistrarComponent: vi.fn()
}));
vi.mock('obsidian-dev-utils/obsidian/components/plugin-settings-tab-component', () => ({
PluginSettingsTabComponent: vi.fn()
}));
vi.mock('obsidian-dev-utils/obsidian/data-handler', () => ({
PluginDataHandler: vi.fn()
}));
vi.mock('obsidian-dev-utils/function', () => ({
noopAsync: vi.fn()
}));
vi.mock('obsidian-dev-utils/obsidian/plugin/plugin', () => ({
PluginBase: vi.fn()
}));
vi.mock('obsidian-dev-utils/obsidian/plugin/plugin-event-source', () => ({
PluginEventSourceImpl: vi.fn()
}));
vi.mock('obsidian-dev-utils/obsidian/plugin/plugin-settings-tab', () => ({
PluginSettingsTabBase: vi.fn()
}));
vi.mock('obsidian-dev-utils/obsidian/components/plugin-settings-component', () => ({
PluginSettingsComponentBase: vi.fn()
}));
vi.mock('obsidian', () => ({
Setting: vi.fn(),
TextComponent: vi.fn()
}));
vi.mock('./command-handlers/invoke-command-handler.ts', () => ({
InvokeCommandHandler: vi.fn()
}));
vi.mock('./plugin-settings-component.ts', () => ({
PluginSettingsComponent: vi.fn()
}));
vi.mock('./plugin-settings-tab.ts', () => ({
PluginSettingsTab: vi.fn()
}));
// eslint-disable-next-line import-x/first, import-x/imports-first -- vi.mock must precede imports.
import Plugin from './main.ts';
// eslint-disable-next-line import-x/first, import-x/imports-first -- vi.mock must precede imports.
import { Plugin as PluginClass } from './plugin.ts';
describe('main', () => {
it('should export Plugin as default export', () => {
expect(Plugin).toBe(PluginClass);
});
});

View file

@ -1,4 +1,4 @@
import './styles/main.scss';
import { Plugin } from './Plugin.ts';
import { Plugin } from './plugin.ts';
export default Plugin;

View file

@ -0,0 +1,160 @@
import type { DataHandler } from 'obsidian-dev-utils/obsidian/data-handler';
import type { PluginEventSource } from 'obsidian-dev-utils/obsidian/plugin/plugin-event-source';
import { strictProxy } from 'obsidian-dev-utils/strict-proxy';
import {
describe,
expect,
it,
vi
} from 'vitest';
interface MockConstructorParams {
readonly pluginSettingsClass: new () => unknown;
}
const PluginSettingsComponentBaseMock = vi.hoisted(() =>
class {
public readonly defaultSettings: unknown;
protected legacyConverterCalled = false;
public constructor(params: MockConstructorParams) {
this.defaultSettings = new params.pluginSettingsClass();
}
protected registerLegacySettingsConverter(
_legacyClass: new () => unknown,
_converter: (settings: Record<string, unknown>) => void
): void {
this.legacyConverterCalled = true;
}
protected registerLegacySettingsConverters(): void {
// Base no-op
}
}
);
vi.mock('obsidian-dev-utils/obsidian/components/plugin-settings-component', () => ({
PluginSettingsComponentBase: PluginSettingsComponentBaseMock
}));
// eslint-disable-next-line import-x/first, import-x/imports-first -- vi.mock must precede imports.
import { PluginSettingsComponent } from './plugin-settings-component.ts';
describe('PluginSettingsComponent', () => {
it('should create an instance', () => {
const component = new PluginSettingsComponent({
dataHandler: strictProxy<DataHandler>({}),
pluginEventSource: strictProxy<PluginEventSource>({})
});
expect(component).toBeInstanceOf(PluginSettingsComponent);
});
it('should pass pluginSettingsClass to base constructor', () => {
const component = new PluginSettingsComponent({
dataHandler: strictProxy<DataHandler>({}),
pluginEventSource: strictProxy<PluginEventSource>({})
});
expect(component.defaultSettings).toBeDefined();
});
it('should convert legacy shouldInsertDoubleLinesBetweenAttachmentLinks=true to double newline delimiter', () => {
const component = new PluginSettingsComponent({
dataHandler: strictProxy<DataHandler>({}),
pluginEventSource: strictProxy<PluginEventSource>({})
});
const legacySettings: Record<string, unknown> = {
shouldInsertDoubleLinesBetweenAttachmentLinks: true
};
const converter = getLegacyConverter(component);
converter(legacySettings);
expect(legacySettings['attachmentLinksDelimiter']).toBe('\n\n');
});
it('should convert legacy shouldInsertDoubleLinesBetweenAttachmentLinks=false to single newline delimiter', () => {
const component = new PluginSettingsComponent({
dataHandler: strictProxy<DataHandler>({}),
pluginEventSource: strictProxy<PluginEventSource>({})
});
const legacySettings: Record<string, unknown> = {
shouldInsertDoubleLinesBetweenAttachmentLinks: false
};
const converter = getLegacyConverter(component);
converter(legacySettings);
expect(legacySettings['attachmentLinksDelimiter']).toBe('\n');
});
it('should not modify delimiter when legacy field is undefined', () => {
const component = new PluginSettingsComponent({
dataHandler: strictProxy<DataHandler>({}),
pluginEventSource: strictProxy<PluginEventSource>({})
});
const legacySettings: Record<string, unknown> = {};
const converter = getLegacyConverter(component);
converter(legacySettings);
expect(legacySettings['attachmentLinksDelimiter']).toBeUndefined();
});
it('should have shouldInsertDoubleLinesBetweenAttachmentLinks default to true in LegacySettings', () => {
const component = new PluginSettingsComponent({
dataHandler: strictProxy<DataHandler>({}),
pluginEventSource: strictProxy<PluginEventSource>({})
});
const { legacyClass } = getLegacyRegistration(component);
const legacyInstance = new legacyClass();
expect(legacyInstance['shouldInsertDoubleLinesBetweenAttachmentLinks']).toBe(true);
});
});
interface CapturedLegacyRegistration {
converter(settings: Record<string, unknown>): void;
readonly legacyClass: new () => Record<string, unknown>;
}
interface MockWithConverters {
capturedConverter: ((settings: Record<string, unknown>) => void) | undefined;
registerLegacySettingsConverter(legacyClass: new () => unknown, converter: (settings: Record<string, unknown>) => void): void;
registerLegacySettingsConverters(): void;
}
function getLegacyConverter(component: PluginSettingsComponent): (settings: Record<string, unknown>) => void {
return getLegacyRegistration(component).converter;
}
function getLegacyRegistration(component: PluginSettingsComponent): CapturedLegacyRegistration {
// eslint-disable-next-line no-restricted-syntax -- test helper needs type assertion to access mock methods.
const mock = component as unknown as MockWithConverters;
let captured: CapturedLegacyRegistration | undefined;
mock.registerLegacySettingsConverter = (
legacyClass: new () => unknown,
converter: (settings: Record<string, unknown>) => void
): void => {
captured = {
converter,
legacyClass: legacyClass as new () => Record<string, unknown>
};
};
mock.registerLegacySettingsConverters();
if (!captured) {
throw new Error('No legacy converter was registered');
}
return captured;
}

View file

@ -0,0 +1,33 @@
import type { DataHandler } from 'obsidian-dev-utils/obsidian/data-handler';
import type { PluginEventSource } from 'obsidian-dev-utils/obsidian/plugin/plugin-event-source';
import { PluginSettingsComponentBase } from 'obsidian-dev-utils/obsidian/components/plugin-settings-component';
import { PluginSettings } from './plugin-settings.ts';
export interface PluginSettingsComponentConstructorParams {
readonly dataHandler: DataHandler;
readonly pluginEventSource: PluginEventSource;
}
class LegacySettings {
public shouldInsertDoubleLinesBetweenAttachmentLinks = true;
}
export class PluginSettingsComponent extends PluginSettingsComponentBase<PluginSettings> {
public constructor(params: PluginSettingsComponentConstructorParams) {
super({
dataHandler: params.dataHandler,
pluginEventSource: params.pluginEventSource,
pluginSettingsClass: PluginSettings
});
}
protected override registerLegacySettingsConverters(): void {
this.registerLegacySettingsConverter(LegacySettings, (legacySettings) => {
if (legacySettings.shouldInsertDoubleLinesBetweenAttachmentLinks !== undefined) {
legacySettings.attachmentLinksDelimiter = legacySettings.shouldInsertDoubleLinesBetweenAttachmentLinks ? '\n\n' : '\n';
}
});
}
}

View file

@ -0,0 +1,294 @@
/* eslint-disable @typescript-eslint/no-empty-function -- Test mocks require empty constructors and flexible patterns. */
import type { PluginSettingsTabBaseConstructorParams } from 'obsidian-dev-utils/obsidian/plugin/plugin-settings-tab';
import { castTo } from 'obsidian-dev-utils/object-utils';
import { ensureNonNullable } from 'obsidian-dev-utils/type-guards';
import {
describe,
expect,
it,
vi
} from 'vitest';
import type { PluginSettings } from './plugin-settings.ts';
import { PluginSettingsTab } from './plugin-settings-tab.ts';
interface BindOptions {
readonly componentToPluginSettingsValueConverter?: ValueConverter;
readonly pluginSettingsToComponentValueConverter?: ValueConverter;
}
interface CapturedBind {
component: MockTextComponentInTab;
key: string;
options?: BindOptions;
}
type ValueConverter = (value: string) => string;
const capturedBinds: CapturedBind[] = [];
let mockContainerEl: HTMLElement;
vi.mock('obsidian-dev-utils/obsidian/plugin/plugin-settings-tab', () => ({
PluginSettingsTabBase: class {
public containerEl: HTMLElement = activeDocument.createElement('div');
public constructor(_params: unknown) {
mockContainerEl = this.containerEl;
}
public bind(component: MockTextComponentInTab, key: string, options?: BindOptions): unknown {
const entry: CapturedBind = { component, key };
if (options !== undefined) {
entry.options = options;
}
capturedBinds.push(entry);
return component;
}
public display(): void {}
}
}));
vi.mock('obsidian-dev-utils/string', () => ({
replace: vi.fn((str: string, replacements: Record<string, string>) => {
let result = str;
for (const [from, to] of Object.entries(replacements)) {
result = result.replaceAll(from, to);
}
return result;
})
}));
class MockTextComponentInTab {
public inputEl: HTMLInputElement = activeDocument.createElement('input');
}
vi.mock('obsidian', () => ({
Setting: class MockSetting {
public constructor(el: HTMLElement) {
el.appendChild(activeDocument.createElement('div'));
}
public addText(cb: (text: MockTextComponentInTab) => void): this {
const mockText = new MockTextComponentInTab();
cb(mockText);
return this;
}
public setDesc(_desc: string): this {
return this;
}
public setName(_name: string): this {
return this;
}
},
// eslint-disable-next-line @typescript-eslint/no-extraneous-class -- imported module reference must exist for InstanceType resolution.
TextComponent: class MockTextComponentGlobal {}
}));
describe('PluginSettingsTab', () => {
function createSettingsTab(): PluginSettingsTab {
capturedBinds.length = 0;
return new PluginSettingsTab(castTo<PluginSettingsTabBaseConstructorParams<PluginSettings>>({}));
}
it('should create an instance', () => {
const tab = createSettingsTab();
expect(tab).toBeInstanceOf(PluginSettingsTab);
});
it('should render three settings on display()', () => {
const tab = createSettingsTab();
// eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional.
tab.display();
const EXPECTED_SETTING_COUNT = 3;
expect(mockContainerEl.children.length).toBe(EXPECTED_SETTING_COUNT);
});
it('should bind attachmentLinksPrefix', () => {
const tab = createSettingsTab();
// eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional.
tab.display();
expect(capturedBinds.map((b) => b.key)).toContain('attachmentLinksPrefix');
});
it('should bind attachmentLinksDelimiter', () => {
const tab = createSettingsTab();
// eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional.
tab.display();
expect(capturedBinds.map((b) => b.key)).toContain('attachmentLinksDelimiter');
});
it('should bind attachmentLinksSuffix', () => {
const tab = createSettingsTab();
// eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional.
tab.display();
expect(capturedBinds.map((b) => b.key)).toContain('attachmentLinksSuffix');
});
it('should convert newline to visible enter character when reading value from plugin settings', () => {
const tab = createSettingsTab();
// eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional.
tab.display();
const prefixBind = capturedBinds.find((b) => b.key === 'attachmentLinksPrefix');
const converter = prefixBind?.options?.pluginSettingsToComponentValueConverter;
expect(converter).toBeDefined();
expect(converter?.('\n')).toBe('↵');
});
it('should convert space to visible space character when reading value from plugin settings', () => {
const tab = createSettingsTab();
// eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional.
tab.display();
const prefixBind = capturedBinds.find((b) => b.key === 'attachmentLinksPrefix');
const converter = prefixBind?.options?.pluginSettingsToComponentValueConverter;
expect(converter).toBeDefined();
expect(converter?.(' ')).toBe('␣');
});
it('should restore visible enter character back to newline when saving to plugin settings', () => {
const tab = createSettingsTab();
// eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional.
tab.display();
const prefixBind = capturedBinds.find((b) => b.key === 'attachmentLinksPrefix');
const converter = prefixBind?.options?.componentToPluginSettingsValueConverter;
expect(converter).toBeDefined();
expect(converter?.('↵')).toBe('\n');
});
it('should restore visible space character back to space when saving to plugin settings', () => {
const tab = createSettingsTab();
// eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional.
tab.display();
const prefixBind = capturedBinds.find((b) => b.key === 'attachmentLinksPrefix');
const converter = prefixBind?.options?.componentToPluginSettingsValueConverter;
expect(converter).toBeDefined();
expect(converter?.('␣')).toBe(' ');
});
it('should replace visible space character when user types in input field', () => {
const tab = createSettingsTab();
// eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional.
tab.display();
const prefixBind = capturedBinds.find((b) => b.key === 'attachmentLinksPrefix');
const inputEl = prefixBind?.component.inputEl;
expect(inputEl).toBeDefined();
const safeInputEl = ensureNonNullable(inputEl);
safeInputEl.value = ' ';
safeInputEl.dispatchEvent(new Event('input'));
expect(safeInputEl.value).toBe('␣');
});
it('should insert visible enter character when user presses Enter key', () => {
const tab = createSettingsTab();
// eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional.
tab.display();
const prefixBind = capturedBinds.find((b) => b.key === 'attachmentLinksPrefix');
const inputEl = prefixBind?.component.inputEl;
expect(inputEl).toBeDefined();
const safeInputEl = ensureNonNullable(inputEl);
safeInputEl.value = 'prefix';
Object.defineProperty(safeInputEl, 'selectionStart', { configurable: true, value: 6 });
Object.defineProperty(safeInputEl, 'selectionEnd', { configurable: true, value: 6 });
const keypressEvent = new KeyboardEvent('keypress', { key: 'Enter' });
const preventDefaultSpy = vi.spyOn(keypressEvent, 'preventDefault');
safeInputEl.dispatchEvent(keypressEvent);
expect(preventDefaultSpy).toHaveBeenCalled();
expect(safeInputEl.value).toBe('prefix↵');
});
it('should not intercept non-Enter key presses', () => {
const tab = createSettingsTab();
// eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional.
tab.display();
const prefixBind = capturedBinds.find((b) => b.key === 'attachmentLinksPrefix');
const inputEl = prefixBind?.component.inputEl;
expect(inputEl).toBeDefined();
const safeInputEl = ensureNonNullable(inputEl);
safeInputEl.value = 'prefix';
const keypressEvent = new KeyboardEvent('keypress', { key: 'a' });
const preventDefaultSpy = vi.spyOn(keypressEvent, 'preventDefault');
safeInputEl.dispatchEvent(keypressEvent);
expect(preventDefaultSpy).not.toHaveBeenCalled();
expect(safeInputEl.value).toBe('prefix');
});
it('should use 0 as fallback when selectionStart is null on input event', () => {
const tab = createSettingsTab();
// eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional.
tab.display();
const prefixBind = capturedBinds.find((b) => b.key === 'attachmentLinksPrefix');
const inputEl = prefixBind?.component.inputEl;
expect(inputEl).toBeDefined();
const safeInputEl = ensureNonNullable(inputEl);
Object.defineProperty(safeInputEl, 'selectionStart', { configurable: true, value: null });
Object.defineProperty(safeInputEl, 'selectionEnd', { configurable: true, value: null });
safeInputEl.value = ' ';
safeInputEl.dispatchEvent(new Event('input'));
expect(safeInputEl.value).toBe('␣');
});
it('should use 0 as fallback when selectionStart is null on Enter keypress', () => {
const tab = createSettingsTab();
// eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional.
tab.display();
const prefixBind = capturedBinds.find((b) => b.key === 'attachmentLinksPrefix');
const inputEl = prefixBind?.component.inputEl;
expect(inputEl).toBeDefined();
const safeInputEl = ensureNonNullable(inputEl);
safeInputEl.value = 'text';
Object.defineProperty(safeInputEl, 'selectionStart', { configurable: true, value: null });
Object.defineProperty(safeInputEl, 'selectionEnd', { configurable: true, value: null });
const keypressEvent = new KeyboardEvent('keypress', { key: 'Enter' });
safeInputEl.dispatchEvent(keypressEvent);
expect(safeInputEl.value).toBe('↵text');
});
});
/* eslint-enable @typescript-eslint/no-empty-function -- End of test file. */

View file

@ -2,16 +2,17 @@ import {
Setting,
TextComponent
} from 'obsidian';
import { PluginSettingsTabBase } from 'obsidian-dev-utils/obsidian/plugin/plugin-settings-tab-base';
import { PluginSettingsTabBase } from 'obsidian-dev-utils/obsidian/plugin/plugin-settings-tab';
import { replace } from 'obsidian-dev-utils/string';
import type { PluginTypes } from './PluginTypes.ts';
import type { PluginSettings } from './plugin-settings.ts';
const VISIBLE_SPACE_CHARACTER = '␣';
const VISIBLE_ENTER_CHARACTER = '↵';
export class PluginSettingsTab extends PluginSettingsTabBase<PluginTypes> {
export class PluginSettingsTab extends PluginSettingsTabBase<PluginSettings> {
public override display(): void {
// eslint-disable-next-line @typescript-eslint/no-deprecated -- super.display() calls the PluginSettingsTabBase override; the inherited @deprecated tag on Obsidian's SettingTab.display propagates via TS getJsDocTags.
super.display();
new Setting(this.containerEl)

View file

@ -0,0 +1,27 @@
import {
describe,
expect,
it
} from 'vitest';
import { PluginSettings } from './plugin-settings.ts';
describe('PluginSettings', () => {
it('should have attachmentLinksDelimiter default to double newline', () => {
const settings = new PluginSettings();
expect(settings.attachmentLinksDelimiter).toBe('\n\n');
});
it('should have attachmentLinksPrefix default to empty string', () => {
const settings = new PluginSettings();
expect(settings.attachmentLinksPrefix).toBe('');
});
it('should have attachmentLinksSuffix default to empty string', () => {
const settings = new PluginSettings();
expect(settings.attachmentLinksSuffix).toBe('');
});
});

View file

@ -0,0 +1,13 @@
import { getTempVault } from 'obsidian-integration-testing/vitest-global-setup';
import {
describe,
expect,
it
} from 'vitest';
describe('Smoke test', () => {
it('should load plugin on Android', () => {
const vault = getTempVault();
expect(vault.path).toBeTruthy();
});
});

View file

@ -0,0 +1,13 @@
import { getTempVault } from 'obsidian-integration-testing/vitest-global-setup';
import {
describe,
expect,
it
} from 'vitest';
describe('Smoke test', () => {
it('should load plugin on Desktop', () => {
const vault = getTempVault();
expect(vault.path).toBeTruthy();
});
});

188
src/plugin.test.ts Normal file
View file

@ -0,0 +1,188 @@
import type {
App,
Component,
PluginManifest
} from 'obsidian';
import { castTo } from 'obsidian-dev-utils/object-utils';
import {
afterEach,
describe,
expect,
it,
vi
} from 'vitest';
const addedChildren: Component[] = [];
let layoutReadyCallback: (() => Promise<void>) | undefined;
const PluginBaseMock = vi.hoisted(() =>
class {
public app: unknown;
public manifest: unknown;
public constructor(app: unknown, manifest: unknown) {
this.app = app;
this.manifest = manifest;
}
public addChild<T extends Component>(child: T): T {
addedChildren.push(child);
return child;
}
}
);
const CallbackLayoutReadyComponentMock = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-extraneous-class -- mock class stores side effects in constructor.
class MockCallbackLayoutReadyComponent {
public constructor(_app: unknown, callback: () => Promise<void>) {
layoutReadyCallback = callback;
}
}
return MockCallbackLayoutReadyComponent;
});
vi.mock('obsidian-dev-utils/obsidian/plugin/plugin', () => ({
PluginBase: PluginBaseMock
}));
vi.mock('obsidian-dev-utils/obsidian/components/layout-ready-component', () => ({
CallbackLayoutReadyComponent: CallbackLayoutReadyComponentMock
}));
vi.mock('obsidian-dev-utils/function', () => ({
noopAsync: vi.fn().mockResolvedValue(undefined)
}));
vi.mock('obsidian-dev-utils/obsidian/active-file-provider', () => ({
// eslint-disable-next-line @typescript-eslint/no-extraneous-class -- mock class must exist for constructor call.
AppActiveFileProvider: class MockAppActiveFileProvider {}
}));
vi.mock('obsidian-dev-utils/obsidian/command-handlers/command-handler-component', () => ({
// eslint-disable-next-line @typescript-eslint/no-extraneous-class -- mock class must exist for constructor call.
CommandHandlerComponent: class MockCommandHandlerComponent {}
}));
vi.mock('obsidian-dev-utils/obsidian/command-registrar', () => ({
// eslint-disable-next-line @typescript-eslint/no-extraneous-class -- mock class must exist for constructor call.
PluginCommandRegistrar: class MockPluginCommandRegistrar {}
}));
vi.mock('obsidian-dev-utils/obsidian/components/menu-event-registrar-component', () => ({
// eslint-disable-next-line @typescript-eslint/no-extraneous-class -- mock class must exist for constructor call.
MenuEventRegistrarComponent: class MockMenuEventRegistrarComponent {}
}));
vi.mock('obsidian-dev-utils/obsidian/components/plugin-settings-tab-component', () => ({
// eslint-disable-next-line @typescript-eslint/no-extraneous-class -- mock class must exist for constructor call.
PluginSettingsTabComponent: class MockPluginSettingsTabComponent {}
}));
vi.mock('obsidian-dev-utils/obsidian/data-handler', () => ({
// eslint-disable-next-line @typescript-eslint/no-extraneous-class -- mock class must exist for constructor call.
PluginDataHandler: class MockPluginDataHandler {}
}));
vi.mock('obsidian-dev-utils/obsidian/plugin/plugin-event-source', () => ({
// eslint-disable-next-line @typescript-eslint/no-extraneous-class -- mock class must exist for constructor call.
PluginEventSourceImpl: class MockPluginEventSourceImpl {}
}));
vi.mock('obsidian-dev-utils/obsidian/plugin/plugin-settings-tab', () => ({
// eslint-disable-next-line @typescript-eslint/no-extraneous-class -- mock class must exist for constructor call.
PluginSettingsTabBase: class MockPluginSettingsTabBase {}
}));
vi.mock('obsidian-dev-utils/obsidian/components/plugin-settings-component', () => ({
PluginSettingsComponentBase: class MockPluginSettingsComponentBase {
public readonly settings = {};
}
}));
vi.mock('obsidian', () => ({
Setting: vi.fn(),
TextComponent: vi.fn()
}));
interface InvokeCommandHandlerConstructorParamsMock {
getPluginSettings(): unknown;
}
let capturedGetPluginSettings: (() => unknown) | undefined;
vi.mock('./command-handlers/invoke-command-handler.ts', () => {
// eslint-disable-next-line @typescript-eslint/no-extraneous-class -- mock class stores side effects in constructor.
class MockInvokeCommandHandler {
public constructor(params: InvokeCommandHandlerConstructorParamsMock) {
capturedGetPluginSettings = params.getPluginSettings.bind(params);
}
}
return { InvokeCommandHandler: MockInvokeCommandHandler };
});
vi.mock('./plugin-settings-component.ts', () => ({
PluginSettingsComponent: class MockPluginSettingsComponent {
public readonly settings = {};
}
}));
vi.mock('./plugin-settings-tab.ts', () => ({
// eslint-disable-next-line @typescript-eslint/no-extraneous-class -- mock class must exist for constructor call.
PluginSettingsTab: class MockPluginSettingsTab {}
}));
// eslint-disable-next-line import-x/first, import-x/imports-first -- vi.mock must precede imports.
import { noopAsync } from 'obsidian-dev-utils/function';
// eslint-disable-next-line import-x/first, import-x/imports-first -- vi.mock must precede imports.
import { Plugin } from './plugin.ts';
describe('Plugin', () => {
afterEach(() => {
addedChildren.length = 0;
layoutReadyCallback = undefined;
capturedGetPluginSettings = undefined;
vi.restoreAllMocks();
vi.clearAllMocks();
});
it('should create plugin instance', () => {
const plugin = new Plugin(castTo<App>({}), castTo<PluginManifest>({ name: 'test-plugin' }));
expect(plugin).toBeInstanceOf(Plugin);
});
it('should add five child components in the constructor', () => {
addedChildren.length = 0;
new Plugin(castTo<App>({}), castTo<PluginManifest>({ name: 'test-plugin' }));
const EXPECTED_CHILD_COUNT = 5;
expect(addedChildren).toHaveLength(EXPECTED_CHILD_COUNT);
});
it('should register a layout ready callback', () => {
new Plugin(castTo<App>({}), castTo<PluginManifest>({ name: 'test-plugin' }));
expect(layoutReadyCallback).toBeDefined();
});
it('should call noopAsync when onLayoutReady fires', async () => {
new Plugin(castTo<App>({}), castTo<PluginManifest>({ name: 'test-plugin' }));
await layoutReadyCallback?.();
expect(noopAsync).toHaveBeenCalled();
});
it('should provide working getPluginSettings that returns settings', () => {
new Plugin(castTo<App>({}), castTo<PluginManifest>({ name: 'test-plugin' }));
expect(capturedGetPluginSettings).toBeDefined();
const settings = capturedGetPluginSettings?.();
expect(settings).toBeDefined();
});
});

65
src/plugin.ts Normal file
View file

@ -0,0 +1,65 @@
import type {
App,
PluginManifest
} from 'obsidian';
import { noopAsync } from 'obsidian-dev-utils/function';
import { AppActiveFileProvider } from 'obsidian-dev-utils/obsidian/active-file-provider';
import { CommandHandlerComponent } from 'obsidian-dev-utils/obsidian/command-handlers/command-handler-component';
import { PluginCommandRegistrar } from 'obsidian-dev-utils/obsidian/command-registrar';
import { CallbackLayoutReadyComponent } from 'obsidian-dev-utils/obsidian/components/layout-ready-component';
import { MenuEventRegistrarComponent } from 'obsidian-dev-utils/obsidian/components/menu-event-registrar-component';
import { PluginSettingsTabComponent } from 'obsidian-dev-utils/obsidian/components/plugin-settings-tab-component';
import { PluginDataHandler } from 'obsidian-dev-utils/obsidian/data-handler';
import { PluginBase } from 'obsidian-dev-utils/obsidian/plugin/plugin';
import { PluginEventSourceImpl } from 'obsidian-dev-utils/obsidian/plugin/plugin-event-source';
import type { PluginSettings } from './plugin-settings.ts';
import { InvokeCommandHandler } from './command-handlers/invoke-command-handler.ts';
import { PluginSettingsComponent } from './plugin-settings-component.ts';
import { PluginSettingsTab } from './plugin-settings-tab.ts';
export class Plugin extends PluginBase {
private readonly pluginSettingsComponent: PluginSettingsComponent;
public constructor(app: App, manifest: PluginManifest) {
super(app, manifest);
this.pluginSettingsComponent = this.addChild(
new PluginSettingsComponent({
dataHandler: new PluginDataHandler(this),
pluginEventSource: new PluginEventSourceImpl(this)
})
);
this.addChild(
new PluginSettingsTabComponent({
plugin: this,
pluginSettingsTab: new PluginSettingsTab({
plugin: this,
pluginSettingsComponent: this.pluginSettingsComponent
})
})
);
const menuEventRegistrar = this.addChild(new MenuEventRegistrarComponent(app));
this.addChild(
new CommandHandlerComponent({
activeFileProvider: new AppActiveFileProvider(app),
commandHandlers: [
new InvokeCommandHandler({
app,
getPluginSettings: (): PluginSettings => this.pluginSettingsComponent.settings as PluginSettings,
pluginName: manifest.name
})
],
commandRegistrar: new PluginCommandRegistrar(this),
menuEventRegistrar,
pluginName: manifest.name
})
);
this.addChild(new CallbackLayoutReadyComponent(app, this.onLayoutReady.bind(this)));
}
protected async onLayoutReady(): Promise<void> {
await noopAsync();
}
}

1
src/styles/scss.d.ts vendored Normal file
View file

@ -0,0 +1 @@
declare module '*.scss';

View file

@ -10,6 +10,7 @@
"inlineSources": true,
"lib": [
"DOM",
"DOM.Iterable",
"ESNext"
],
"libReplacement": true,
@ -21,7 +22,7 @@
"types": [
"@total-typescript/ts-reset",
"node",
"obsidian-typings"
"@obsidian-typings/obsidian-public-latest"
],
"verbatimModuleSyntax": true
},
@ -30,6 +31,7 @@
"./commitlint.config.ts",
"./eslint.config.mts",
"./src/**/*.ts",
"./scripts/**/*.ts"
"./scripts/**/*.ts",
"./vitest.config.ts"
]
}

View file

@ -43,5 +43,6 @@
"1.3.4": "1.10.6",
"1.3.5": "1.11.4",
"1.3.6": "1.11.4",
"1.3.7": "1.12.4"
"1.3.7": "1.12.4",
"1.3.8": "1.12.7"
}

3
vitest.config.ts Normal file
View file

@ -0,0 +1,3 @@
import { config } from './scripts/vitest-config.ts';
export default config;