mirror of
https://github.com/mnaoumov/obsidian-insert-multiple-attachments.git
synced 2026-07-22 05:32:21 +00:00
chore: update template
This commit is contained in:
parent
dc4a4477af
commit
753e8c44e8
42 changed files with 6239 additions and 5497 deletions
3
.czrc
3
.czrc
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"path": "./node_modules/cz-conventional-changelog"
|
||||
}
|
||||
24
.github/workflows/attest-release-assets.yml
vendored
Normal file
24
.github/workflows/attest-release-assets.yml
vendored
Normal 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
1
.gitignore
vendored
|
|
@ -25,3 +25,4 @@ dist
|
|||
.env
|
||||
/tsconfig.tsbuildinfo
|
||||
.claude/settings.local.json
|
||||
coverage
|
||||
|
|
|
|||
71
CONTRIBUTING.md
Normal file
71
CONTRIBUTING.md
Normal 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.
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
[](https://www.buymeacoffee.com/mnaoumov)
|
||||
[](https://github.com/mnaoumov/obsidian-insert-multiple-attachments/releases)
|
||||
[](https://github.com/mnaoumov/obsidian-insert-multiple-attachments/releases)
|
||||
[](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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"version": "0.2",
|
||||
"ignorePaths": [
|
||||
"coverage",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"tsconfig.tsbuildinfo"
|
||||
|
|
|
|||
|
|
@ -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
9802
package-lock.json
generated
File diff suppressed because it is too large
Load diff
68
package.json
68
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
11
scripts/test-coverage.ts
Normal 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']
|
||||
})
|
||||
);
|
||||
8
scripts/test-integration-android.ts
Normal file
8
scripts/test-integration-android.ts
Normal 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']
|
||||
})
|
||||
);
|
||||
8
scripts/test-integration-desktop.ts
Normal file
8
scripts/test-integration-desktop.ts
Normal 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']
|
||||
})
|
||||
);
|
||||
8
scripts/test-integration-no-app.ts
Normal file
8
scripts/test-integration-no-app.ts
Normal 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']
|
||||
})
|
||||
);
|
||||
14
scripts/test-integration.ts
Normal file
14
scripts/test-integration.ts
Normal 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
8
scripts/test-watch.ts
Normal 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
8
scripts/test.ts
Normal 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
88
scripts/vitest-config.ts
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
114
src/command-handlers/invoke-command-handler.test.ts
Normal file
114
src/command-handlers/invoke-command-handler.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
55
src/command-handlers/invoke-command-handler.ts
Normal file
55
src/command-handlers/invoke-command-handler.ts
Normal 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()
|
||||
});
|
||||
}
|
||||
}
|
||||
404
src/insert-attachments-control.test.ts
Normal file
404
src/insert-attachments-control.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
82
src/main.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import './styles/main.scss';
|
||||
import { Plugin } from './Plugin.ts';
|
||||
import { Plugin } from './plugin.ts';
|
||||
|
||||
export default Plugin;
|
||||
|
|
|
|||
160
src/plugin-settings-component.test.ts
Normal file
160
src/plugin-settings-component.test.ts
Normal 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;
|
||||
}
|
||||
33
src/plugin-settings-component.ts
Normal file
33
src/plugin-settings-component.ts
Normal 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';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
294
src/plugin-settings-tab.test.ts
Normal file
294
src/plugin-settings-tab.test.ts
Normal 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. */
|
||||
|
|
@ -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)
|
||||
27
src/plugin-settings.test.ts
Normal file
27
src/plugin-settings.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
13
src/plugin.android.integration.test.ts
Normal file
13
src/plugin.android.integration.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
13
src/plugin.desktop.integration.test.ts
Normal file
13
src/plugin.desktop.integration.test.ts
Normal 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
188
src/plugin.test.ts
Normal 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
65
src/plugin.ts
Normal 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
1
src/styles/scss.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
declare module '*.scss';
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
3
vitest.config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { config } from './scripts/vitest-config.ts';
|
||||
|
||||
export default config;
|
||||
Loading…
Reference in a new issue