chore: update template

This commit is contained in:
Michael Naumov 2026-06-09 06:41:09 -06:00
parent bae3c27b55
commit 40666fe33f
40 changed files with 6798 additions and 5904 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

View file

@ -211,7 +211,7 @@
## 1.0.1
- Applied suggestions after [code review](https://github.com/obsidianmd/obsidian-releases/pull/1782#issuecomment-1482613623)
- Applied suggestions after code review
## 1.0.0

1
CLAUDE.md Normal file
View file

@ -0,0 +1 @@
# CLAUDE.md

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-smart-rename.git
cd obsidian-smart-rename
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-smart-rename)](https://github.com/mnaoumov/obsidian-smart-rename/releases)
[![GitHub downloads](https://img.shields.io/github/downloads/mnaoumov/obsidian-smart-rename/total)](https://github.com/mnaoumov/obsidian-smart-rename/releases)
[![Coverage: 100%](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/mnaoumov/obsidian-smart-rename)
This is a plugin for [Obsidian](https://obsidian.md/) that adds the command `Smart Rename` which performs the following steps after renaming the note:
@ -68,13 +69,13 @@ Current plugin's aim is to preserve `OldName` display text in links 1, 2, 4
## Installation
The plugin is available in [the official Community Plugins repository](https://obsidian.md/plugins?id=smart-rename).
The plugin is available in [the official Community Plugins repository](https://community.obsidian.md/plugins/smart-rename).
### Beta versions
To install the latest beta release of this plugin (regardless if it is available in [the official Community Plugins repository](https://obsidian.md/plugins) or not), follow these steps:
To install the latest beta release of this plugin (regardless if it is available in [the official Community Plugins repository](https://community.obsidian.md) or not), follow these steps:
1. Ensure you have the [BRAT plugin](https://obsidian.md/plugins?id=obsidian42-brat) installed and enabled.
1. Ensure you have the [BRAT plugin](https://community.obsidian.md/plugins/obsidian42-brat) installed and enabled.
2. Click [Install via BRAT](https://intradeus.github.io/http-protocol-redirector?r=obsidian://brat?plugin=https://github.com/mnaoumov/obsidian-smart-rename).
3. An Obsidian pop-up window should appear. In the window, click the `Add plugin` button once and wait a few seconds for the plugin to install.

View file

@ -1,6 +1,7 @@
{
"version": "0.2",
"ignorePaths": [
"coverage",
"dist",
"node_modules",
"tsconfig.tsbuildinfo"
@ -15,7 +16,10 @@
"mnaoumov",
"Naumov",
"obsidianmd",
"OLDTITLE",
"Promisable",
"tfile",
"tfolder",
"tsbuildinfo",
"Wikilink"
],

View file

@ -3,7 +3,7 @@
"name": "Smart Rename",
"version": "2.2.1",
"minAppVersion": "1.12.4",
"description": "Renames notes keeping previous title in existing links",
"description": "Renames notes keeping previous title in existing links.",
"author": "mnaoumov",
"authorUrl": "https://github.com/mnaoumov",
"isDesktopOnly": false,

10442
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,15 @@
{
"name": "smart-rename",
"version": "2.2.1",
"description": "Renames notes keeping previous title in existing links",
"description": "Renames notes keeping previous title in existing links.",
"keywords": [],
"homepage": "https://github.com/mnaoumov/obsidian-smart-rename#readme",
"bugs": "https://github.com/mnaoumov/obsidian-smart-rename/issues",
"repository": "git+https://github.com/mnaoumov/obsidian-smart-rename.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,45 @@
"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/config-helpers": "$@eslint/config-helpers",
"@vitest/coverage-v8": "$@vitest/coverage-v8",
"eslint": "$eslint",
"type-fest": "$type-fest",
"typescript": "$typescript"
},
"type": "module"
"devDependencies": {
"@commitlint/cli": "^21.0.2",
"@commitlint/config-conventional": "^21.0.2",
"@commitlint/types": "^21.0.1",
"@eslint/config-helpers": "^0.6.0",
"@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",
"type-fest": "^5.7.0",
"typescript": "^6.0.3",
"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,50 +0,0 @@
import type { TFile } from 'obsidian';
import {
FileCommandBase,
FileCommandInvocationBase
} from 'obsidian-dev-utils/obsidian/commands/file-command-base';
import { isMarkdownFile } from 'obsidian-dev-utils/obsidian/file-system';
import type { Plugin } from '../Plugin.ts';
class InvokeCommandInvocation extends FileCommandInvocationBase<Plugin> {
public constructor(plugin: Plugin, file: null | TFile) {
super(plugin, file);
}
public override async execute(): Promise<void> {
await this.plugin.smartRename(this.file);
}
protected override canExecute(): boolean {
if (!super.canExecute()) {
return false;
}
if (!this.plugin.settings.shouldSupportNonMarkdownFiles && !isMarkdownFile(this.app, this.file)) {
return false;
}
return true;
}
}
export class InvokeCommand extends FileCommandBase<Plugin> {
public constructor(plugin: Plugin) {
super({
fileMenuItemName: 'Smart rename',
icon: 'edit-3',
id: 'invoke',
name: 'Invoke',
plugin
});
}
protected override createCommandInvocationForFile(file: null | TFile): FileCommandInvocationBase<Plugin> {
return new InvokeCommandInvocation(this.plugin, file);
}
protected override shouldAddToFileMenu(): boolean {
return true;
}
}

View file

@ -1,21 +0,0 @@
import type { MaybeReturn } from 'obsidian-dev-utils/type';
import { PluginSettingsManagerBase } from 'obsidian-dev-utils/obsidian/plugin/plugin-settings-manager-base';
import type { PluginTypes } from './PluginTypes.ts';
import { PluginSettings } from './PluginSettings.ts';
export class PluginSettingsManager extends PluginSettingsManagerBase<PluginTypes> {
protected override createDefaultSettings(): PluginSettings {
return new PluginSettings();
}
protected override registerValidators(): void {
this.registerValidator('replacementCharacter', (value): MaybeReturn<string> => {
if (this.plugin.hasInvalidCharacters(value)) {
return 'Invalid replacement character';
}
});
}
}

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,97 @@
/* eslint-disable @typescript-eslint/no-extraneous-class, @typescript-eslint/no-useless-constructor, @typescript-eslint/no-empty-function -- Test mocks require empty constructors and flexible patterns. */
import type { TFile } from 'obsidian';
import type { ReadonlyDeep } from 'type-fest';
import { noopAsync } from 'obsidian-dev-utils/function';
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';
vi.mock('obsidian-dev-utils/obsidian/command-handlers/file-command-handler', () => ({
FileCommandHandler: class MockFileCommandHandler {
public constructor(_params: unknown) {}
}
}));
interface CreateHandlerOptions {
checkIsMarkdownFile?(file: TFile): boolean;
getSettings?(): ReadonlyDeep<PluginSettings>;
smartRename?(file: TFile): Promise<void>;
}
function createHandler(opts?: CreateHandlerOptions): InvokeCommandHandler {
return new InvokeCommandHandler({
checkIsMarkdownFile: opts?.checkIsMarkdownFile ?? ((): boolean => true),
getSettings: opts?.getSettings ?? ((): ReadonlyDeep<PluginSettings> => strictProxy<ReadonlyDeep<PluginSettings>>({ shouldSupportNonMarkdownFiles: false })),
smartRename: opts?.smartRename ?? ((): Promise<void> => noopAsync())
});
}
describe('InvokeCommandHandler', () => {
it('should create an instance', () => {
const handler = createHandler();
expect(handler).toBeInstanceOf(InvokeCommandHandler);
});
describe('canExecuteFile', () => {
it('should return true when file is a markdown file', () => {
const handler = createHandler({
checkIsMarkdownFile: (): boolean => true,
getSettings: (): ReadonlyDeep<PluginSettings> => strictProxy<ReadonlyDeep<PluginSettings>>({ shouldSupportNonMarkdownFiles: false })
});
const mockFile = strictProxy<TFile>({ path: 'note.md' });
expect(handler['canExecuteFile'](mockFile)).toBe(true);
});
it('should return false when file is not markdown and non-markdown not supported', () => {
const handler = createHandler({
checkIsMarkdownFile: (): boolean => false,
getSettings: (): ReadonlyDeep<PluginSettings> => strictProxy<ReadonlyDeep<PluginSettings>>({ shouldSupportNonMarkdownFiles: false })
});
const mockFile = strictProxy<TFile>({ path: 'note.pdf' });
expect(handler['canExecuteFile'](mockFile)).toBe(false);
});
it('should return true when file is not markdown but non-markdown files are supported', () => {
const handler = createHandler({
checkIsMarkdownFile: (): boolean => false,
getSettings: (): ReadonlyDeep<PluginSettings> => strictProxy<ReadonlyDeep<PluginSettings>>({ shouldSupportNonMarkdownFiles: true })
});
const mockFile = strictProxy<TFile>({ path: 'note.pdf' });
expect(handler['canExecuteFile'](mockFile)).toBe(true);
});
});
describe('executeFile', () => {
it('should call smartRename with the file', async () => {
const smartRename = vi.fn<(file: TFile) => Promise<void>>().mockResolvedValue(undefined);
const handler = createHandler({ smartRename });
const mockFile = strictProxy<TFile>({ path: 'note.md' });
await handler['executeFile'](mockFile);
expect(smartRename).toHaveBeenCalledWith(mockFile);
});
});
describe('shouldAddToFileMenu', () => {
it('should return true for any file', () => {
const handler = createHandler();
const mockFile = strictProxy<TFile>({ path: 'note.md' });
expect(handler['shouldAddToFileMenu'](mockFile, 'file-explorer')).toBe(true);
});
it('should return true for non-markdown file', () => {
const handler = createHandler();
const mockFile = strictProxy<TFile>({ path: 'image.png' });
expect(handler['shouldAddToFileMenu'](mockFile, 'file-explorer')).toBe(true);
});
});
});
/* eslint-enable @typescript-eslint/no-extraneous-class, @typescript-eslint/no-useless-constructor, @typescript-eslint/no-empty-function -- End of test file. */

View file

@ -0,0 +1,43 @@
import type { TFile } from 'obsidian';
import type { ReadonlyDeep } from 'type-fest';
import { FileCommandHandler } from 'obsidian-dev-utils/obsidian/command-handlers/file-command-handler';
import type { PluginSettings } from '../plugin-settings.ts';
interface InvokeCommandHandlerConstructorOptions {
checkIsMarkdownFile(this: void, file: TFile): boolean;
getSettings(this: void): ReadonlyDeep<PluginSettings>;
smartRename(this: void, file: TFile): Promise<void>;
}
export class InvokeCommandHandler extends FileCommandHandler {
private readonly checkIsMarkdownFile: (file: TFile) => boolean;
private readonly getSettings: () => ReadonlyDeep<PluginSettings>;
private readonly smartRename: (file: TFile) => Promise<void>;
public constructor(options: InvokeCommandHandlerConstructorOptions) {
super({
fileMenuItemName: 'Smart rename',
icon: 'edit-3',
id: 'invoke',
name: 'Invoke'
});
const { checkIsMarkdownFile, getSettings, smartRename } = options;
this.checkIsMarkdownFile = checkIsMarkdownFile;
this.getSettings = getSettings;
this.smartRename = smartRename;
}
protected override canExecuteFile(file: TFile): boolean {
return this.checkIsMarkdownFile(file) || this.getSettings().shouldSupportNonMarkdownFiles;
}
protected override async executeFile(file: TFile): Promise<void> {
await this.smartRename(file);
}
protected override shouldAddToFileMenu(_file: TFile, _source: string, _leaf?: unknown): boolean {
return true;
}
}

View file

@ -0,0 +1,27 @@
import {
describe,
expect,
it
} from 'vitest';
import { InvalidCharacterAction } from './invalid-character-action.ts';
describe('InvalidCharacterAction', () => {
it('should have Error value equal to "Error"', () => {
expect(InvalidCharacterAction.Error).toBe('Error');
});
it('should have Remove value equal to "Remove"', () => {
expect(InvalidCharacterAction.Remove).toBe('Remove');
});
it('should have Replace value equal to "Replace"', () => {
expect(InvalidCharacterAction.Replace).toBe('Replace');
});
it('should have exactly three members', () => {
const EXPECTED_MEMBER_COUNT = 3;
const values = Object.values(InvalidCharacterAction);
expect(values).toHaveLength(EXPECTED_MEMBER_COUNT);
});
});

View file

@ -1,3 +1,3 @@
import { Plugin } from './Plugin.ts';
import { Plugin } from './plugin.ts';
export default Plugin;

View file

@ -0,0 +1,76 @@
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';
import { PluginSettings } from './plugin-settings.ts';
const PluginSettingsComponentBaseMock = vi.hoisted(() => {
class MockBase {
public readonly validators = new Map<string, (value: string) => string | undefined>();
public constructor(_params: unknown) {
// Call registerValidators so subclass overrides can register their validators
this.registerValidators();
}
public registerValidator(key: string, fn: (value: string) => string | undefined): void {
this.validators.set(key, fn);
}
protected registerValidators(): void {
// Base no-op; subclass overrides this.
}
}
return MockBase;
});
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', () => {
function createComponent(hasInvalidCharacters: (str: string) => boolean): PluginSettingsComponent {
return new PluginSettingsComponent({
dataHandler: strictProxy<DataHandler>({}),
hasInvalidCharacters,
pluginEventSource: strictProxy<PluginEventSource>({}),
pluginSettingsClass: PluginSettings
});
}
it('should create an instance', () => {
const component = createComponent(() => false);
expect(component).toBeInstanceOf(PluginSettingsComponent);
});
it('should register a validator for replacementCharacter', () => {
// eslint-disable-next-line no-restricted-syntax -- test needs access to mock internals via double assertion.
const component = createComponent(() => false) as unknown as { validators: Map<string, (v: string) => string | undefined> };
expect(component.validators.has('replacementCharacter')).toBe(true);
});
it('should return error message when replacement character is invalid', () => {
// eslint-disable-next-line no-restricted-syntax -- test needs access to mock internals via double assertion.
const component = createComponent((str) => str === '/') as unknown as { validators: Map<string, (v: string) => string | undefined> };
const validator = component.validators.get('replacementCharacter');
expect(validator?.('/')).toBe('Invalid replacement character');
});
it('should return undefined when replacement character is valid', () => {
// eslint-disable-next-line no-restricted-syntax -- test needs access to mock internals via double assertion.
const component = createComponent(() => false) as unknown as { validators: Map<string, (v: string) => string | undefined> };
const validator = component.validators.get('replacementCharacter');
expect(validator?.('_')).toBeUndefined();
});
});

View file

@ -0,0 +1,28 @@
import type { PluginSettingsComponentBaseConstructorParams } from 'obsidian-dev-utils/obsidian/components/plugin-settings-component';
import type { MaybeReturn } from 'obsidian-dev-utils/type';
import { PluginSettingsComponentBase } from 'obsidian-dev-utils/obsidian/components/plugin-settings-component';
import { PluginSettings } from './plugin-settings.ts';
interface PluginSettingsComponentConstructorParams extends PluginSettingsComponentBaseConstructorParams<PluginSettings> {
hasInvalidCharacters(this: void, str: string): boolean;
}
export class PluginSettingsComponent extends PluginSettingsComponentBase<PluginSettings> {
private readonly hasInvalidCharacters: (str: string) => boolean;
public constructor(params: PluginSettingsComponentConstructorParams) {
super(params);
const { hasInvalidCharacters } = params;
this.hasInvalidCharacters = hasInvalidCharacters;
}
protected override registerValidators(): void {
this.registerValidator('replacementCharacter', (value): MaybeReturn<string> => {
if (this.hasInvalidCharacters(value)) {
return 'Invalid replacement character';
}
});
}
}

View file

@ -0,0 +1,222 @@
/* eslint-disable @typescript-eslint/no-empty-function, @typescript-eslint/no-useless-constructor, no-restricted-syntax -- 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 {
describe,
expect,
it,
vi
} from 'vitest';
import type { PluginSettings } from './plugin-settings.ts';
const hoisted = vi.hoisted(() => {
const boundKeys: string[] = [];
const onChangedCallbacks: (() => void)[] = [];
class PluginSettingsTabBaseMock {
public containerEl = activeDocument.createElement('div');
public pluginSettingsComponent = {
settings: {
// Use the string value directly since InvalidCharacterAction is not importable in hoisted context.
invalidCharacterAction: 'Error'
}
};
public constructor(_params: unknown) {}
public bind(component: unknown, key: string, options?: { onChanged?(): void }): unknown {
boundKeys.push(key);
if (options?.onChanged) {
onChangedCallbacks.push(options.onChanged);
}
return component;
}
public display(): void {}
}
return { boundKeys, onChangedCallbacks, PluginSettingsTabBaseMock };
});
vi.mock('obsidian-dev-utils/obsidian/plugin/plugin-settings-tab', () => ({
PluginSettingsTabBase: hoisted.PluginSettingsTabBaseMock
}));
vi.mock('obsidian-dev-utils/html-element', () => ({
appendCodeBlock: vi.fn()
}));
const MockSettingExHoisted = vi.hoisted(() => {
class MockDropdown {
public addOptions(_opts: Record<string, string>): this {
return this;
}
}
class MockText {
public inputEl = { maxLength: 0 };
}
class MockSettingEx {
public addDropdown(cb: (dropdown: MockDropdown) => void): this {
cb(new MockDropdown());
return this;
}
public addText(cb: (text: MockText) => void): this {
cb(new MockText());
return this;
}
public addToggle(cb: (toggle: object) => void): this {
cb({});
return this;
}
public setDesc(_desc: unknown): this {
return this;
}
public setDisabled(_disabled: boolean): this {
return this;
}
public setName(_name: string): this {
return this;
}
}
return { MockSettingEx };
});
vi.mock('obsidian-dev-utils/obsidian/setting-group-ex', () => ({
SettingGroupEx: class MockSettingGroupEx {
public constructor(_containerEl: HTMLElement) {}
public addSettingEx(cb: (setting: InstanceType<typeof MockSettingExHoisted.MockSettingEx>) => void): this {
cb(new MockSettingExHoisted.MockSettingEx());
return this;
}
public setHeading(_heading: string): this {
return this;
}
}
}));
vi.mock('obsidian-dev-utils/obsidian/setting-ex', () => ({
SettingEx: MockSettingExHoisted.MockSettingEx
}));
// eslint-disable-next-line import-x/first, import-x/imports-first -- vi.mock must precede imports.
import { PluginSettingsTab } from './plugin-settings-tab.ts';
describe('PluginSettingsTab', () => {
function createSettingsTab(): PluginSettingsTab {
return new PluginSettingsTab(castTo<PluginSettingsTabBaseConstructorParams<PluginSettings>>({}));
}
it('should create an instance', () => {
const tab = createSettingsTab();
expect(tab).toBeInstanceOf(PluginSettingsTab);
});
it('should render settings when display() is called', () => {
const tab = createSettingsTab();
hoisted.boundKeys.length = 0;
// eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional.
tab.display();
expect(hoisted.boundKeys.length).toBeGreaterThan(0);
});
it('should bind invalidCharacterAction setting', () => {
const tab = createSettingsTab();
hoisted.boundKeys.length = 0;
// eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional.
tab.display();
expect(hoisted.boundKeys).toContain('invalidCharacterAction');
});
it('should bind replacementCharacter setting', () => {
const tab = createSettingsTab();
hoisted.boundKeys.length = 0;
// eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional.
tab.display();
expect(hoisted.boundKeys).toContain('replacementCharacter');
});
it('should bind shouldUpdateTitleKey setting', () => {
const tab = createSettingsTab();
hoisted.boundKeys.length = 0;
// eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional.
tab.display();
expect(hoisted.boundKeys).toContain('shouldUpdateTitleKey');
});
it('should bind shouldStoreInvalidTitle setting', () => {
const tab = createSettingsTab();
hoisted.boundKeys.length = 0;
// eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional.
tab.display();
expect(hoisted.boundKeys).toContain('shouldStoreInvalidTitle');
});
it('should bind shouldPreservePreviousDisplayTextInNoteLinks setting', () => {
const tab = createSettingsTab();
hoisted.boundKeys.length = 0;
// eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional.
tab.display();
expect(hoisted.boundKeys).toContain('shouldPreservePreviousDisplayTextInNoteLinks');
});
it('should bind shouldPreservePreviousDisplayTextInFrontmatterLinks setting', () => {
const tab = createSettingsTab();
hoisted.boundKeys.length = 0;
// eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional.
tab.display();
expect(hoisted.boundKeys).toContain('shouldPreservePreviousDisplayTextInFrontmatterLinks');
});
it('should bind shouldUpdateFirstHeader setting', () => {
const tab = createSettingsTab();
hoisted.boundKeys.length = 0;
// eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional.
tab.display();
expect(hoisted.boundKeys).toContain('shouldUpdateFirstHeader');
});
it('should bind shouldSupportNonMarkdownFiles setting', () => {
const tab = createSettingsTab();
hoisted.boundKeys.length = 0;
// eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional.
tab.display();
expect(hoisted.boundKeys).toContain('shouldSupportNonMarkdownFiles');
});
it('should call display again when onChanged fires for invalidCharacterAction', () => {
const tab = createSettingsTab();
hoisted.boundKeys.length = 0;
hoisted.onChangedCallbacks.length = 0;
const displaySpy = vi.spyOn(tab, 'display');
// eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional.
tab.display();
const callback = hoisted.onChangedCallbacks[0];
expect(callback).toBeDefined();
callback?.();
expect(displaySpy).toHaveBeenCalledTimes(2);
});
});
/* eslint-enable @typescript-eslint/no-empty-function, @typescript-eslint/no-useless-constructor, no-restricted-syntax -- End of test file. */

View file

@ -1,15 +1,15 @@
import { appendCodeBlock } from 'obsidian-dev-utils/html-element';
import { PluginSettingsTabBase } from 'obsidian-dev-utils/obsidian/plugin/plugin-settings-tab-base';
import { PluginSettingsTabBase } from 'obsidian-dev-utils/obsidian/plugin/plugin-settings-tab';
import { SettingGroupEx } from 'obsidian-dev-utils/obsidian/setting-group-ex';
import type { PluginTypes } from './PluginTypes.ts';
import type { PluginSettings } from './plugin-settings.ts';
import { InvalidCharacterAction } from './InvalidCharacterAction.ts';
import { InvalidCharacterAction } from './invalid-character-action.ts';
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();
this.containerEl.empty();
new SettingGroupEx(this.containerEl)
.setHeading('Invalid characters')
@ -25,6 +25,7 @@ export class PluginSettingsTab extends PluginSettingsTabBase<PluginTypes> {
});
this.bind(dropdown, 'invalidCharacterAction', {
onChanged: () => {
// eslint-disable-next-line @typescript-eslint/no-deprecated -- this.display() re-renders the tab to refresh dependent settings; the inherited @deprecated tag on Obsidian's SettingTab.display propagates via TS getJsDocTags.
this.display();
}
});
@ -42,7 +43,7 @@ export class PluginSettingsTab extends PluginSettingsTabBase<PluginTypes> {
appendCodeBlock(f, 'Replace invalid characters');
f.appendText('.');
}))
.setDisabled(this.plugin.settings.invalidCharacterAction !== InvalidCharacterAction.Replace)
.setDisabled(this.pluginSettingsComponent.settings.invalidCharacterAction !== InvalidCharacterAction.Replace)
.addText((text) => {
text.inputEl.maxLength = 1;
this.bind(text, 'replacementCharacter');

View file

@ -0,0 +1,50 @@
import {
describe,
expect,
it
} from 'vitest';
import { InvalidCharacterAction } from './invalid-character-action.ts';
import { PluginSettings } from './plugin-settings.ts';
describe('PluginSettings', () => {
it('should default invalidCharacterAction to Error', () => {
const settings = new PluginSettings();
expect(settings.invalidCharacterAction).toBe(InvalidCharacterAction.Error);
});
it('should default replacementCharacter to underscore', () => {
const settings = new PluginSettings();
expect(settings.replacementCharacter).toBe('_');
});
it('should default shouldPreservePreviousDisplayTextInFrontmatterLinks to true', () => {
const settings = new PluginSettings();
expect(settings.shouldPreservePreviousDisplayTextInFrontmatterLinks).toBe(true);
});
it('should default shouldPreservePreviousDisplayTextInNoteLinks to true', () => {
const settings = new PluginSettings();
expect(settings.shouldPreservePreviousDisplayTextInNoteLinks).toBe(true);
});
it('should default shouldStoreInvalidTitle to true', () => {
const settings = new PluginSettings();
expect(settings.shouldStoreInvalidTitle).toBe(true);
});
it('should default shouldSupportNonMarkdownFiles to true', () => {
const settings = new PluginSettings();
expect(settings.shouldSupportNonMarkdownFiles).toBe(true);
});
it('should default shouldUpdateFirstHeader to false', () => {
const settings = new PluginSettings();
expect(settings.shouldUpdateFirstHeader).toBe(false);
});
it('should default shouldUpdateTitleKey to false', () => {
const settings = new PluginSettings();
expect(settings.shouldUpdateTitleKey).toBe(false);
});
});

View file

@ -1,4 +1,4 @@
import { InvalidCharacterAction } from './InvalidCharacterAction.ts';
import { InvalidCharacterAction } from './invalid-character-action.ts';
export class PluginSettings {
public invalidCharacterAction = InvalidCharacterAction.Error;

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

1132
src/plugin.test.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,15 @@
import type { Reference } from 'obsidian';
import type { CustomArrayDict } from '@obsidian-typings/obsidian-public-latest';
import type {
App,
PluginManifest,
Reference
} from 'obsidian';
import type { GenerateMarkdownLinkParams } from 'obsidian-dev-utils/obsidian/link';
import type { CustomArrayDict } from 'obsidian-typings';
import {
isFrontmatterLinkCache,
isReferenceCache
} from '@obsidian-typings/obsidian-public-latest/implementations';
import {
Notice,
TFile
@ -10,6 +18,12 @@ import {
normalizeOptionalProperties,
toJson
} from 'obsidian-dev-utils/object-utils';
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 { 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 {
addAlias,
processFrontmatter
@ -28,7 +42,7 @@ import {
getCacheSafe
} from 'obsidian-dev-utils/obsidian/metadata-cache';
import { prompt } from 'obsidian-dev-utils/obsidian/modals/prompt';
import { PluginBase } from 'obsidian-dev-utils/obsidian/plugin/plugin-base';
import { PluginBase } from 'obsidian-dev-utils/obsidian/plugin/plugin';
import { addToQueue } from 'obsidian-dev-utils/obsidian/queue';
import { getOsAndObsidianUnsafePathCharsRegExp } from 'obsidian-dev-utils/obsidian/validation';
import { process } from 'obsidian-dev-utils/obsidian/vault';
@ -39,19 +53,54 @@ import {
makeFileName
} from 'obsidian-dev-utils/path';
import { insertAt } from 'obsidian-dev-utils/string';
import {
isFrontmatterLinkCache,
isReferenceCache
} from 'obsidian-typings/implementations';
import type { PluginTypes } from './PluginTypes.ts';
import { InvokeCommandHandler } from './command-handlers/invoke-command-handler.ts';
import { InvalidCharacterAction } from './invalid-character-action.ts';
import { PluginSettingsComponent } from './plugin-settings-component.ts';
import { PluginSettingsTab } from './plugin-settings-tab.ts';
import { PluginSettings } from './plugin-settings.ts';
import { InvokeCommand } from './Commands/InvokeCommand.ts';
import { InvalidCharacterAction } from './InvalidCharacterAction.ts';
import { PluginSettingsManager } from './PluginSettingsManager.ts';
import { PluginSettingsTab } from './PluginSettingsTab.ts';
export class Plugin extends PluginBase {
private readonly pluginSettingsComponent: PluginSettingsComponent;
public constructor(app: App, manifest: PluginManifest) {
super(app, manifest);
const dataHandler = new PluginDataHandler(this);
this.pluginSettingsComponent = this.addChild(
new PluginSettingsComponent({
dataHandler,
hasInvalidCharacters: this.hasInvalidCharacters.bind(this),
pluginEventSource: this,
pluginSettingsClass: PluginSettings
})
);
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({
checkIsMarkdownFile: (file): boolean => isMarkdownFile(app, file),
getSettings: (): PluginSettings => this.pluginSettingsComponent.settings,
smartRename: this.smartRename.bind(this)
})
],
commandRegistrar: new PluginCommandRegistrar(this),
menuEventRegistrar,
pluginName: manifest.name
})
);
}
export class Plugin extends PluginBase<PluginTypes> {
public hasInvalidCharacters(str: string): boolean {
return getOsAndObsidianUnsafePathCharsRegExp().test(str);
}
@ -67,7 +116,7 @@ export class Plugin extends PluginBase<PluginTypes> {
let titleToStore = newTitle;
if (this.hasInvalidCharacters(newTitle)) {
switch (this.settings.invalidCharacterAction) {
switch (this.pluginSettingsComponent.settings.invalidCharacterAction) {
case InvalidCharacterAction.Error:
new Notice('The new title has invalid characters');
return;
@ -75,14 +124,14 @@ export class Plugin extends PluginBase<PluginTypes> {
newTitle = this.replaceInvalidCharacters(newTitle, '');
break;
case InvalidCharacterAction.Replace:
newTitle = this.replaceInvalidCharacters(newTitle, this.settings.replacementCharacter);
newTitle = this.replaceInvalidCharacters(newTitle, this.pluginSettingsComponent.settings.replacementCharacter);
break;
default:
throw new Error('Invalid character action');
}
}
if (!this.settings.shouldStoreInvalidTitle) {
if (!this.pluginSettingsComponent.settings.shouldStoreInvalidTitle) {
titleToStore = newTitle;
}
@ -114,24 +163,11 @@ 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 onloadImpl(): Promise<void> {
await super.onloadImpl();
new InvokeCommand(this).register();
}
private async addAliases(newPath: string, oldTitle: string, titleToStore: string): Promise<void> {
const newTitle = basename(newPath, extname(newPath));
await addAlias(this.app, newPath, oldTitle);
if (this.settings.shouldStoreInvalidTitle && titleToStore !== newTitle) {
if (this.pluginSettingsComponent.settings.shouldStoreInvalidTitle && titleToStore !== newTitle) {
await addAlias(this.app, newPath, titleToStore);
}
}
@ -183,8 +219,8 @@ export class Plugin extends PluginBase<PluginTypes> {
}
const isNewTitle = (link.displayText ?? '').toLowerCase() === newTitle.toLowerCase();
const shouldPreservePreviousDisplayText = (isReferenceCache(link) && this.settings.shouldPreservePreviousDisplayTextInNoteLinks)
|| (isFrontmatterLinkCache(link) && this.settings.shouldPreservePreviousDisplayTextInFrontmatterLinks);
const shouldPreservePreviousDisplayText = (isReferenceCache(link) && this.pluginSettingsComponent.settings.shouldPreservePreviousDisplayTextInNoteLinks)
|| (isFrontmatterLinkCache(link) && this.pluginSettingsComponent.settings.shouldPreservePreviousDisplayTextInFrontmatterLinks);
const alias = isNewTitle && shouldPreservePreviousDisplayText ? oldTitle : link.displayText;
@ -217,11 +253,11 @@ export class Plugin extends PluginBase<PluginTypes> {
}
private async updateFirstHeader(newPath: string, titleToStore: string): Promise<void> {
if (!this.settings.shouldUpdateFirstHeader) {
if (!this.pluginSettingsComponent.settings.shouldUpdateFirstHeader) {
return;
}
await process(this.app, newPath, async (abortSignal, content) => {
await process(this.app, newPath, async ({ abortSignal, content }) => {
abortSignal.throwIfAborted();
const cache = await getCacheSafe(this.app, newPath);
abortSignal.throwIfAborted();
@ -239,7 +275,7 @@ export class Plugin extends PluginBase<PluginTypes> {
}
private async updateTitle(newPath: string, titleToStore: string): Promise<void> {
if (!this.settings.shouldUpdateTitleKey) {
if (!this.pluginSettingsComponent.settings.shouldUpdateTitleKey) {
return;
}
await processFrontmatter(this.app, newPath, (frontMatter) => {

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

3
vitest.config.ts Normal file
View file

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