mirror of
https://github.com/mnaoumov/obsidian-smart-rename.git
synced 2026-07-22 07:40:32 +00:00
chore: update template
This commit is contained in:
parent
bae3c27b55
commit
40666fe33f
40 changed files with 6798 additions and 5904 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
|
||||
|
|
|
|||
|
|
@ -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
1
CLAUDE.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# CLAUDE.md
|
||||
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-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.
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
[](https://www.buymeacoffee.com/mnaoumov)
|
||||
[](https://github.com/mnaoumov/obsidian-smart-rename/releases)
|
||||
[](https://github.com/mnaoumov/obsidian-smart-rename/releases)
|
||||
[](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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
10440
package-lock.json
generated
10440
package-lock.json
generated
File diff suppressed because it is too large
Load diff
75
package.json
75
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
97
src/command-handlers/invoke-command-handler.test.ts
Normal file
97
src/command-handlers/invoke-command-handler.test.ts
Normal 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. */
|
||||
43
src/command-handlers/invoke-command-handler.ts
Normal file
43
src/command-handlers/invoke-command-handler.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
27
src/invalid-character-action.test.ts
Normal file
27
src/invalid-character-action.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
import { Plugin } from './Plugin.ts';
|
||||
import { Plugin } from './plugin.ts';
|
||||
|
||||
export default Plugin;
|
||||
|
|
|
|||
76
src/plugin-settings-component.test.ts
Normal file
76
src/plugin-settings-component.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
28
src/plugin-settings-component.ts
Normal file
28
src/plugin-settings-component.ts
Normal 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';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
222
src/plugin-settings-tab.test.ts
Normal file
222
src/plugin-settings-tab.test.ts
Normal 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. */
|
||||
|
|
@ -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');
|
||||
50
src/plugin-settings.test.ts
Normal file
50
src/plugin-settings.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { InvalidCharacterAction } from './InvalidCharacterAction.ts';
|
||||
import { InvalidCharacterAction } from './invalid-character-action.ts';
|
||||
|
||||
export class PluginSettings {
|
||||
public invalidCharacterAction = InvalidCharacterAction.Error;
|
||||
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();
|
||||
});
|
||||
});
|
||||
1132
src/plugin.test.ts
Normal file
1132
src/plugin.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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) => {
|
||||
|
|
@ -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
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