Add pre-commit hooks with formatting, linting, type checking and tests

This commit is contained in:
larsbuecker 2025-09-26 12:29:37 +02:00
parent c441bb72d2
commit f37bb5304b
62 changed files with 19027 additions and 17417 deletions

View file

@ -15,9 +15,13 @@
},
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off"
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"no-case-declarations": "off",
"@typescript-eslint/no-var-requires": "off"
}
}

View file

@ -3,12 +3,12 @@ name: Release Obsidian plugin
on:
push:
tags:
- "*"
- '*'
jobs:
build:
runs-on: ubuntu-latest
needs: test # Wartet auf erfolgreiche Tests
needs: test # Wartet auf erfolgreiche Tests
steps:
- uses: actions/checkout@v3
@ -16,7 +16,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: "18.x"
node-version: '18.x'
- name: Build plugin
run: |
@ -38,13 +38,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: "18.x"
node-version: '18.x'
- name: Run tests
run: |
npm install
npm test
npm test

View file

@ -2,25 +2,25 @@ name: Test Pipeline
on:
push:
branches: [ main, master, dev ]
branches: [main, master, dev]
pull_request:
branches: [ main, master, dev ]
branches: [main, master, dev]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test

10
.husky/pre-commit Normal file
View file

@ -0,0 +1,10 @@
#!/usr/bin/env sh
# Run lint-staged
npx lint-staged
# Run TypeScript type checking
npm run type-check
# Run tests
npm test

7
.prettierignore Normal file
View file

@ -0,0 +1,7 @@
node_modules/
coverage/
*.min.js
main.js
manifest.json
versions.json
data.json

11
.prettierrc Normal file
View file

@ -0,0 +1,11 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf"
}

View file

@ -1,11 +1,14 @@
# Changelog
## [0.4.2](https://github.com/bueckerlars/obsidian-note-mover-shortcut/compare/0.4.1...0.4.2)
### Features
- Added an option for "on-edit" note movement
- Changed the filter whitelist/blacklist toggle to a dropdown
- Changed the filter whitelist/blacklist toggle to a dropdown
## Changes
- Renamed the periodic movement settings section to trigger section
- Implemented an event system for periodic movements and on-edit movement
@ -14,16 +17,20 @@
> Attention: Breaking Changes
### Changes
- Removed inbox and notes folder settings
- Removed inbox and notes folder settings
- Added setting for history rentention policy and dropdown in the history modal to select timespan of the entries shown
## [0.4.0](https://github.com/bueckerlars/obsidian-note-mover-shortcut/compare/0.3.4...0.4.0)
### Features
- Added a button for opening notes in the `History Modal`
- Added Wildcard and Regex matching for filenames in the rules
- Added Settings for importing and exporting settings
- Added Wildcard and Regex matching for filenames in the rules
- Added Settings for importing and exporting settings
### Changes
- Implemented comprehensive codebase refactoring for improved maintainability and performance
- Added new `FileMovementService` class for unified file movement operations
- Centralized file movement logic with plugin move tracking
@ -44,6 +51,7 @@
- Enhanced error reporting and user feedback
### Improvements
- Refactored all modal classes to extend `BaseModal` for consistency
- Enhanced error handling with standardized error creation and management
- Improved test coverage with comprehensive unit tests for new classes
@ -53,13 +61,16 @@
- Improved code organization and separation of concerns
### Bug Fixes
- Fixed various edge cases in file movement operations
- Improved error handling for folder creation failures
- Enhanced undo functionality for complex file operations
- Fixed issues with metadata extraction in edge cases
## [0.3.4](https://github.com/bueckerlars/obsidian-note-mover-shortcut/compare/0.3.3...0.3.4)
### Features
- Added "Only move notes with rules" option #21
- New toggle in Rules settings to control note movement behavior
- When enabled: Only notes matching defined rules will be moved, others remain untouched
@ -67,25 +78,32 @@
- Provides flexibility for users who want selective note processing based on rules
### Improvements
- Enhanced rule processing logic to support selective note movement
- Updated preview functionality to reflect the new movement behavior
- Improved user experience with clear option descriptions and conditional UI display
## [0.3.3](https://github.com/bueckerlars/obsidian-note-mover-shortcut/compare/0.3.2...0.3.3)
### Features
- Implemented file move preview functionality to show which files will be moved before execution
- Added bulk undo functionality to history for reverting multiple movements at once
- Added custom confirmation modal
### Bug Fixes
- Fixed bug where undo was failing with notes that are moved to subfolders by rules
- Small styling fixes for preview modal
### Improvements
- Enhanced test coverage and improved mock data in tests
## [0.3.2](https://github.com/bueckerlars/obsidian-note-mover-shortcut/compare/0.3.1...0.3.2)
### Features
- Added support for Properties (Frontmatter) in Rules and Filters #20
- New `property:` criteria type for filtering and moving notes based on frontmatter metadata
- Support for exact value matching (`property:key:value`) and existence checking (`property:key`)
@ -96,17 +114,22 @@
- Seamless UX with automatic colon insertion after property key selection
### Improvements
- Extended AdvancedSuggest with comprehensive property value tracking
- Updated UI placeholders and descriptions to include property examples
- Added comprehensive test coverage for property-based functionality
## [0.3.1](https://github.com/bueckerlars/obsidian-note-mover-shortcut/compare/0.3.0...0.3.1)
### Features
- Implemented support for subtags in rules #19
- Implemented creation of destination folders that do not exist when moving notes
## [0.3.0](https://github.com/bueckerlars/obsidian-note-mover-shortcut/compare/0.2.1...0.3.0)
### Features
- Implemented update modal that shows changelog information for new versions
- Added advanced filter system with intelligent suggestors for folders and tags
- Implemented advanced suggest system for rule settings
@ -114,26 +137,35 @@
- Command to manually show update modal for viewing changelog
### Improvements
- Refactored rule code to make iterations and maintenance easier
- Improved test coverage and updated test implementation for new filter settings
- Enhanced user experience with better autocomplete suggestions
## [0.2.1](https://github.com/bueckerlars/obsidian-note-mover-shortcut/compare/0.2.0...0.2.1)
### Bug Fixes
- Fixed config gets overwrited on history changes #17
## [0.2.0](https://github.com/bueckerlars/obsidian-note-mover-shortcut/compare/0.1.7...0.2.0)
### Features
- Implemented movement history
- Added modal to show the history and revert movements
- Added Notice for single move command with undo option
## [0.1.7](https://github.com/bueckerlars/obsidian-note-mover-shortcut/compare/0.1.6...0.1.7)
### Bug Fixes
- Fixed issues mentioned in PR obsidianmd/obsidian-releases#6028
## [0.1.6](https://github.com/bueckerlars/obsidian-note-mover-shortcut/compare/0.1.5...0.1.6)
### Bug Fixes
- Removed path import for mobile support
- Refactored suggestors with AbstractInputSuggest
- Use getAllTags() method for getting tags to insure tags are used from file and frontmatter
@ -141,22 +173,27 @@
- Removed use of innerHTML from log functions
## [0.1.5](https://github.com/bueckerlars/obsidian-note-mover-shortcut/compare/0.1.4...0.1.5)
### Features
- Added periodic movement options to settings
- Implemented timer function
- Added filter options to settings
- Added heading to periodic movement setting
- Added periodic movement options to settings
- Implemented timer function
- Added filter options to settings
- Added heading to periodic movement setting
- Implemented filter setting
- Added periodic movement enabled on plugin startup
### Bug Fixes
- Fixed skip if whitelist and no tags
- Fixed filter code and added skip option for manuell note movement
- Fixed typo in settings
- Fixed interaval reset
## [0.1.4](https://github.com/bueckerlars/obsidian-note-mover-shortcut/compare/0.1.4...0.1.5)
### Features
- Added rules section to settings
- Added TagSuggest
- Implemented note movement based on rules
@ -166,9 +203,11 @@
## [0.1.3](https://github.com/bueckerlars/obsidian-note-mover-shortcut/compare/0.1.2...0.1.3) (2025-01-03)
### Bug Fixes
- Renamed setting for notes folder
- Set default value for notes folder to vault root
### Features
- Added inbox folder setting
- Added command to move all files from inbox to notes folder
- Added command to move all files from inbox to notes folder

View file

@ -1,28 +1,37 @@
# NoteMover Shortcut
NoteMover Shortcut is a plugin for [Obsidian](https://obsidian.md).
## Description
The "NoteMover Shortcut" plugin streamlines your file organization in Obsidian. It offers a suite of shortcuts to:
- **Move Single Files**: Swiftly relocate the currently open file to an appropriate destination folder based on rules and filters.
- **Batch Move Files**: Efficiently transfer all files in your vault to appropriate target folders based on various criteria including tags, properties, file names, content, paths, and dates.
![Plugin Overview](images/noteMover-settings-overview.png)
## Configuration
### Settings:
#### Trigger Settings
![Periodic Movement Settings](images/noteMover-settings-trigger.png)
- **Enable on-edit trigger**: When enabled, the plugin listens for file modifications (on edit) and automatically checks the edited note against your rules and filters. If a positive match is found, the note is moved immediately after the edit.
- **Enable periodic movement**: When enabled, the plugin will automatically move all files in your vault at regular intervals.
- **Periodic movement interval**: Set the interval in minutes between automatic file movements (minimum: 1 minute).
Notes:
- The on-edit trigger only reacts to Markdown files and runs the same rule- and filter-based movement logic as manual commands.
- Periodic movement has been refactored to use the same unified event handler as the on-edit trigger, ensuring consistent behavior and a single orchestration point.
#### Filter Settings
![Filter Settings](images/noteMover-settings-filter.png)
- **Toggle blacklist/whitelist**: Choose between:
- **Blacklist**: Move all files EXCEPT those matching the specified criteria
- **Whitelist**: Move ONLY files matching the specified criteria
@ -39,7 +48,9 @@ Notes:
- **Update Date**: `updated_at: date` - Match files based on last modification date
#### Rules
![Rules Configuration](images/noteMover-settings-rules.png)
- **Rules description**: Define custom rules for moving files based on various criteria. Rules are always active.
- **Only move files with rules**: When enabled, only files matching defined rules will be moved, others remain untouched. When disabled, files without matching rules are moved to the root folder.
- **Rule configuration**: For each rule, specify:
@ -67,15 +78,18 @@ Notes:
### Example Configurations
#### Advanced Rule Examples
You can use different criteria types for sophisticated file organization:
**Filter Examples**:
- **Whitelist**: `tag: #inbox`, `property: status:draft`, `fileName: *.json`
- Only moves files with the inbox tag, OR draft status, OR JSON files
- **Blacklist**: `content: private`, `path: Archive/`
- Moves all files EXCEPT those containing "private" or located in Archive folder
**Rules Examples**:
1. `property: type:meeting``Meetings/`
2. `fileName: Daily*``Daily Notes/`
3. `tag: work/urgent``Work/Priority/`
@ -84,6 +98,7 @@ You can use different criteria types for sophisticated file organization:
6. `created_at: 2024-01-01``Archive/2024/`
**Complex Workflow Example**:
- **Filter (Whitelist)**: `tag: #process`, `property: status:ready`
- **Rules**:
1. `property: type:project``Projects/Active/`
@ -94,24 +109,29 @@ You can use different criteria types for sophisticated file organization:
This setup will only process files tagged with #process OR having status:ready, then move them based on their type, filename pattern, or content.
### Hotkeys:
- Set Hotkeys to the NoteMover Commands
## Usage
1. **Configuration:** Configure your rules and filters as described in the [Configuration section](#configuration)
2. **Open File:** Open the file you want to move (optional for bulk operations).
3. **Execute Command:** Execute one of the NoteMover commands from the command palette or use your configured shortcuts.
### Available Commands
![Available Commands](images/noteMover-commands.png)
The plugin provides the following 6 commands that can be accessed through the command palette or configured with custom hotkeys:
#### Move Active File
- **Command ID**: `trigger-note-movement`
- **Name**: "Move active note to note folder"
- **Description**: Moves the currently active file to an appropriate destination folder based on rules and filters. Includes preview functionality to show the destination before moving.
- **Usage**: Open the file you want to move and execute this command.
#### Bulk Move Files
- **Command ID**: `trigger-note-bulk-move`
- **Name**: "Move all files in vault"
- **Description**: Moves all files in your vault to their respective destination folders based on the current settings:
@ -123,7 +143,9 @@ The plugin provides the following 6 commands that can be accessed through the co
- **Usage**: Execute this command to process all files in your vault at once.
#### History and Undo
![History Modal](images/noteMover-modal-history.png)
- **Command ID**: `show-history`
- **Name**: "Show history"
- **Description**: Displays a history of all file movements performed by the plugin, allowing you to review and undo previous actions.
@ -135,7 +157,9 @@ The plugin provides the following 6 commands that can be accessed through the co
- Filter the history by date or operation type
#### Move Preview
![Move Preview Modal](images/noteMover-modal-movePreview.png)
- **Feature**: File Move Preview
- **Description**: Before executing any move operation, you can preview which files will be moved and where they will be relocated.
- **Benefits**:
@ -149,12 +173,14 @@ The plugin provides the following 6 commands that can be accessed through the co
- Option to proceed or cancel the operation
#### Show Update Modal
- **Command ID**: `show-update-modal`
- **Name**: "Show update modal"
- **Description**: Manually displays the update modal showing changelog information for the current version.
- **Usage**: Execute this command to view the changelog and update information, useful for reviewing new features and changes.
#### Preview Bulk Movement
- **Command ID**: `preview-bulk-movement`
- **Name**: "Preview bulk movement for all files"
- **Description**: Shows a preview of all files that would be moved in your vault without actually executing the move operation.
@ -165,6 +191,7 @@ The plugin provides the following 6 commands that can be accessed through the co
- Plan your bulk operations safely
#### Preview Active File Movement
- **Command ID**: `preview-note-movement`
- **Name**: "Preview active note movement"
- **Description**: Shows a preview of where the currently active file would be moved based on your current rules and settings.
@ -174,9 +201,11 @@ The plugin provides the following 6 commands that can be accessed through the co
- Make informed decisions about file placement
## Contributing:
This plugin is open-source. Contributions are welcome!
## Features
- **Active File Move**: Move the active file to an appropriate destination folder based on rules and filters with a single command
- **Bulk Move**: The ability to move all files in your vault to appropriate destination folders with a single command, saving you time and simplifying organization.
- **Advanced Criteria-Based Moving**: Move files to different destination folders based on various criteria including tags, properties (frontmatter), file names, content, paths, and dates, allowing for sophisticated file organization.

View file

@ -1,49 +1,49 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import esbuild from 'esbuild';
import process from 'process';
import builtins from 'builtin-modules';
const banner =
`/*
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === "production");
const prod = process.argv[2] === 'production';
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: prod,
banner: {
js: banner,
},
entryPoints: ['main.ts'],
bundle: true,
external: [
'obsidian',
'electron',
'@codemirror/autocomplete',
'@codemirror/collab',
'@codemirror/commands',
'@codemirror/language',
'@codemirror/lint',
'@codemirror/search',
'@codemirror/state',
'@codemirror/view',
'@lezer/common',
'@lezer/highlight',
'@lezer/lr',
...builtins,
],
format: 'cjs',
target: 'es2018',
logLevel: 'info',
sourcemap: prod ? false : 'inline',
treeShaking: true,
outfile: 'main.js',
minify: prod,
});
if (prod) {
await context.rebuild();
process.exit(0);
await context.rebuild();
process.exit(0);
} else {
await context.watch();
await context.watch();
}

View file

@ -26,4 +26,4 @@ module.exports = {
'src/types/__tests__/HistoryEntryType.test.ts',
],
setupFiles: ['<rootDir>/src/__tests__/setup.ts'],
};
};

128
main.ts
View file

@ -1,81 +1,85 @@
import { Plugin, TFile } from 'obsidian';
import { NoteMoverShortcut } from 'src/core/NoteMoverShortcut';
import { CommandHandler } from 'src/handlers/CommandHandler';
import { DEFAULT_SETTINGS, NoteMoverShortcutSettings, NoteMoverShortcutSettingsTab } from "src/settings/Settings";
import {
DEFAULT_SETTINGS,
NoteMoverShortcutSettings,
NoteMoverShortcutSettingsTab,
} from 'src/settings/Settings';
import { HistoryManager } from 'src/core/HistoryManager';
import { TriggerEventHandler } from 'src/core/TriggerEventHandler';
import { UpdateManager } from 'src/core/UpdateManager';
export default class NoteMoverShortcutPlugin extends Plugin {
public settings: NoteMoverShortcutSettings;
public noteMover: NoteMoverShortcut;
public command_handler: CommandHandler;
public historyManager: HistoryManager;
public updateManager: UpdateManager;
public triggerHandler: TriggerEventHandler;
public settings: NoteMoverShortcutSettings;
public noteMover: NoteMoverShortcut;
public command_handler: CommandHandler;
public historyManager: HistoryManager;
public updateManager: UpdateManager;
public triggerHandler: TriggerEventHandler;
async onload() {
await this.load_settings();
async onload() {
await this.load_settings();
this.historyManager = new HistoryManager(this);
this.historyManager.loadHistoryFromSettings();
this.updateManager = new UpdateManager(this);
this.noteMover = new NoteMoverShortcut(this);
this.triggerHandler = new TriggerEventHandler(this);
this.command_handler = new CommandHandler(this);
this.command_handler.setup();
this.historyManager = new HistoryManager(this);
this.historyManager.loadHistoryFromSettings();
this.updateManager = new UpdateManager(this);
this.noteMover = new NoteMoverShortcut(this);
this.triggerHandler = new TriggerEventHandler(this);
this.command_handler = new CommandHandler(this);
this.command_handler.setup();
const ribbonIconEl = this.addRibbonIcon('book-plus', 'NoteMover', (evt: MouseEvent) => {
this.noteMover.moveFocusedNoteToDestination();
});
this.addRibbonIcon('book-plus', 'NoteMover', (evt: MouseEvent) => {
this.noteMover.moveFocusedNoteToDestination();
});
this.addSettingTab(new NoteMoverShortcutSettingsTab(this));
this.addSettingTab(new NoteMoverShortcutSettingsTab(this));
// Initialize triggers
this.triggerHandler.togglePeriodic();
this.triggerHandler.toggleOnEditListener();
// Event listener for automatic history creation during manual file operations
this.setupVaultEventListeners();
// Initialize triggers
this.triggerHandler.togglePeriodic();
this.triggerHandler.toggleOnEditListener();
// Check for updates after complete loading
this.app.workspace.onLayoutReady(() => {
this.updateManager.checkForUpdates();
});
}
// Event listener for automatic history creation during manual file operations
this.setupVaultEventListeners();
onunload() {
// Event listeners are automatically removed when the plugin is unloaded
}
// Check for updates after complete loading
this.app.workspace.onLayoutReady(() => {
this.updateManager.checkForUpdates();
});
}
/**
* Setup of event listeners for automatic history creation
*/
private setupVaultEventListeners(): void {
// Monitor file renaming/moving
this.registerEvent(this.app.vault.on('rename', (file, oldPath) => {
// Only for Markdown files
if (file instanceof TFile && file.extension === 'md') {
this.historyManager.addEntryFromVaultEvent(
oldPath,
file.path,
file.name
);
}
}));
}
onunload() {
// Event listeners are automatically removed when the plugin is unloaded
}
async save_settings(): Promise<void> {
(this.settings as any).history = this.historyManager ? this.historyManager.getHistory() : [];
await this.saveData(this.settings);
}
/**
* Setup of event listeners for automatic history creation
*/
private setupVaultEventListeners(): void {
// Monitor file renaming/moving
this.registerEvent(
this.app.vault.on('rename', (file, oldPath) => {
// Only for Markdown files
if (file instanceof TFile && file.extension === 'md') {
this.historyManager.addEntryFromVaultEvent(
oldPath,
file.path,
file.name
);
}
})
);
}
async load_settings(): Promise<void> {
const savedData = await this.loadData();
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
savedData || {}
);
}
async save_settings(): Promise<void> {
(this.settings as any).history = this.historyManager
? this.historyManager.getHistory()
: [];
await this.saveData(this.settings);
}
async load_settings(): Promise<void> {
const savedData = await this.loadData();
this.settings = Object.assign({}, DEFAULT_SETTINGS, savedData || {});
}
}

20484
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,35 +1,53 @@
{
"name": "obsidian-note-mover-shortcut",
"version": "0.4.2",
"description": "Quickly and easily move notes to a predefined folder. Perfect for organizing your notes.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"keywords": [],
"author": "bueckerlars",
"license": "MIT",
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@types/jest": "^29.5.14",
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "^0.25.9",
"jest": "^30.0.0",
"jest-environment-jsdom": "^30.0.0",
"obsidian": "latest",
"ts-jest": "^29.4.0",
"tslib": "2.4.0",
"typescript": "4.7.4"
},
"dependencies": {
"@popperjs/core": "^2.11.8"
}
"name": "obsidian-note-mover-shortcut",
"version": "0.4.2",
"description": "Quickly and easily move notes to a predefined folder. Perfect for organizing your notes.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"type-check": "tsc --noEmit",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "eslint . --ext .ts",
"lint:fix": "eslint . --ext .ts --fix",
"prepare": "husky"
},
"keywords": [],
"author": "bueckerlars",
"license": "MIT",
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@types/jest": "^29.5.14",
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "^0.25.9",
"husky": "^9.1.7",
"jest": "^30.0.0",
"jest-environment-jsdom": "^30.0.0",
"lint-staged": "^16.2.1",
"obsidian": "latest",
"prettier": "^3.6.2",
"ts-jest": "^29.4.0",
"tslib": "2.4.0",
"typescript": "4.7.4"
},
"dependencies": {
"@popperjs/core": "^2.11.8"
},
"lint-staged": {
"*.{ts,js}": [
"eslint --fix --max-warnings 0",
"prettier --write"
],
"*.{json,md}": [
"prettier --write"
]
}
}

View file

@ -1,33 +1,35 @@
export class Notice {
noticeEl: HTMLElement;
constructor() {
this.noticeEl = document.createElement('div');
}
hide() {}
noticeEl: HTMLElement;
constructor() {
this.noticeEl = document.createElement('div');
}
hide() {}
}
export class AbstractInputSuggest<T> {
constructor(app: any, inputEl: HTMLElement) {}
getSuggestions(query: string): T[] { return []; }
renderSuggestion(value: T, el: HTMLElement): void {}
selectSuggestion(value: T): void {}
close(): void {}
constructor(app: any, inputEl: HTMLElement) {}
getSuggestions(query: string): T[] {
return [];
}
renderSuggestion(value: T, el: HTMLElement): void {}
selectSuggestion(value: T): void {}
close(): void {}
}
export class TFolder {
path: string = '';
name: string = '';
path = '';
name = '';
}
export class TFile {
path: string = '';
name: string = '';
stat: any = {};
path = '';
name = '';
stat: any = {};
}
export class TAbstractFile {
path: string = '';
name: string = '';
path = '';
name = '';
}
export const getAllTags = jest.fn(() => ['#test']);
export const getAllTags = jest.fn(() => ['#test']);

File diff suppressed because it is too large Load diff

View file

@ -2,29 +2,29 @@
// Mock for window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
// Mock for document.createRange
document.createRange = () => {
const range = {
setStart: () => {},
setEnd: () => {},
// Essential properties for Node
commonAncestorContainer: document.body
};
return range as any;
};
const range = {
setStart: () => {},
setEnd: () => {},
// Essential properties for Node
commonAncestorContainer: document.body,
};
return range as any;
};
// Empty export to make the file a module
export {};
export {};

View file

@ -7,13 +7,13 @@
// =============================================================================
export const HISTORY_CONSTANTS = {
MAX_HISTORY_ENTRIES: 50,
MAX_BULK_OPERATIONS: 20,
DUPLICATE_CHECK_WINDOW_MS: 1000, // 1 second window to detect duplicate entries
DEFAULT_RETENTION_POLICY: {
value: 30,
unit: 'days' as const
}
MAX_HISTORY_ENTRIES: 50,
MAX_BULK_OPERATIONS: 20,
DUPLICATE_CHECK_WINDOW_MS: 1000, // 1 second window to detect duplicate entries
DEFAULT_RETENTION_POLICY: {
value: 30,
unit: 'days' as const,
},
} as const;
// =============================================================================
@ -21,23 +21,23 @@ export const HISTORY_CONSTANTS = {
// =============================================================================
export const NOTIFICATION_CONSTANTS = {
DEFAULT_DURATIONS: {
info: 8000,
error: 15000,
update: 15000,
success: 5000,
warning: 10000,
} as const,
DEFAULT_DURATIONS: {
info: 8000,
error: 15000,
update: 15000,
success: 5000,
warning: 10000,
} as const,
DEFAULT_TITLES: {
info: "NoteMover info:",
error: "NoteMover error:",
update: "NoteMover update:",
success: "NoteMover success:",
warning: "NoteMover warning:",
} as const,
DEFAULT_TITLES: {
info: 'NoteMover info:',
error: 'NoteMover error:',
update: 'NoteMover update:',
success: 'NoteMover success:',
warning: 'NoteMover warning:',
} as const,
DURATION_OVERRIDE: 5000, // Used for specific notifications that need different duration
DURATION_OVERRIDE: 5000, // Used for specific notifications that need different duration
} as const;
// =============================================================================
@ -45,64 +45,67 @@ export const NOTIFICATION_CONSTANTS = {
// =============================================================================
export const SETTINGS_CONSTANTS = {
DEFAULT_SETTINGS: {
enablePeriodicMovement: false,
periodicMovementInterval: 5,
enableOnEditTrigger: false,
filter: [] as string[],
isFilterWhitelist: false,
rules: [] as any[],
onlyMoveNotesWithRules: false,
retentionPolicy: HISTORY_CONSTANTS.DEFAULT_RETENTION_POLICY,
},
DEFAULT_SETTINGS: {
enablePeriodicMovement: false,
periodicMovementInterval: 5,
enableOnEditTrigger: false,
filter: [] as string[],
isFilterWhitelist: false,
rules: [] as any[],
onlyMoveNotesWithRules: false,
retentionPolicy: HISTORY_CONSTANTS.DEFAULT_RETENTION_POLICY,
},
PLACEHOLDER_TEXTS: {
FOLDER_PATH: 'Example: folder1/folder2',
INTERVAL: '5',
FILTER: 'Filter (z.B. tag:, fileName:, path:, property:, ...)',
CRITERIA: 'Criteria (z.B. tag:, fileName:, path:, property:, ...)',
PATH: 'Path',
} as const,
PLACEHOLDER_TEXTS: {
FOLDER_PATH: 'Example: folder1/folder2',
INTERVAL: '5',
FILTER: 'Filter (z.B. tag:, fileName:, path:, property:, ...)',
CRITERIA: 'Criteria (z.B. tag:, fileName:, path:, property:, ...)',
PATH: 'Path',
} as const,
UI_TEXTS: {
UNDO_ALL: "Undo All",
UNDO: "Undo",
CLEAR_HISTORY: "Clear history",
CLEAR_HISTORY_TITLE: "Clear History",
CLEAR_HISTORY_MESSAGE: "Are you sure you want to clear the history?<br/><br/>This action cannot be undone.",
CLEAR_HISTORY_CONFIRM: "Clear History",
CLEAR_HISTORY_CANCEL: "Cancel",
EXPORT_SETTINGS: "Export Settings",
IMPORT_SETTINGS: "Import Settings",
EXPORT_SETTINGS_TITLE: "Export Settings",
EXPORT_SETTINGS_MESSAGE: "This will download your current settings as a JSON file.",
EXPORT_SETTINGS_CONFIRM: "Export",
EXPORT_SETTINGS_CANCEL: "Cancel",
IMPORT_SETTINGS_TITLE: "Import Settings",
IMPORT_SETTINGS_MESSAGE: "This will replace your current settings with the imported ones.<br/><br/>This action cannot be undone. Make sure you have a backup of your current settings.",
IMPORT_SETTINGS_CONFIRM: "Import",
IMPORT_SETTINGS_CANCEL: "Cancel",
IMPORT_SUCCESS: "Settings imported successfully",
IMPORT_ERROR: "Failed to import settings",
EXPORT_SUCCESS: "Settings exported successfully",
EXPORT_ERROR: "Failed to export settings",
INVALID_FILE: "Invalid file format. Please select a valid JSON file.",
VALIDATION_ERRORS: "Settings validation failed",
// History time filter texts
TIME_FILTER_ALL: "All",
TIME_FILTER_TODAY: "Today",
TIME_FILTER_WEEK: "This Week",
TIME_FILTER_MONTH: "This Month",
TIME_FILTER_LABEL: "Show entries from:",
// Retention policy texts
RETENTION_POLICY_TITLE: "Retention Policy",
RETENTION_POLICY_DESC: "Configure how long history entries should be kept",
RETENTION_POLICY_VALUE_LABEL: "Keep entries for:",
RETENTION_POLICY_UNIT_LABEL: "Unit:",
RETENTION_POLICY_DAYS: "Days",
RETENTION_POLICY_WEEKS: "Weeks",
RETENTION_POLICY_MONTHS: "Months",
} as const,
UI_TEXTS: {
UNDO_ALL: 'Undo All',
UNDO: 'Undo',
CLEAR_HISTORY: 'Clear history',
CLEAR_HISTORY_TITLE: 'Clear History',
CLEAR_HISTORY_MESSAGE:
'Are you sure you want to clear the history?<br/><br/>This action cannot be undone.',
CLEAR_HISTORY_CONFIRM: 'Clear History',
CLEAR_HISTORY_CANCEL: 'Cancel',
EXPORT_SETTINGS: 'Export Settings',
IMPORT_SETTINGS: 'Import Settings',
EXPORT_SETTINGS_TITLE: 'Export Settings',
EXPORT_SETTINGS_MESSAGE:
'This will download your current settings as a JSON file.',
EXPORT_SETTINGS_CONFIRM: 'Export',
EXPORT_SETTINGS_CANCEL: 'Cancel',
IMPORT_SETTINGS_TITLE: 'Import Settings',
IMPORT_SETTINGS_MESSAGE:
'This will replace your current settings with the imported ones.<br/><br/>This action cannot be undone. Make sure you have a backup of your current settings.',
IMPORT_SETTINGS_CONFIRM: 'Import',
IMPORT_SETTINGS_CANCEL: 'Cancel',
IMPORT_SUCCESS: 'Settings imported successfully',
IMPORT_ERROR: 'Failed to import settings',
EXPORT_SUCCESS: 'Settings exported successfully',
EXPORT_ERROR: 'Failed to export settings',
INVALID_FILE: 'Invalid file format. Please select a valid JSON file.',
VALIDATION_ERRORS: 'Settings validation failed',
// History time filter texts
TIME_FILTER_ALL: 'All',
TIME_FILTER_TODAY: 'Today',
TIME_FILTER_WEEK: 'This Week',
TIME_FILTER_MONTH: 'This Month',
TIME_FILTER_LABEL: 'Show entries from:',
// Retention policy texts
RETENTION_POLICY_TITLE: 'Retention Policy',
RETENTION_POLICY_DESC: 'Configure how long history entries should be kept',
RETENTION_POLICY_VALUE_LABEL: 'Keep entries for:',
RETENTION_POLICY_UNIT_LABEL: 'Unit:',
RETENTION_POLICY_DAYS: 'Days',
RETENTION_POLICY_WEEKS: 'Weeks',
RETENTION_POLICY_MONTHS: 'Months',
} as const,
} as const;
// =============================================================================
@ -110,12 +113,11 @@ export const SETTINGS_CONSTANTS = {
// =============================================================================
export const GENERAL_CONSTANTS = {
TIME_CONVERSIONS: {
MINUTES_TO_MILLISECONDS: 60 * 1000,
} as const,
TIME_CONVERSIONS: {
MINUTES_TO_MILLISECONDS: 60 * 1000,
} as const,
SUGGESTION_LIMITS: {
FOLDER_SUGGESTIONS: 1000,
} as const,
SUGGESTION_LIMITS: {
FOLDER_SUGGESTIONS: 1000,
} as const,
} as const;

View file

@ -1,302 +1,321 @@
import { App, TFile } from 'obsidian';
import { createError, handleError } from '../utils/Error';
import { NoticeManager } from '../utils/NoticeManager';
import { getParentPath, ensureFolderExists, combinePath } from '../utils/PathUtils';
import {
getParentPath,
ensureFolderExists,
combinePath,
} from '../utils/PathUtils';
import { HistoryEntry } from '../types/HistoryEntry';
import NoteMoverShortcutPlugin from 'main';
/**
* Central service for all file movement operations
*
*
* Provides unified API for moving files, undo operations, and batch movements.
* Eliminates code duplication between NoteMoverShortcut, HistoryManager, and PreviewModal.
*
*
* Features: plugin move tracking, folder creation, history integration, batch operations
*
*
* @since 0.4.0
*/
export class FileMovementService {
private isPluginMove = false; // Flag to detect internal plugin moves
private isPluginMove = false; // Flag to detect internal plugin moves
constructor(
private app: App,
private plugin: NoteMoverShortcutPlugin
) {}
constructor(
private app: App,
private plugin: NoteMoverShortcutPlugin
) {}
/**
* Marks start of internal plugin move (prevents history tracking loops)
*/
public markPluginMoveStart(): void {
this.isPluginMove = true;
/**
* Marks start of internal plugin move (prevents history tracking loops)
*/
public markPluginMoveStart(): void {
this.isPluginMove = true;
}
/**
* Marks end of internal plugin move (call in finally block)
*/
public markPluginMoveEnd(): void {
this.isPluginMove = false;
}
/**
* Checks if currently in a plugin move operation
*
* @returns True if plugin move in progress
*/
public isInPluginMove(): boolean {
return this.isPluginMove;
}
/**
* Validates that a file exists and is a TFile instance
*
* @param filePath - Path to validate
* @returns TFile if exists, null otherwise
*/
public validateFileExists(filePath: string): TFile | null {
const file = this.app.vault.getAbstractFileByPath(filePath);
if (!file) {
return null;
}
/**
* Marks end of internal plugin move (call in finally block)
*/
public markPluginMoveEnd(): void {
this.isPluginMove = false;
// In test environment, we don't have real TFile instances
// Check if it has the properties we expect from a TFile
if (typeof file === 'object' && 'path' in file && 'name' in file) {
return file as TFile;
}
return null;
}
/**
* Checks if currently in a plugin move operation
*
* @returns True if plugin move in progress
*/
public isInPluginMove(): boolean {
return this.isPluginMove;
}
/**
* Performs file move with error handling and tracking
*
* Handles folder creation, plugin move tracking, history, and notifications
*
* @param file - TFile to move
* @param targetPath - Complete target path
* @param options - Move configuration
* @returns True if successful
*/
public async moveFile(
file: TFile,
targetPath: string,
options: {
trackInHistory?: boolean;
showNotifications?: boolean;
originalPath?: string;
} = {}
): Promise<boolean> {
const {
trackInHistory = true,
showNotifications = false,
originalPath = file.path,
} = options;
/**
* Validates that a file exists and is a TFile instance
*
* @param filePath - Path to validate
* @returns TFile if exists, null otherwise
*/
public validateFileExists(filePath: string): TFile | null {
const file = this.app.vault.getAbstractFileByPath(filePath);
if (!file) {
return null;
}
// In test environment, we don't have real TFile instances
// Check if it has the properties we expect from a TFile
if (typeof file === 'object' && 'path' in file && 'name' in file) {
return file as TFile;
}
return null;
}
try {
// Ensure target folder exists
const targetFolder = getParentPath(targetPath);
if (targetFolder && !(await ensureFolderExists(this.app, targetFolder))) {
const error = `Failed to create target folder: ${targetFolder}`;
handleError(createError(error), 'moveFile', showNotifications);
return false;
}
// Mark plugin move start
this.markPluginMoveStart();
/**
* Performs file move with error handling and tracking
*
* Handles folder creation, plugin move tracking, history, and notifications
*
* @param file - TFile to move
* @param targetPath - Complete target path
* @param options - Move configuration
* @returns True if successful
*/
public async moveFile(
file: TFile,
targetPath: string,
options: {
trackInHistory?: boolean;
showNotifications?: boolean;
originalPath?: string;
} = {}
): Promise<boolean> {
const {
trackInHistory = true,
showNotifications = false,
originalPath = file.path
} = options;
try {
// Perform the actual file move
await this.app.fileManager.renameFile(file, targetPath);
try {
// Ensure target folder exists
const targetFolder = getParentPath(targetPath);
if (targetFolder && !await ensureFolderExists(this.app, targetFolder)) {
const error = `Failed to create target folder: ${targetFolder}`;
handleError(createError(error), "moveFile", showNotifications);
return false;
}
// Mark plugin move start
this.markPluginMoveStart();
try {
// Perform the actual file move
await this.app.fileManager.renameFile(file, targetPath);
// Add to history if requested
if (trackInHistory) {
this.plugin.historyManager.addEntry({
sourcePath: originalPath,
destinationPath: targetPath,
fileName: file.name
});
}
if (showNotifications) {
NoticeManager.success(`Moved ${file.name} to ${targetPath}`);
}
return true;
} finally {
// Always mark plugin move end, even on errors
this.markPluginMoveEnd();
}
} catch (error) {
const errorMsg = `Error moving file '${file.path}' to '${targetPath}'`;
handleError(error, errorMsg, showNotifications);
return false;
}
}
/**
* Moves file to folder (combines folder path with filename)
*
* @param file - TFile to move
* @param targetFolder - Target folder path
* @param options - Same as moveFile()
* @returns True if successful
*/
public async moveFileToFolder(
file: TFile,
targetFolder: string,
options: {
trackInHistory?: boolean;
showNotifications?: boolean;
originalPath?: string;
} = {}
): Promise<boolean> {
const targetPath = combinePath(targetFolder, file.name);
return this.moveFile(file, targetPath, options);
}
/**
* Undoes a move operation by moving file back to original location
*
* @param destinationPath - Current file location
* @param sourcePath - Original location to restore
* @param fileName - File name for notifications
* @param options - Undo configuration
* @returns True if successful
*/
public async undoMove(
destinationPath: string,
sourcePath: string,
fileName: string,
options: {
showNotifications?: boolean;
} = {}
): Promise<boolean> {
const { showNotifications = false } = options;
// Validate file exists at destination
const file = this.validateFileExists(destinationPath);
if (!file) {
const error = `File not found at path: ${destinationPath}`;
handleError(createError(error), "undoMove", showNotifications);
return false;
}
// Ensure source folder exists
const sourceFolder = getParentPath(sourcePath);
if (sourceFolder && !await ensureFolderExists(this.app, sourceFolder)) {
const error = `Failed to create source folder: ${sourceFolder}`;
handleError(createError(error), "undoMove", showNotifications);
return false;
}
try {
if (showNotifications) {
NoticeManager.info(`Attempting to move ${fileName} from ${destinationPath} to ${sourcePath}`);
}
// Mark plugin move start
this.markPluginMoveStart();
try {
// Perform the undo move
await this.app.fileManager.renameFile(file, sourcePath);
if (showNotifications) {
NoticeManager.success(`Successfully moved ${fileName} back to ${sourcePath}`);
}
return true;
} finally {
// Always mark plugin move end
this.markPluginMoveEnd();
}
} catch (error) {
const errorMsg = `Error during undo operation for ${fileName}`;
handleError(error, errorMsg, showNotifications);
return false;
}
}
/**
* Undoes a move using a HistoryEntry object
*
* @param entry - HistoryEntry with paths
* @param options - Same as undoMove()
* @returns True if successful
*/
public async undoMoveFromHistoryEntry(
entry: HistoryEntry,
options: {
showNotifications?: boolean;
} = {}
): Promise<boolean> {
return this.undoMove(
entry.destinationPath,
entry.sourcePath,
entry.fileName,
options
);
}
/**
* Batch move multiple files with progress tracking
*
* @param operations - Array of {file, targetPath, originalPath?}
* @param options - Batch configuration with optional progress callback
* @returns Results with success/failure counts and errors
*/
public async batchMoveFiles(
operations: Array<{
file: TFile;
targetPath: string;
originalPath?: string;
}>,
options: {
trackInHistory?: boolean;
showNotifications?: boolean;
onProgress?: (completed: number, total: number, currentFile: string) => void;
} = {}
): Promise<{ successful: number; failed: number; errors: string[] }> {
const {
trackInHistory = true,
showNotifications = false,
onProgress
} = options;
const results = {
successful: 0,
failed: 0,
errors: [] as string[]
};
for (let i = 0; i < operations.length; i++) {
const operation = operations[i];
if (onProgress) {
onProgress(i, operations.length, operation.file.name);
}
const success = await this.moveFile(operation.file, operation.targetPath, {
trackInHistory,
showNotifications: false, // Handle notifications at batch level
originalPath: operation.originalPath
});
if (success) {
results.successful++;
} else {
results.failed++;
results.errors.push(`Failed to move ${operation.file.name}`);
}
// Add to history if requested
if (trackInHistory) {
this.plugin.historyManager.addEntry({
sourcePath: originalPath,
destinationPath: targetPath,
fileName: file.name,
});
}
if (showNotifications) {
if (results.failed === 0) {
NoticeManager.success(`Successfully moved ${results.successful} files!`);
} else {
NoticeManager.warning(`Moved ${results.successful} files with ${results.failed} errors.`);
}
NoticeManager.success(`Moved ${file.name} to ${targetPath}`);
}
return results;
return true;
} finally {
// Always mark plugin move end, even on errors
this.markPluginMoveEnd();
}
} catch (error) {
const errorMsg = `Error moving file '${file.path}' to '${targetPath}'`;
handleError(error, errorMsg, showNotifications);
return false;
}
}
/**
* Moves file to folder (combines folder path with filename)
*
* @param file - TFile to move
* @param targetFolder - Target folder path
* @param options - Same as moveFile()
* @returns True if successful
*/
public async moveFileToFolder(
file: TFile,
targetFolder: string,
options: {
trackInHistory?: boolean;
showNotifications?: boolean;
originalPath?: string;
} = {}
): Promise<boolean> {
const targetPath = combinePath(targetFolder, file.name);
return this.moveFile(file, targetPath, options);
}
/**
* Undoes a move operation by moving file back to original location
*
* @param destinationPath - Current file location
* @param sourcePath - Original location to restore
* @param fileName - File name for notifications
* @param options - Undo configuration
* @returns True if successful
*/
public async undoMove(
destinationPath: string,
sourcePath: string,
fileName: string,
options: {
showNotifications?: boolean;
} = {}
): Promise<boolean> {
const { showNotifications = false } = options;
// Validate file exists at destination
const file = this.validateFileExists(destinationPath);
if (!file) {
const error = `File not found at path: ${destinationPath}`;
handleError(createError(error), 'undoMove', showNotifications);
return false;
}
// Ensure source folder exists
const sourceFolder = getParentPath(sourcePath);
if (sourceFolder && !(await ensureFolderExists(this.app, sourceFolder))) {
const error = `Failed to create source folder: ${sourceFolder}`;
handleError(createError(error), 'undoMove', showNotifications);
return false;
}
try {
if (showNotifications) {
NoticeManager.info(
`Attempting to move ${fileName} from ${destinationPath} to ${sourcePath}`
);
}
// Mark plugin move start
this.markPluginMoveStart();
try {
// Perform the undo move
await this.app.fileManager.renameFile(file, sourcePath);
if (showNotifications) {
NoticeManager.success(
`Successfully moved ${fileName} back to ${sourcePath}`
);
}
return true;
} finally {
// Always mark plugin move end
this.markPluginMoveEnd();
}
} catch (error) {
const errorMsg = `Error during undo operation for ${fileName}`;
handleError(error, errorMsg, showNotifications);
return false;
}
}
/**
* Undoes a move using a HistoryEntry object
*
* @param entry - HistoryEntry with paths
* @param options - Same as undoMove()
* @returns True if successful
*/
public async undoMoveFromHistoryEntry(
entry: HistoryEntry,
options: {
showNotifications?: boolean;
} = {}
): Promise<boolean> {
return this.undoMove(
entry.destinationPath,
entry.sourcePath,
entry.fileName,
options
);
}
/**
* Batch move multiple files with progress tracking
*
* @param operations - Array of {file, targetPath, originalPath?}
* @param options - Batch configuration with optional progress callback
* @returns Results with success/failure counts and errors
*/
public async batchMoveFiles(
operations: Array<{
file: TFile;
targetPath: string;
originalPath?: string;
}>,
options: {
trackInHistory?: boolean;
showNotifications?: boolean;
onProgress?: (
completed: number,
total: number,
currentFile: string
) => void;
} = {}
): Promise<{ successful: number; failed: number; errors: string[] }> {
const {
trackInHistory = true,
showNotifications = false,
onProgress,
} = options;
const results = {
successful: 0,
failed: 0,
errors: [] as string[],
};
for (let i = 0; i < operations.length; i++) {
const operation = operations[i];
if (onProgress) {
onProgress(i, operations.length, operation.file.name);
}
const success = await this.moveFile(
operation.file,
operation.targetPath,
{
trackInHistory,
showNotifications: false, // Handle notifications at batch level
originalPath: operation.originalPath,
}
);
if (success) {
results.successful++;
} else {
results.failed++;
results.errors.push(`Failed to move ${operation.file.name}`);
}
}
if (showNotifications) {
if (results.failed === 0) {
NoticeManager.success(
`Successfully moved ${results.successful} files!`
);
} else {
NoticeManager.warning(
`Moved ${results.successful} files with ${results.failed} errors.`
);
}
}
return results;
}
}

File diff suppressed because it is too large Load diff

View file

@ -8,124 +8,130 @@ import { handleError } from '../utils/Error';
* used throughout the application for rule matching and processing
*/
export class MetadataExtractor {
constructor(private app: App) {}
constructor(private app: App) {}
/**
* Extracts complete metadata from a file
* Handles errors gracefully and provides null values for unavailable data
*/
public async extractFileMetadata(file: TFile): Promise<FileMetadata> {
try {
// Basic file information
const fileName = file.name;
const filePath = file.path;
/**
* Extracts complete metadata from a file
* Handles errors gracefully and provides null values for unavailable data
*/
public async extractFileMetadata(file: TFile): Promise<FileMetadata> {
try {
// Basic file information
const fileName = file.name;
const filePath = file.path;
// File system dates
const createdAt = (file.stat && file.stat.ctime) ? new Date(file.stat.ctime) : null;
const updatedAt = (file.stat && file.stat.mtime) ? new Date(file.stat.mtime) : null;
// File system dates
const createdAt =
file.stat && file.stat.ctime ? new Date(file.stat.ctime) : null;
const updatedAt =
file.stat && file.stat.mtime ? new Date(file.stat.mtime) : null;
// Metadata cache
const fileCache = this.app.metadataCache.getFileCache(file);
const tags = getAllTags(fileCache || {}) || [];
const properties = fileCache?.frontmatter || {};
// Metadata cache
const fileCache = this.app.metadataCache.getFileCache(file);
const tags = getAllTags(fileCache || {}) || [];
const properties = fileCache?.frontmatter || {};
// File content (optional, with error handling)
let fileContent = "";
try {
fileContent = await this.app.vault.read(file);
} catch (error) {
// Content is optional - don't fail if we can't read it
// Could log this for debugging if needed
}
// File content (optional, with error handling)
let fileContent = '';
try {
fileContent = await this.app.vault.read(file);
} catch (error) {
// Content is optional - don't fail if we can't read it
// Could log this for debugging if needed
}
return {
fileName,
filePath,
tags,
properties,
fileContent,
createdAt,
updatedAt
};
} catch (error) {
handleError(
error,
`Error extracting metadata for file '${file.path}'`,
false
);
return {
fileName,
filePath,
tags,
properties,
fileContent,
createdAt,
updatedAt,
};
} catch (error) {
handleError(
error,
`Error extracting metadata for file '${file.path}'`,
false
);
// Return minimal metadata on error
return {
fileName: file.name,
filePath: file.path,
tags: [],
properties: {},
fileContent: "",
createdAt: null,
updatedAt: null
};
}
// Return minimal metadata on error
return {
fileName: file.name,
filePath: file.path,
tags: [],
properties: {},
fileContent: '',
createdAt: null,
updatedAt: null,
};
}
}
/**
* Extracts only basic metadata without content (faster)
* Useful when content is not needed for rule matching
*/
public async extractBasicMetadata(file: TFile): Promise<Omit<FileMetadata, 'fileContent'>> {
try {
const fileName = file.name;
const filePath = file.path;
const createdAt = (file.stat && file.stat.ctime) ? new Date(file.stat.ctime) : null;
const updatedAt = (file.stat && file.stat.mtime) ? new Date(file.stat.mtime) : null;
/**
* Extracts only basic metadata without content (faster)
* Useful when content is not needed for rule matching
*/
public async extractBasicMetadata(
file: TFile
): Promise<Omit<FileMetadata, 'fileContent'>> {
try {
const fileName = file.name;
const filePath = file.path;
const createdAt =
file.stat && file.stat.ctime ? new Date(file.stat.ctime) : null;
const updatedAt =
file.stat && file.stat.mtime ? new Date(file.stat.mtime) : null;
const fileCache = this.app.metadataCache.getFileCache(file);
const tags = getAllTags(fileCache || {}) || [];
const properties = fileCache?.frontmatter || {};
const fileCache = this.app.metadataCache.getFileCache(file);
const tags = getAllTags(fileCache || {}) || [];
const properties = fileCache?.frontmatter || {};
return {
fileName,
filePath,
tags,
properties,
createdAt,
updatedAt
};
} catch (error) {
handleError(
error,
`Error extracting basic metadata for file '${file.path}'`,
false
);
return {
fileName,
filePath,
tags,
properties,
createdAt,
updatedAt,
};
} catch (error) {
handleError(
error,
`Error extracting basic metadata for file '${file.path}'`,
false
);
return {
fileName: file.name,
filePath: file.path,
tags: [],
properties: {},
createdAt: null,
updatedAt: null
};
}
return {
fileName: file.name,
filePath: file.path,
tags: [],
properties: {},
createdAt: null,
updatedAt: null,
};
}
}
/**
* Extracts tags from all markdown files synchronously
* Returns a Set of unique tags for faster lookup
*/
public extractAllTags(): Set<string> {
const tags = new Set<string>();
const files = this.app.vault.getMarkdownFiles();
/**
* Extracts tags from all markdown files synchronously
* Returns a Set of unique tags for faster lookup
*/
public extractAllTags(): Set<string> {
const tags = new Set<string>();
const files = this.app.vault.getMarkdownFiles();
files.forEach(file => {
try {
const fileCache = this.app.metadataCache.getFileCache(file);
const fileTags = getAllTags(fileCache || {}) || [];
fileTags.forEach(tag => tags.add(tag));
} catch (error) {
// Silently ignore files that can't be processed
}
});
files.forEach(file => {
try {
const fileCache = this.app.metadataCache.getFileCache(file);
const fileTags = getAllTags(fileCache || {}) || [];
fileTags.forEach(tag => tags.add(tag));
} catch (error) {
// Silently ignore files that can't be processed
}
});
return tags;
}
return tags;
}
}

View file

@ -1,4 +1,4 @@
import { App, TFile } from 'obsidian';
import { TFile } from 'obsidian';
import { NoticeManager } from '../utils/NoticeManager';
import { RuleManager } from './RuleManager';
import { createError, handleError } from '../utils/Error';
@ -6,238 +6,272 @@ import { combinePath, ensureFolderExists } from '../utils/PathUtils';
import NoteMoverShortcutPlugin from 'main';
import { type OperationType } from '../types/Common';
import { MovePreview } from '../types/MovePreview';
import { SETTINGS_CONSTANTS, GENERAL_CONSTANTS, NOTIFICATION_CONSTANTS } from '../config/constants';
import {
SETTINGS_CONSTANTS,
NOTIFICATION_CONSTANTS,
} from '../config/constants';
export class NoteMoverShortcut {
private ruleManager: RuleManager;
private ruleManager: RuleManager;
constructor(private plugin: NoteMoverShortcutPlugin) {
this.ruleManager = new RuleManager(plugin.app, '/');
this.updateRuleManager();
}
constructor(private plugin: NoteMoverShortcutPlugin) {
this.ruleManager = new RuleManager(plugin.app, '/');
this.updateRuleManager();
}
public updateRuleManager(): void {
// Always update rules and filters (no toggle check)
this.ruleManager.setRules(this.plugin.settings.rules);
this.ruleManager.setFilter(
this.plugin.settings.filter,
this.plugin.settings.isFilterWhitelist
);
this.ruleManager.setOnlyMoveNotesWithRules(
this.plugin.settings.onlyMoveNotesWithRules
);
}
public updateRuleManager(): void {
// Always update rules and filters (no toggle check)
this.ruleManager.setRules(this.plugin.settings.rules);
this.ruleManager.setFilter(
this.plugin.settings.filter,
this.plugin.settings.isFilterWhitelist
);
this.ruleManager.setOnlyMoveNotesWithRules(
this.plugin.settings.onlyMoveNotesWithRules
);
}
async setup(): Promise<void> {
// Periodic setup moved to TriggerEventHandler
}
async setup(): Promise<void> {
// Periodic setup moved to TriggerEventHandler
}
public async moveFileBasedOnTags(file: TFile, defaultFolder: string, skipFilter: boolean = false): Promise<void> {
const { app } = this.plugin;
const originalPath = file.path;
public async moveFileBasedOnTags(
file: TFile,
defaultFolder: string,
skipFilter = false
): Promise<void> {
const { app } = this.plugin;
const originalPath = file.path;
try {
let targetFolder = defaultFolder;
try {
let targetFolder = defaultFolder;
// Always use rules and filters (no toggle check)
const result = await this.ruleManager.moveFileBasedOnTags(file, skipFilter);
if (result === null) {
return; // File should be skipped based on filter or no matching rule
}
targetFolder = result;
const newPath = combinePath(targetFolder, file.name);
// Always use rules and filters (no toggle check)
const result = await this.ruleManager.moveFileBasedOnTags(
file,
skipFilter
);
if (result === null) {
return; // File should be skipped based on filter or no matching rule
}
targetFolder = result;
// Ensure target folder exists before moving the file
if (!await ensureFolderExists(app, targetFolder)) {
throw createError(`Failed to create target folder: ${targetFolder}`);
}
const newPath = combinePath(targetFolder, file.name);
// Markiere Plugin-interne Verschiebung
this.plugin.historyManager.markPluginMoveStart();
// Ensure target folder exists before moving the file
if (!(await ensureFolderExists(app, targetFolder))) {
throw createError(`Failed to create target folder: ${targetFolder}`);
}
try {
// Move file to new path
await app.fileManager.renameFile(file, newPath);
// Markiere Plugin-interne Verschiebung
this.plugin.historyManager.markPluginMoveStart();
// Add entry to history
this.plugin.historyManager.addEntry({
sourcePath: originalPath,
destinationPath: newPath,
fileName: file.name
});
} finally {
// End internal plugin move (even on errors)
this.plugin.historyManager.markPluginMoveEnd();
}
} catch (error) {
handleError(error, `Error moving file '${file.path}'`);
}
}
try {
// Move file to new path
await app.fileManager.renameFile(file, newPath);
private async moveAllFiles(options: {
createFolders: boolean;
showNotifications: boolean;
operationType: OperationType;
}): Promise<void> {
const { app } = this.plugin;
// Add entry to history
this.plugin.historyManager.addEntry({
sourcePath: originalPath,
destinationPath: newPath,
fileName: file.name,
});
} finally {
// End internal plugin move (even on errors)
this.plugin.historyManager.markPluginMoveEnd();
}
} catch (error) {
handleError(error, `Error moving file '${file.path}'`);
}
}
// Get all files in the vault
const files = await app.vault.getFiles();
private async moveAllFiles(options: {
createFolders: boolean;
showNotifications: boolean;
operationType: OperationType;
}): Promise<void> {
const { app } = this.plugin;
if (files.length === 0) {
if (options.showNotifications) {
NoticeManager.info("No files found in vault");
}
return;
}
// Get all files in the vault
const files = await app.vault.getFiles();
// Start bulk operation
const bulkOperationId = this.plugin.historyManager.startBulkOperation(options.operationType);
let successCount = 0;
let errorCount = 0;
if (files.length === 0) {
if (options.showNotifications) {
NoticeManager.info('No files found in vault');
}
return;
}
try {
// Iterate over each file and move it
for (const file of files) {
try {
await this.moveFileBasedOnTags(file, '/');
successCount++;
} catch (error) {
errorCount++;
const errorMessage = options.operationType === 'periodic'
? `Error moving file '${file.path}' during periodic movement`
: `Error moving file '${file.path}'`;
handleError(error, errorMessage, false);
}
}
// Start bulk operation
const bulkOperationId = this.plugin.historyManager.startBulkOperation(
options.operationType
);
let successCount = 0;
let errorCount = 0;
// Show completion notice
if (successCount > 0 && options.showNotifications) {
if (options.operationType === 'bulk') {
const totalFiles = successCount + errorCount;
const successMessage = errorCount > 0
? `Moved ${successCount}/${totalFiles} files. ${errorCount} files had errors.`
: `Successfully moved ${successCount} files`;
NoticeManager.showWithUndo(
'info',
`Bulk Operation: ${successMessage}`,
async () => {
const success = await this.plugin.historyManager.undoBulkOperation(bulkOperationId);
if (success) {
NoticeManager.success(`Bulk operation undone: ${successCount} files moved back`, { duration: NOTIFICATION_CONSTANTS.DURATION_OVERRIDE });
} else {
NoticeManager.warning(`Could not undo all moves. Check individual files in history.`, { duration: NOTIFICATION_CONSTANTS.DURATION_OVERRIDE });
}
},
SETTINGS_CONSTANTS.UI_TEXTS.UNDO_ALL
);
} else {
NoticeManager.info(`Periodic movement: Successfully moved ${successCount} files`);
}
}
try {
// Iterate over each file and move it
for (const file of files) {
try {
await this.moveFileBasedOnTags(file, '/');
successCount++;
} catch (error) {
errorCount++;
const errorMessage =
options.operationType === 'periodic'
? `Error moving file '${file.path}' during periodic movement`
: `Error moving file '${file.path}'`;
handleError(error, errorMessage, false);
}
}
} finally {
// End bulk operation
this.plugin.historyManager.endBulkOperation();
}
}
// Show completion notice
if (successCount > 0 && options.showNotifications) {
if (options.operationType === 'bulk') {
const totalFiles = successCount + errorCount;
const successMessage =
errorCount > 0
? `Moved ${successCount}/${totalFiles} files. ${errorCount} files had errors.`
: `Successfully moved ${successCount} files`;
async moveAllFilesInVault() {
await this.moveAllFiles({
createFolders: true,
showNotifications: true,
operationType: 'bulk'
});
}
NoticeManager.showWithUndo(
'info',
`Bulk Operation: ${successMessage}`,
async () => {
const success =
await this.plugin.historyManager.undoBulkOperation(
bulkOperationId
);
if (success) {
NoticeManager.success(
`Bulk operation undone: ${successCount} files moved back`,
{ duration: NOTIFICATION_CONSTANTS.DURATION_OVERRIDE }
);
} else {
NoticeManager.warning(
`Could not undo all moves. Check individual files in history.`,
{ duration: NOTIFICATION_CONSTANTS.DURATION_OVERRIDE }
);
}
},
SETTINGS_CONSTANTS.UI_TEXTS.UNDO_ALL
);
} else {
NoticeManager.info(
`Periodic movement: Successfully moved ${successCount} files`
);
}
}
} finally {
// End bulk operation
this.plugin.historyManager.endBulkOperation();
}
}
/**
* Periodic version of bulk move - same logic but marked as periodic operation
*/
async moveAllFilesInVaultPeriodic() {
await this.moveAllFiles({
createFolders: false,
showNotifications: false,
operationType: 'periodic'
});
}
async moveAllFilesInVault() {
await this.moveAllFiles({
createFolders: true,
showNotifications: true,
operationType: 'bulk',
});
}
async moveFocusedNoteToDestination() {
const { app } = this.plugin;
const file = app.workspace.getActiveFile();
/**
* Periodic version of bulk move - same logic but marked as periodic operation
*/
async moveAllFilesInVaultPeriodic() {
await this.moveAllFiles({
createFolders: false,
showNotifications: false,
operationType: 'periodic',
});
}
if (!file) {
handleError(createError("No file is open"), "moveFocusedNoteToDestination", false);
return;
}
async moveFocusedNoteToDestination() {
const { app } = this.plugin;
const file = app.workspace.getActiveFile();
try {
// Move file using rules and filters
await this.moveFileBasedOnTags(file, '/', false);
// Create notice with undo button
NoticeManager.showWithUndo(
'info',
`Note "${file.name}" has been moved`,
async () => {
try {
NoticeManager.info(`Attempting to undo move for file: ${file.name}`);
const success = await this.plugin.historyManager.undoLastMove(file.name);
if (success) {
NoticeManager.success(`Note "${file.name}" has been moved back`, { duration: 3000 });
} else {
NoticeManager.warning(`Could not undo move for "${file.name}"`, { duration: 3000 });
}
} catch (error) {
handleError(error, "Error in undo button click handler", false);
}
},
"Undo"
);
} catch (error) {
handleError(error, "moveFocusedNoteToDestination", false);
return;
}
}
if (!file) {
handleError(
createError('No file is open'),
'moveFocusedNoteToDestination',
false
);
return;
}
/**
* Generates a preview of moves for all files in the vault
*/
async generateVaultMovePreview(): Promise<MovePreview> {
const { app } = this.plugin;
try {
// Move file using rules and filters
await this.moveFileBasedOnTags(file, '/', false);
// Get all files in the vault
const files = await app.vault.getFiles();
// Create notice with undo button
NoticeManager.showWithUndo(
'info',
`Note "${file.name}" has been moved`,
async () => {
try {
NoticeManager.info(
`Attempting to undo move for file: ${file.name}`
);
const success = await this.plugin.historyManager.undoLastMove(
file.name
);
if (success) {
NoticeManager.success(`Note "${file.name}" has been moved back`, {
duration: 3000,
});
} else {
NoticeManager.warning(`Could not undo move for "${file.name}"`, {
duration: 3000,
});
}
} catch (error) {
handleError(error, 'Error in undo button click handler', false);
}
},
'Undo'
);
} catch (error) {
handleError(error, 'moveFocusedNoteToDestination', false);
return;
}
}
// Generate preview using RuleManager
return await this.ruleManager.generateMovePreview(
files,
true, // Rules are always enabled
true, // Filter is always enabled
this.plugin.settings.isFilterWhitelist
);
}
/**
* Generates a preview of moves for all files in the vault
*/
async generateVaultMovePreview(): Promise<MovePreview> {
const { app } = this.plugin;
/**
* Generates a preview for the currently active note
*/
async generateActiveNotePreview(): Promise<MovePreview | null> {
const { app } = this.plugin;
const activeFile = app.workspace.getActiveFile();
// Get all files in the vault
const files = await app.vault.getFiles();
if (!activeFile) {
return null;
}
// Generate preview using RuleManager
return await this.ruleManager.generateMovePreview(
files,
true, // Rules are always enabled
true, // Filter is always enabled
this.plugin.settings.isFilterWhitelist
);
}
// Generate preview for single file
return await this.ruleManager.generateMovePreview(
[activeFile],
true, // Rules are always enabled
true, // Filter is always enabled
this.plugin.settings.isFilterWhitelist
);
}
/**
* Generates a preview for the currently active note
*/
async generateActiveNotePreview(): Promise<MovePreview | null> {
const { app } = this.plugin;
const activeFile = app.workspace.getActiveFile();
if (!activeFile) {
return null;
}
// Generate preview for single file
return await this.ruleManager.generateMovePreview(
[activeFile],
true, // Rules are always enabled
true, // Filter is always enabled
this.plugin.settings.isFilterWhitelist
);
}
}

View file

@ -7,156 +7,190 @@ import { MetadataExtractor } from './MetadataExtractor';
import { RuleMatcher } from './RuleMatcher';
export class RuleManager {
private rules: Rule[] = [];
private filter: string[] = [];
private isFilterWhitelist: boolean = false;
private onlyMoveNotesWithRules: boolean = false;
private metadataExtractor: MetadataExtractor;
private ruleMatcher: RuleMatcher;
private rules: Rule[] = [];
private filter: string[] = [];
private isFilterWhitelist = false;
private onlyMoveNotesWithRules = false;
private metadataExtractor: MetadataExtractor;
private ruleMatcher: RuleMatcher;
constructor(
private app: App,
private defaultFolder: string
) {
this.metadataExtractor = new MetadataExtractor(app);
this.ruleMatcher = new RuleMatcher();
constructor(
private app: App,
private defaultFolder: string
) {
this.metadataExtractor = new MetadataExtractor(app);
this.ruleMatcher = new RuleMatcher();
}
public setRules(rules: Rule[]): void {
this.rules = rules;
}
public setFilter(filter: string[], isWhitelist: boolean): void {
this.filter = filter;
this.isFilterWhitelist = isWhitelist;
}
public setOnlyMoveNotesWithRules(onlyMoveNotesWithRules: boolean): void {
this.onlyMoveNotesWithRules = onlyMoveNotesWithRules;
}
public async moveFileBasedOnTags(
file: TFile,
skipFilter = false
): Promise<string | null> {
try {
// Extract metadata
const metadata = await this.metadataExtractor.extractFileMetadata(file);
// Check if file should be skipped based on filter
if (
!skipFilter &&
!this.ruleMatcher.evaluateFilter(
metadata,
this.filter,
this.isFilterWhitelist
)
) {
return null; // File is blocked by filter
}
// Find matching rule
const matchingRule = this.ruleMatcher.findMatchingRule(
metadata,
this.rules
);
if (matchingRule) {
return matchingRule.path;
}
// If onlyMoveNotesWithRules is enabled and no rule matched, return null to skip the file
if (this.onlyMoveNotesWithRules) {
return null;
}
return this.defaultFolder;
} catch (error) {
handleError(
error,
`Error processing rules for file '${file.path}'`,
false
);
return null;
}
}
public setRules(rules: Rule[]): void {
this.rules = rules;
}
/**
* Generates preview information for a single file without actually moving it
*/
public async generatePreviewForFile(
file: TFile,
skipFilter = false
): Promise<PreviewEntry> {
try {
// Extract metadata
const metadata = await this.metadataExtractor.extractFileMetadata(file);
const { tags, fileName, filePath } = metadata;
public setFilter(filter: string[], isWhitelist: boolean): void {
this.filter = filter;
this.isFilterWhitelist = isWhitelist;
}
public setOnlyMoveNotesWithRules(onlyMoveNotesWithRules: boolean): void {
this.onlyMoveNotesWithRules = onlyMoveNotesWithRules;
}
public async moveFileBasedOnTags(file: TFile, skipFilter: boolean = false): Promise<string | null> {
try {
// Extract metadata
const metadata = await this.metadataExtractor.extractFileMetadata(file);
// Check if file should be skipped based on filter
if (!skipFilter && !this.ruleMatcher.evaluateFilter(metadata, this.filter, this.isFilterWhitelist)) {
return null; // File is blocked by filter
}
// Find matching rule
const matchingRule = this.ruleMatcher.findMatchingRule(metadata, this.rules);
if (matchingRule) {
return matchingRule.path;
}
// If onlyMoveNotesWithRules is enabled and no rule matched, return null to skip the file
if (this.onlyMoveNotesWithRules) {
return null;
}
return this.defaultFolder;
} catch (error) {
handleError(error, `Error processing rules for file '${file.path}'`, false);
return null;
}
}
/**
* Generates preview information for a single file without actually moving it
*/
public async generatePreviewForFile(file: TFile, skipFilter: boolean = false): Promise<PreviewEntry> {
try {
// Extract metadata
const metadata = await this.metadataExtractor.extractFileMetadata(file);
const { tags, fileName, filePath } = metadata;
// Check filters first
if (!skipFilter) {
const filterDetails = this.ruleMatcher.getFilterMatchDetails(metadata, this.filter, this.isFilterWhitelist);
if (!filterDetails.passes) {
return {
fileName,
currentPath: filePath,
targetPath: null,
willBeMoved: false,
blockReason: filterDetails.blockReason!,
blockingFilter: filterDetails.blockingFilter!,
tags
};
}
}
// Check rules for matches
const matchingRule = this.ruleMatcher.findMatchingRule(metadata, this.rules);
if (matchingRule) {
return {
fileName,
currentPath: filePath,
targetPath: matchingRule.path,
willBeMoved: true,
matchedRule: matchingRule.criteria,
tags
};
}
// No rule matched
if (this.onlyMoveNotesWithRules) {
return {
fileName,
currentPath: filePath,
targetPath: null,
willBeMoved: false,
blockReason: "No matching rule found",
tags
};
} else {
return {
fileName,
currentPath: filePath,
targetPath: this.defaultFolder,
willBeMoved: true,
matchedRule: "Default destination",
tags
};
}
} catch (error) {
handleError(error, `Error generating preview for file '${file.path}'`, false);
return {
fileName: file.name,
currentPath: file.path,
targetPath: null,
willBeMoved: false,
blockReason: `Error: ${error instanceof Error ? error.message : String(error)}`,
tags: []
};
}
}
/**
* Generates a complete move preview for multiple files
*/
public async generateMovePreview(files: TFile[], enableRules: boolean, enableFilter: boolean, isFilterWhitelist: boolean): Promise<MovePreview> {
const successfulMoves: PreviewEntry[] = [];
for (const file of files) {
const preview = await this.generatePreviewForFile(file, !enableFilter);
if (preview.willBeMoved) {
successfulMoves.push(preview);
}
// Only include files that will be moved - blocked files are ignored
// Check filters first
if (!skipFilter) {
const filterDetails = this.ruleMatcher.getFilterMatchDetails(
metadata,
this.filter,
this.isFilterWhitelist
);
if (!filterDetails.passes) {
return {
fileName,
currentPath: filePath,
targetPath: null,
willBeMoved: false,
blockReason: filterDetails.blockReason!,
blockingFilter: filterDetails.blockingFilter!,
tags,
};
}
}
// Check rules for matches
const matchingRule = this.ruleMatcher.findMatchingRule(
metadata,
this.rules
);
if (matchingRule) {
return {
successfulMoves,
totalFiles: files.length,
settings: {
isFilterWhitelist
}
fileName,
currentPath: filePath,
targetPath: matchingRule.path,
willBeMoved: true,
matchedRule: matchingRule.criteria,
tags,
};
}
// No rule matched
if (this.onlyMoveNotesWithRules) {
return {
fileName,
currentPath: filePath,
targetPath: null,
willBeMoved: false,
blockReason: 'No matching rule found',
tags,
};
} else {
return {
fileName,
currentPath: filePath,
targetPath: this.defaultFolder,
willBeMoved: true,
matchedRule: 'Default destination',
tags,
};
}
} catch (error) {
handleError(
error,
`Error generating preview for file '${file.path}'`,
false
);
return {
fileName: file.name,
currentPath: file.path,
targetPath: null,
willBeMoved: false,
blockReason: `Error: ${error instanceof Error ? error.message : String(error)}`,
tags: [],
};
}
}
}
/**
* Generates a complete move preview for multiple files
*/
public async generateMovePreview(
files: TFile[],
enableRules: boolean,
enableFilter: boolean,
isFilterWhitelist: boolean
): Promise<MovePreview> {
const successfulMoves: PreviewEntry[] = [];
for (const file of files) {
const preview = await this.generatePreviewForFile(file, !enableFilter);
if (preview.willBeMoved) {
successfulMoves.push(preview);
}
// Only include files that will be moved - blocked files are ignored
}
return {
successfulMoves,
totalFiles: files.length,
settings: {
isFilterWhitelist,
},
};
}
}

View file

@ -3,264 +3,287 @@ import { Rule } from '../types/Rule';
/**
* Handles all rule and filter matching logic for file processing
*
*
* Extracted from RuleManager to eliminate code duplication and improve reusability.
* Provides centralized matching for tags, properties, and rule evaluation.
*
*
* @since 0.4.0
*/
export class RuleMatcher {
/**
* Matches tags hierarchically with parent-child relationships
*
* - Exact matches have highest priority
* - Parent tags match subtags (e.g., #food matches #food/recipes)
*
* @param fileTags - Array of tags from the file
* @param ruleTag - Tag pattern to match
* @returns True if rule tag matches any file tag
*/
public matchTag(fileTags: string[], ruleTag: string): boolean {
// First check for exact match
if (fileTags.includes(ruleTag)) {
return true;
}
// Then check if any file tag is a subtag of the rule tag
// Rule: #food should match #food/recipes, #food/breakfast, etc.
return fileTags.some(fileTag => {
if (fileTag.startsWith(ruleTag + '/')) {
return true;
}
return false;
});
/**
* Matches tags hierarchically with parent-child relationships
*
* - Exact matches have highest priority
* - Parent tags match subtags (e.g., #food matches #food/recipes)
*
* @param fileTags - Array of tags from the file
* @param ruleTag - Tag pattern to match
* @returns True if rule tag matches any file tag
*/
public matchTag(fileTags: string[], ruleTag: string): boolean {
// First check for exact match
if (fileTags.includes(ruleTag)) {
return true;
}
/**
* Matches file names with wildcard pattern support
*
* Supports:
* - Exact matches: "test.md" matches "test.md"
* - Wildcards: "Daily*" matches "Daily Test.md", "Daily Notes.md"
* - Single character: "test?.md" matches "test1.md", "testA.md"
* - Case insensitive matching
*
* @param fileName - The actual file name to match against
* @param pattern - The pattern to match (may contain wildcards)
* @returns True if fileName matches the pattern
*/
public matchFileName(fileName: string, pattern: string): boolean {
// 1. Exact match (for backward compatibility)
if (fileName === pattern) {
return true;
}
// 2. Wildcard pattern matching
if (pattern.includes('*') || pattern.includes('?')) {
// Convert wildcard pattern to regex
// First escape all regex special characters except * and ?
let regexPattern = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
// Then convert wildcards to regex patterns
regexPattern = regexPattern
.replace(/\*/g, '.*') // * -> .*
.replace(/\?/g, '.'); // ? -> .
// Create case-insensitive regex
const regex = new RegExp(`^${regexPattern}$`, 'i');
return regex.test(fileName);
}
// 3. Case-insensitive exact match (fallback)
return fileName.toLowerCase() === pattern.toLowerCase();
// Then check if any file tag is a subtag of the rule tag
// Rule: #food should match #food/recipes, #food/breakfast, etc.
return fileTags.some(fileTag => {
if (fileTag.startsWith(ruleTag + '/')) {
return true;
}
return false;
});
}
/**
* Matches file names with wildcard pattern support
*
* Supports:
* - Exact matches: "test.md" matches "test.md"
* - Wildcards: "Daily*" matches "Daily Test.md", "Daily Notes.md"
* - Single character: "test?.md" matches "test1.md", "testA.md"
* - Case insensitive matching
*
* @param fileName - The actual file name to match against
* @param pattern - The pattern to match (may contain wildcards)
* @returns True if fileName matches the pattern
*/
public matchFileName(fileName: string, pattern: string): boolean {
// 1. Exact match (for backward compatibility)
if (fileName === pattern) {
return true;
}
/**
* Matches frontmatter properties with flexible criteria
*
* @param properties - Frontmatter properties object
* @param criteria - "key" for existence or "key:value" for exact match
* @returns True if property matches criteria
*/
public matchProperty(properties: Record<string, any>, criteria: string): boolean {
// Check if criteria contains a colon to separate key and value
const colonIndex = criteria.indexOf(':');
if (colonIndex === -1) {
// Format: "key" - check if property exists
const key = criteria.trim();
return properties.hasOwnProperty(key) && properties[key] !== undefined && properties[key] !== null;
} else {
// Format: "key:value" - check for exact value match
const key = criteria.substring(0, colonIndex).trim();
const expectedValue = criteria.substring(colonIndex + 1).trim();
if (!properties.hasOwnProperty(key)) {
return false;
}
const actualValue = properties[key];
// Handle different value types
if (actualValue === null || actualValue === undefined) {
return false;
}
// Convert both to strings for comparison to handle different types
const actualValueStr = String(actualValue).toLowerCase();
const expectedValueStr = expectedValue.toLowerCase();
return actualValueStr === expectedValueStr;
}
// 2. Wildcard pattern matching
if (pattern.includes('*') || pattern.includes('?')) {
// Convert wildcard pattern to regex
// First escape all regex special characters except * and ?
let regexPattern = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
// Then convert wildcards to regex patterns
regexPattern = regexPattern
.replace(/\*/g, '.*') // * -> .*
.replace(/\?/g, '.'); // ? -> .
// Create case-insensitive regex
const regex = new RegExp(`^${regexPattern}$`, 'i');
return regex.test(fileName);
}
/**
* Evaluates a single criteria string against file metadata
*
* Supports: tag, fileName, path, content, created_at, updated_at, property
*
* @param metadata - File metadata object
* @param criteria - Criteria in "type:value" format
* @returns True if metadata matches criteria
*/
public evaluateCriteria(metadata: FileMetadata, criteria: string): boolean {
// Parse criteria string: type: value
const match = criteria.match(/^([a-zA-Z_]+):\s*(.*)$/);
if (!match) return false;
const type = match[1];
const value = match[2];
const { tags, fileName, filePath, createdAt, updatedAt, properties, fileContent } = metadata;
switch (type) {
case "tag":
return this.matchTag(tags, value);
case "fileName":
return this.matchFileName(fileName, value);
case "path":
return filePath === value;
case "content":
return fileContent.includes(value);
case "created_at":
if (createdAt) return createdAt.toISOString().startsWith(value);
return false;
case "updated_at":
if (updatedAt) return updatedAt.toISOString().startsWith(value);
return false;
case "property":
return this.matchProperty(properties, value);
default:
return false;
}
// 3. Case-insensitive exact match (fallback)
return fileName.toLowerCase() === pattern.toLowerCase();
}
/**
* Matches frontmatter properties with flexible criteria
*
* @param properties - Frontmatter properties object
* @param criteria - "key" for existence or "key:value" for exact match
* @returns True if property matches criteria
*/
public matchProperty(
properties: Record<string, any>,
criteria: string
): boolean {
// Check if criteria contains a colon to separate key and value
const colonIndex = criteria.indexOf(':');
if (colonIndex === -1) {
// Format: "key" - check if property exists
const key = criteria.trim();
return (
properties.hasOwnProperty(key) &&
properties[key] !== undefined &&
properties[key] !== null
);
} else {
// Format: "key:value" - check for exact value match
const key = criteria.substring(0, colonIndex).trim();
const expectedValue = criteria.substring(colonIndex + 1).trim();
if (!properties.hasOwnProperty(key)) {
return false;
}
const actualValue = properties[key];
// Handle different value types
if (actualValue === null || actualValue === undefined) {
return false;
}
// Convert both to strings for comparison to handle different types
const actualValueStr = String(actualValue).toLowerCase();
const expectedValueStr = expectedValue.toLowerCase();
return actualValueStr === expectedValueStr;
}
}
/**
* Evaluates a single criteria string against file metadata
*
* Supports: tag, fileName, path, content, created_at, updated_at, property
*
* @param metadata - File metadata object
* @param criteria - Criteria in "type:value" format
* @returns True if metadata matches criteria
*/
public evaluateCriteria(metadata: FileMetadata, criteria: string): boolean {
// Parse criteria string: type: value
const match = criteria.match(/^([a-zA-Z_]+):\s*(.*)$/);
if (!match) return false;
const type = match[1];
const value = match[2];
const {
tags,
fileName,
filePath,
createdAt,
updatedAt,
properties,
fileContent,
} = metadata;
switch (type) {
case 'tag':
return this.matchTag(tags, value);
case 'fileName':
return this.matchFileName(fileName, value);
case 'path':
return filePath === value;
case 'content':
return fileContent.includes(value);
case 'created_at':
if (createdAt) return createdAt.toISOString().startsWith(value);
return false;
case 'updated_at':
if (updatedAt) return updatedAt.toISOString().startsWith(value);
return false;
case 'property':
return this.matchProperty(properties, value);
default:
return false;
}
}
/**
* Evaluates filter rules to determine if file should be processed
*
* @param metadata - File metadata object
* @param filter - Array of filter criteria
* @param isFilterWhitelist - True for whitelist, false for blacklist
* @returns True if file should be processed
*/
public evaluateFilter(
metadata: FileMetadata,
filter: string[],
isFilterWhitelist: boolean
): boolean {
if (filter.length === 0) {
return true; // No filter means all files pass
}
/**
* Evaluates filter rules to determine if file should be processed
*
* @param metadata - File metadata object
* @param filter - Array of filter criteria
* @param isFilterWhitelist - True for whitelist, false for blacklist
* @returns True if file should be processed
*/
public evaluateFilter(metadata: FileMetadata, filter: string[], isFilterWhitelist: boolean): boolean {
if (filter.length === 0) {
return true; // No filter means all files pass
}
for (const filterStr of filter) {
const filterMatch = this.evaluateCriteria(metadata, filterStr);
for (const filterStr of filter) {
const filterMatch = this.evaluateCriteria(metadata, filterStr);
if (!isFilterWhitelist && filterMatch) {
return false; // Blacklist: file matches filter, so skip it
}
if (isFilterWhitelist && !filterMatch) {
return false; // Whitelist: file doesn't match filter, so skip it
}
}
return true; // File passes filter
if (!isFilterWhitelist && filterMatch) {
return false; // Blacklist: file matches filter, so skip it
}
if (isFilterWhitelist && !filterMatch) {
return false; // Whitelist: file doesn't match filter, so skip it
}
}
/**
* Finds the first matching rule using specificity-based ordering
*
* Rules are sorted by specificity (more specific tag rules first)
*
* @param metadata - File metadata object
* @param rules - Array of rules to evaluate
* @returns First matching rule or null
*/
public findMatchingRule(metadata: FileMetadata, rules: Rule[]): Rule | null {
const sortedRules = this.sortRulesBySpecificity(rules);
for (const rule of sortedRules) {
if (this.evaluateCriteria(metadata, rule.criteria)) {
return rule;
}
}
return null;
return true; // File passes filter
}
/**
* Finds the first matching rule using specificity-based ordering
*
* Rules are sorted by specificity (more specific tag rules first)
*
* @param metadata - File metadata object
* @param rules - Array of rules to evaluate
* @returns First matching rule or null
*/
public findMatchingRule(metadata: FileMetadata, rules: Rule[]): Rule | null {
const sortedRules = this.sortRulesBySpecificity(rules);
for (const rule of sortedRules) {
if (this.evaluateCriteria(metadata, rule.criteria)) {
return rule;
}
}
/**
* Sorts rules by tag specificity (most specific first)
*
* @param rules - Array of rules to sort
* @returns Rules sorted by hierarchy depth
*/
public sortRulesBySpecificity(rules: Rule[]): Rule[] {
return rules.slice().sort((a, b) => {
const aMatch = a.criteria.match(/^tag:\s*(.*)$/);
const bMatch = b.criteria.match(/^tag:\s*(.*)$/);
if (aMatch && bMatch) {
// Count the number of subtag levels (slashes)
const aLevels = (aMatch[1].match(/\//g) || []).length;
const bLevels = (bMatch[1].match(/\//g) || []).length;
return bLevels - aLevels; // More specific (more slashes) first
}
return 0; // Keep original order for non-tag rules
});
return null;
}
/**
* Sorts rules by tag specificity (most specific first)
*
* @param rules - Array of rules to sort
* @returns Rules sorted by hierarchy depth
*/
public sortRulesBySpecificity(rules: Rule[]): Rule[] {
return rules.slice().sort((a, b) => {
const aMatch = a.criteria.match(/^tag:\s*(.*)$/);
const bMatch = b.criteria.match(/^tag:\s*(.*)$/);
if (aMatch && bMatch) {
// Count the number of subtag levels (slashes)
const aLevels = (aMatch[1].match(/\//g) || []).length;
const bLevels = (bMatch[1].match(/\//g) || []).length;
return bLevels - aLevels; // More specific (more slashes) first
}
return 0; // Keep original order for non-tag rules
});
}
/**
* Gets detailed filter match information for debugging/preview
*
* @param metadata - File metadata object
* @param filter - Array of filter criteria
* @param isFilterWhitelist - True for whitelist mode
* @returns Object with pass/fail status and blocking details
*/
public getFilterMatchDetails(
metadata: FileMetadata,
filter: string[],
isFilterWhitelist: boolean
): {
passes: boolean;
blockingFilter?: string;
blockReason?: string;
} {
if (filter.length === 0) {
return { passes: true };
}
/**
* Gets detailed filter match information for debugging/preview
*
* @param metadata - File metadata object
* @param filter - Array of filter criteria
* @param isFilterWhitelist - True for whitelist mode
* @returns Object with pass/fail status and blocking details
*/
public getFilterMatchDetails(metadata: FileMetadata, filter: string[], isFilterWhitelist: boolean): {
passes: boolean;
blockingFilter?: string;
blockReason?: string;
} {
if (filter.length === 0) {
return { passes: true };
}
for (const filterStr of filter) {
const filterMatch = this.evaluateCriteria(metadata, filterStr);
for (const filterStr of filter) {
const filterMatch = this.evaluateCriteria(metadata, filterStr);
if (!isFilterWhitelist && filterMatch) {
return {
passes: false,
blockingFilter: filterStr,
blockReason: "Blocked by blacklist filter"
};
}
if (isFilterWhitelist && !filterMatch) {
return {
passes: false,
blockingFilter: filterStr,
blockReason: "Not in whitelist"
};
}
}
return { passes: true };
if (!isFilterWhitelist && filterMatch) {
return {
passes: false,
blockingFilter: filterStr,
blockReason: 'Blocked by blacklist filter',
};
}
if (isFilterWhitelist && !filterMatch) {
return {
passes: false,
blockingFilter: filterStr,
blockReason: 'Not in whitelist',
};
}
}
}
return { passes: true };
}
}

View file

@ -2,50 +2,50 @@ import { TFile } from 'obsidian';
import NoteMoverShortcutPlugin from 'main';
export class TriggerEventHandler {
private periodicIntervalId: number | null = null;
private onEditUnregister: (() => void) | null = null;
private periodicIntervalId: number | null = null;
private onEditUnregister: (() => void) | null = null;
constructor(private plugin: NoteMoverShortcutPlugin) {}
constructor(private plugin: NoteMoverShortcutPlugin) {}
public togglePeriodic(): void {
if (this.periodicIntervalId) {
window.clearInterval(this.periodicIntervalId);
this.periodicIntervalId = null;
}
if (this.plugin.settings.enablePeriodicMovement) {
const minutes = this.plugin.settings.periodicMovementInterval;
this.periodicIntervalId = window.setInterval(async () => {
await this.plugin.noteMover.moveAllFilesInVaultPeriodic();
}, minutes * (60 * 1000));
}
public togglePeriodic(): void {
if (this.periodicIntervalId) {
window.clearInterval(this.periodicIntervalId);
this.periodicIntervalId = null;
}
public toggleOnEditListener(): void {
// Unregister existing listener
if (this.onEditUnregister) {
this.onEditUnregister();
this.onEditUnregister = null;
}
if (this.plugin.settings.enablePeriodicMovement) {
const minutes = this.plugin.settings.periodicMovementInterval;
this.periodicIntervalId = window.setInterval(
async () => {
await this.plugin.noteMover.moveAllFilesInVaultPeriodic();
},
minutes * (60 * 1000)
);
}
}
if ((this.plugin.settings as any).enableOnEditTrigger) {
this.plugin.registerEvent(
this.plugin.app.vault.on('modify', async (file) => {
if (file instanceof TFile && file.extension === 'md') {
await this.handleOnEdit(file);
}
})
);
// Obsidian's registerEvent doesn't return an unregister function
// The event is automatically cleaned up when the plugin unloads
this.onEditUnregister = null;
}
public toggleOnEditListener(): void {
// Unregister existing listener
if (this.onEditUnregister) {
this.onEditUnregister();
this.onEditUnregister = null;
}
private async handleOnEdit(file: TFile): Promise<void> {
// Skip filter is false for normal movement on edit
await this.plugin.noteMover.moveFileBasedOnTags(file, '/', false);
if ((this.plugin.settings as any).enableOnEditTrigger) {
this.plugin.registerEvent(
this.plugin.app.vault.on('modify', async file => {
if (file instanceof TFile && file.extension === 'md') {
await this.handleOnEdit(file);
}
})
);
// For testing purposes, we'll store a mock function
this.onEditUnregister = () => {};
}
}
private async handleOnEdit(file: TFile): Promise<void> {
// Skip filter is false for normal movement on edit
await this.plugin.noteMover.moveFileBasedOnTags(file, '/', false);
}
}

View file

@ -8,121 +8,132 @@ import { BaseModal } from 'src/modals/BaseModal';
import { MetadataExtractor } from './MetadataExtractor';
interface ChangelogEntry {
version: string;
changes: {
features?: string[];
bugFixes?: string[];
improvements?: string[];
};
version: string;
changes: {
features?: string[];
bugFixes?: string[];
improvements?: string[];
};
}
export class UpdateManager {
private plugin: NoteMoverShortcutPlugin;
private plugin: NoteMoverShortcutPlugin;
constructor(plugin: NoteMoverShortcutPlugin) {
this.plugin = plugin;
constructor(plugin: NoteMoverShortcutPlugin) {
this.plugin = plugin;
}
/**
* Checks if the plugin has been updated and shows the UpdateModal
*/
async checkForUpdates(): Promise<void> {
const currentVersion = this.plugin.manifest.version;
const lastSeenVersion = this.plugin.settings.lastSeenVersion;
// On first start or when a new version is detected
if (
!lastSeenVersion ||
this.isNewerVersion(currentVersion, lastSeenVersion)
) {
await this.showUpdateModal();
}
}
/**
* Shows the UpdateModal (can also be called manually)
* @param forceShow If true, the modal will be shown even if the version has already been seen
*/
async showUpdateModal(forceShow = false): Promise<void> {
const currentVersion = this.plugin.manifest.version;
let lastSeenVersion = this.plugin.settings.lastSeenVersion;
// For manual invocation (forceShow), use an older version as base,
// to ensure relevant information is displayed
if (forceShow && lastSeenVersion === currentVersion) {
// Use an older version as base for display
lastSeenVersion = '0.1.6';
}
/**
* Checks if the plugin has been updated and shows the UpdateModal
*/
async checkForUpdates(): Promise<void> {
const currentVersion = this.plugin.manifest.version;
const lastSeenVersion = this.plugin.settings.lastSeenVersion;
const changelogEntries = await this.getRelevantChangelogEntries(
lastSeenVersion,
currentVersion
);
// On first start or when a new version is detected
if (!lastSeenVersion || this.isNewerVersion(currentVersion, lastSeenVersion)) {
await this.showUpdateModal();
}
// UpdateModal anzeigen
const updateModal = new UpdateModal(
this.plugin.app,
currentVersion,
lastSeenVersion || '',
changelogEntries
);
updateModal.open();
// Only mark version as "seen" on automatic call
if (!forceShow) {
this.plugin.settings.lastSeenVersion = currentVersion;
await this.plugin.save_settings();
}
}
/**
* Vergleicht zwei Versionsstrings
* @param current The current version
* @param last The last seen version
* @returns true if current is newer than last
*/
private isNewerVersion(current: string, last: string): boolean {
const parseVersion = (version: string): number[] => {
return version.split('.').map(v => parseInt(v) || 0);
};
const currentParts = parseVersion(current);
const lastParts = parseVersion(last);
// Ensure same length for comparison
const maxLength = Math.max(currentParts.length, lastParts.length);
while (currentParts.length < maxLength) currentParts.push(0);
while (lastParts.length < maxLength) lastParts.push(0);
for (let i = 0; i < maxLength; i++) {
if (currentParts[i] > lastParts[i]) return true;
if (currentParts[i] < lastParts[i]) return false;
}
/**
* Shows the UpdateModal (can also be called manually)
* @param forceShow If true, the modal will be shown even if the version has already been seen
*/
async showUpdateModal(forceShow: boolean = false): Promise<void> {
const currentVersion = this.plugin.manifest.version;
let lastSeenVersion = this.plugin.settings.lastSeenVersion;
// For manual invocation (forceShow), use an older version as base,
// to ensure relevant information is displayed
if (forceShow && lastSeenVersion === currentVersion) {
// Use an older version as base for display
lastSeenVersion = '0.1.6';
}
const changelogEntries = await this.getRelevantChangelogEntries(lastSeenVersion, currentVersion);
// UpdateModal anzeigen
const updateModal = new UpdateModal(
this.plugin.app,
currentVersion,
lastSeenVersion || '',
changelogEntries
);
updateModal.open();
return false; // Versions are equal
}
// Only mark version as "seen" on automatic call
if (!forceShow) {
this.plugin.settings.lastSeenVersion = currentVersion;
await this.plugin.save_settings();
}
/**
* Loads relevant changelog entries between two versions
* @param fromVersion The start version (exclusive)
* @param toVersion The end version (inclusive)
* @returns Array of changelog entries
*/
private async getRelevantChangelogEntries(
fromVersion: string | undefined,
toVersion: string
): Promise<ChangelogEntry[]> {
try {
const changelogContent = await this.loadChangelogContent();
if (!changelogContent) {
return [];
}
return this.parseChangelog(changelogContent, fromVersion, toVersion);
} catch (error) {
NoticeManager.error(
`Fehler beim Laden des Changelogs: ${error instanceof Error ? error.message : String(error)}`
);
return [];
}
}
/**
* Vergleicht zwei Versionsstrings
* @param current The current version
* @param last The last seen version
* @returns true if current is newer than last
*/
private isNewerVersion(current: string, last: string): boolean {
const parseVersion = (version: string): number[] => {
return version.split('.').map(v => parseInt(v) || 0);
};
const currentParts = parseVersion(current);
const lastParts = parseVersion(last);
// Ensure same length for comparison
const maxLength = Math.max(currentParts.length, lastParts.length);
while (currentParts.length < maxLength) currentParts.push(0);
while (lastParts.length < maxLength) lastParts.push(0);
for (let i = 0; i < maxLength; i++) {
if (currentParts[i] > lastParts[i]) return true;
if (currentParts[i] < lastParts[i]) return false;
}
return false; // Versions are equal
}
/**
* Loads relevant changelog entries between two versions
* @param fromVersion The start version (exclusive)
* @param toVersion The end version (inclusive)
* @returns Array of changelog entries
*/
private async getRelevantChangelogEntries(fromVersion: string | undefined, toVersion: string): Promise<ChangelogEntry[]> {
try {
const changelogContent = await this.loadChangelogContent();
if (!changelogContent) {
return [];
}
return this.parseChangelog(changelogContent, fromVersion, toVersion);
} catch (error) {
NoticeManager.error(`Fehler beim Laden des Changelogs: ${error instanceof Error ? error.message : String(error)}`);
return [];
}
}
/**
* Loads the content of the CHANGELOG.md file
*/
private async loadChangelogContent(): Promise<string | null> {
// Since the CHANGELOG.md is located in the plugin directory, not in the vault,
// we embed the content directly here
return `# Changelog
/**
* Loads the content of the CHANGELOG.md file
*/
private async loadChangelogContent(): Promise<string | null> {
// Since the CHANGELOG.md is located in the plugin directory, not in the vault,
// we embed the content directly here
return `# Changelog
## [0.4.2](https://github.com/bueckerlars/obsidian-note-mover-shortcut/compare/0.4.1...0.4.2)
### Features
- Added an option for "on-edit" note movement
@ -295,93 +306,120 @@ export class UpdateManager {
### Features
- Added inbox folder setting
- Added command to move all files from inbox to notes folder`;
}
/**
* Parses the changelog content and extracts relevant versions
*/
private parseChangelog(
content: string,
fromVersion: string | undefined,
toVersion: string
): ChangelogEntry[] {
const entries: ChangelogEntry[] = [];
const lines = content.split('\n');
let currentVersion = '';
let currentChanges: ChangelogEntry['changes'] = {};
let currentSection: 'features' | 'bugFixes' | 'improvements' | null = null;
for (const line of lines) {
// Recognize version header (e.g. "## [0.2.1]" or "## [0.1.6]")
const versionMatch = line.match(/^##\s*\[?(\d+\.\d+\.\d+)\]?/);
if (versionMatch) {
// Vorherige Version speichern, falls vorhanden
if (
currentVersion &&
this.shouldIncludeVersion(currentVersion, fromVersion, toVersion)
) {
entries.push({
version: currentVersion,
changes: { ...currentChanges },
});
}
// Neue Version starten
currentVersion = versionMatch[1];
currentChanges = {};
currentSection = null;
continue;
}
// Abschnitt-Header erkennen
if (line.startsWith('### Features')) {
currentSection = 'features';
currentChanges.features = [];
continue;
}
if (line.startsWith('### Bug Fixes')) {
currentSection = 'bugFixes';
currentChanges.bugFixes = [];
continue;
}
if (
line.startsWith('### Improvements') ||
line.startsWith('### Performance')
) {
currentSection = 'improvements';
currentChanges.improvements = [];
continue;
}
// Collect changelog entries
if (currentSection && line.startsWith('- ')) {
const changeText = line.substring(2).trim();
if (changeText) {
currentChanges[currentSection]?.push(changeText);
}
}
}
/**
* Parses the changelog content and extracts relevant versions
*/
private parseChangelog(content: string, fromVersion: string | undefined, toVersion: string): ChangelogEntry[] {
const entries: ChangelogEntry[] = [];
const lines = content.split('\n');
let currentVersion = '';
let currentChanges: ChangelogEntry['changes'] = {};
let currentSection: 'features' | 'bugFixes' | 'improvements' | null = null;
for (const line of lines) {
// Recognize version header (e.g. "## [0.2.1]" or "## [0.1.6]")
const versionMatch = line.match(/^##\s*\[?(\d+\.\d+\.\d+)\]?/);
if (versionMatch) {
// Vorherige Version speichern, falls vorhanden
if (currentVersion && this.shouldIncludeVersion(currentVersion, fromVersion, toVersion)) {
entries.push({
version: currentVersion,
changes: { ...currentChanges }
});
}
// Neue Version starten
currentVersion = versionMatch[1];
currentChanges = {};
currentSection = null;
continue;
}
// Abschnitt-Header erkennen
if (line.startsWith('### Features')) {
currentSection = 'features';
currentChanges.features = [];
continue;
}
if (line.startsWith('### Bug Fixes')) {
currentSection = 'bugFixes';
currentChanges.bugFixes = [];
continue;
}
if (line.startsWith('### Improvements') || line.startsWith('### Performance')) {
currentSection = 'improvements';
currentChanges.improvements = [];
continue;
}
// Collect changelog entries
if (currentSection && line.startsWith('- ')) {
const changeText = line.substring(2).trim();
if (changeText) {
currentChanges[currentSection]?.push(changeText);
}
}
}
// Letzte Version speichern
if (currentVersion && this.shouldIncludeVersion(currentVersion, fromVersion, toVersion)) {
entries.push({
version: currentVersion,
changes: { ...currentChanges }
});
}
return entries;
// Letzte Version speichern
if (
currentVersion &&
this.shouldIncludeVersion(currentVersion, fromVersion, toVersion)
) {
entries.push({
version: currentVersion,
changes: { ...currentChanges },
});
}
/**
* Determines if a version should be included in the changelog entries
*/
private shouldIncludeVersion(version: string, fromVersion: string | undefined, toVersion: string): boolean {
// Always include the current version
if (version === toVersion) {
return true;
}
return entries;
}
// If no fromVersion (first start), show the last important versions
if (!fromVersion) {
// Define important versions that should be shown on first start
const importantVersionsToShow = ['0.3.4', '0.3.3', '0.3.0', '0.2.1', '0.2.0', '0.1.7', '0.1.6'];
return importantVersionsToShow.includes(version);
}
// Version must be between fromVersion (exclusive) and toVersion (inclusive)
return this.isNewerVersion(version, fromVersion) &&
(version === toVersion || this.isNewerVersion(toVersion, version));
/**
* Determines if a version should be included in the changelog entries
*/
private shouldIncludeVersion(
version: string,
fromVersion: string | undefined,
toVersion: string
): boolean {
// Always include the current version
if (version === toVersion) {
return true;
}
// If no fromVersion (first start), show the last important versions
if (!fromVersion) {
// Define important versions that should be shown on first start
const importantVersionsToShow = [
'0.3.4',
'0.3.3',
'0.3.0',
'0.2.1',
'0.2.0',
'0.1.7',
'0.1.6',
];
return importantVersionsToShow.includes(version);
}
// Version must be between fromVersion (exclusive) and toVersion (inclusive)
return (
this.isNewerVersion(version, fromVersion) &&
(version === toVersion || this.isNewerVersion(toVersion, version))
);
}
}

View file

@ -1,7 +1,11 @@
import { FileMovementService } from '../FileMovementService';
import { TFile } from 'obsidian';
import { NoticeManager } from '../../utils/NoticeManager';
import { ensureFolderExists, getParentPath, combinePath } from '../../utils/PathUtils';
import {
ensureFolderExists,
getParentPath,
combinePath,
} from '../../utils/PathUtils';
// Mock dependencies
jest.mock('../../utils/NoticeManager');
@ -9,301 +13,355 @@ jest.mock('../../utils/PathUtils');
jest.mock('../../utils/Error');
const mockApp = {
vault: {
getAbstractFileByPath: jest.fn(),
createFolder: jest.fn()
},
fileManager: {
renameFile: jest.fn()
}
vault: {
getAbstractFileByPath: jest.fn(),
createFolder: jest.fn(),
},
fileManager: {
renameFile: jest.fn(),
},
} as any;
const mockPlugin = {
historyManager: {
addEntry: jest.fn()
}
historyManager: {
addEntry: jest.fn(),
},
} as any;
const mockFile = {
path: 'source/test.md',
name: 'test.md'
path: 'source/test.md',
name: 'test.md',
} as TFile;
describe('FileMovementService', () => {
let fileMovementService: FileMovementService;
let fileMovementService: FileMovementService;
beforeEach(() => {
fileMovementService = new FileMovementService(mockApp, mockPlugin);
jest.clearAllMocks();
// Default mocks
(ensureFolderExists as jest.Mock).mockResolvedValue(true);
(getParentPath as jest.Mock).mockImplementation((path: string) => {
const lastSlash = path.lastIndexOf('/');
return lastSlash > 0 ? path.substring(0, lastSlash) : '';
});
(combinePath as jest.Mock).mockImplementation((folder: string, file: string) => {
if (!folder || folder === '/') return file;
return `${folder}/${file}`;
});
mockApp.vault.getAbstractFileByPath.mockReturnValue(mockFile);
mockApp.fileManager.renameFile.mockResolvedValue(undefined);
beforeEach(() => {
fileMovementService = new FileMovementService(mockApp, mockPlugin);
jest.clearAllMocks();
// Default mocks
(ensureFolderExists as jest.Mock).mockResolvedValue(true);
(getParentPath as jest.Mock).mockImplementation((path: string) => {
const lastSlash = path.lastIndexOf('/');
return lastSlash > 0 ? path.substring(0, lastSlash) : '';
});
(combinePath as jest.Mock).mockImplementation(
(folder: string, file: string) => {
if (!folder || folder === '/') return file;
return `${folder}/${file}`;
}
);
mockApp.vault.getAbstractFileByPath.mockReturnValue(mockFile);
mockApp.fileManager.renameFile.mockResolvedValue(undefined);
});
describe('Plugin Move Tracking', () => {
it('should track plugin move state correctly', () => {
expect(fileMovementService.isInPluginMove()).toBe(false);
fileMovementService.markPluginMoveStart();
expect(fileMovementService.isInPluginMove()).toBe(true);
fileMovementService.markPluginMoveEnd();
expect(fileMovementService.isInPluginMove()).toBe(false);
});
});
describe('validateFileExists', () => {
it('should return file if it exists and is a TFile', () => {
mockApp.vault.getAbstractFileByPath.mockReturnValue(mockFile);
const result = fileMovementService.validateFileExists('test/path.md');
expect(result).toBe(mockFile);
});
describe('Plugin Move Tracking', () => {
it('should track plugin move state correctly', () => {
expect(fileMovementService.isInPluginMove()).toBe(false);
fileMovementService.markPluginMoveStart();
expect(fileMovementService.isInPluginMove()).toBe(true);
fileMovementService.markPluginMoveEnd();
expect(fileMovementService.isInPluginMove()).toBe(false);
});
it('should return null if file does not exist', () => {
mockApp.vault.getAbstractFileByPath.mockReturnValue(null);
const result = fileMovementService.validateFileExists('nonexistent.md');
expect(result).toBeNull();
});
describe('validateFileExists', () => {
it('should return file if it exists and is a TFile', () => {
mockApp.vault.getAbstractFileByPath.mockReturnValue(mockFile);
const result = fileMovementService.validateFileExists('test/path.md');
expect(result).toBe(mockFile);
});
it('should return null if file is not a TFile', () => {
const mockFolder = { path: 'folder' };
mockApp.vault.getAbstractFileByPath.mockReturnValue(mockFolder);
it('should return null if file does not exist', () => {
mockApp.vault.getAbstractFileByPath.mockReturnValue(null);
const result = fileMovementService.validateFileExists('nonexistent.md');
expect(result).toBeNull();
});
const result = fileMovementService.validateFileExists('folder');
expect(result).toBeNull();
});
});
it('should return null if file is not a TFile', () => {
const mockFolder = { path: 'folder' };
mockApp.vault.getAbstractFileByPath.mockReturnValue(mockFolder);
const result = fileMovementService.validateFileExists('folder');
expect(result).toBeNull();
});
describe('moveFile', () => {
it('should successfully move file with default options', async () => {
const result = await fileMovementService.moveFile(
mockFile,
'target/test.md'
);
expect(result).toBe(true);
expect(mockApp.fileManager.renameFile).toHaveBeenCalledWith(
mockFile,
'target/test.md'
);
expect(mockPlugin.historyManager.addEntry).toHaveBeenCalledWith({
sourcePath: 'source/test.md',
destinationPath: 'target/test.md',
fileName: 'test.md',
});
});
it('should move file without tracking in history when disabled', async () => {
const result = await fileMovementService.moveFile(
mockFile,
'target/test.md',
{
trackInHistory: false,
}
);
describe('moveFile', () => {
it('should successfully move file with default options', async () => {
const result = await fileMovementService.moveFile(mockFile, 'target/test.md');
expect(result).toBe(true);
expect(mockApp.fileManager.renameFile).toHaveBeenCalledWith(mockFile, 'target/test.md');
expect(mockPlugin.historyManager.addEntry).toHaveBeenCalledWith({
sourcePath: 'source/test.md',
destinationPath: 'target/test.md',
fileName: 'test.md'
});
});
it('should move file without tracking in history when disabled', async () => {
const result = await fileMovementService.moveFile(mockFile, 'target/test.md', {
trackInHistory: false
});
expect(result).toBe(true);
expect(mockApp.fileManager.renameFile).toHaveBeenCalled();
expect(mockPlugin.historyManager.addEntry).not.toHaveBeenCalled();
});
it('should show notifications when enabled', async () => {
await fileMovementService.moveFile(mockFile, 'target/test.md', {
showNotifications: true
});
expect(NoticeManager.success).toHaveBeenCalledWith('Moved test.md to target/test.md');
});
it('should use custom original path when provided', async () => {
await fileMovementService.moveFile(mockFile, 'target/test.md', {
originalPath: 'custom/original.md'
});
expect(mockPlugin.historyManager.addEntry).toHaveBeenCalledWith({
sourcePath: 'custom/original.md',
destinationPath: 'target/test.md',
fileName: 'test.md'
});
});
it('should handle folder creation failure', async () => {
(ensureFolderExists as jest.Mock).mockResolvedValue(false);
(getParentPath as jest.Mock).mockReturnValue('target');
const result = await fileMovementService.moveFile(mockFile, 'target/test.md');
expect(result).toBe(false);
expect(mockApp.fileManager.renameFile).not.toHaveBeenCalled();
expect(ensureFolderExists).toHaveBeenCalledWith(mockApp, 'target');
});
it('should handle file rename failure', async () => {
mockApp.fileManager.renameFile.mockRejectedValue(new Error('Rename failed'));
const result = await fileMovementService.moveFile(mockFile, 'target/test.md');
expect(result).toBe(false);
});
it('should always mark plugin move end even on errors', async () => {
mockApp.fileManager.renameFile.mockRejectedValue(new Error('Rename failed'));
await fileMovementService.moveFile(mockFile, 'target/test.md');
expect(fileMovementService.isInPluginMove()).toBe(false);
});
expect(result).toBe(true);
expect(mockApp.fileManager.renameFile).toHaveBeenCalled();
expect(mockPlugin.historyManager.addEntry).not.toHaveBeenCalled();
});
describe('moveFileToFolder', () => {
it('should combine folder path with filename', async () => {
(combinePath as jest.Mock).mockReturnValue('target/folder/test.md');
(getParentPath as jest.Mock).mockReturnValue('target/folder');
const result = await fileMovementService.moveFileToFolder(mockFile, 'target/folder');
expect(result).toBe(true);
expect(mockApp.fileManager.renameFile).toHaveBeenCalledWith(mockFile, 'target/folder/test.md');
});
it('should show notifications when enabled', async () => {
await fileMovementService.moveFile(mockFile, 'target/test.md', {
showNotifications: true,
});
it('should handle root folder', async () => {
(combinePath as jest.Mock).mockReturnValue('test.md');
(getParentPath as jest.Mock).mockReturnValue('');
await fileMovementService.moveFileToFolder(mockFile, '');
expect(mockApp.fileManager.renameFile).toHaveBeenCalledWith(mockFile, 'test.md');
});
expect(NoticeManager.success).toHaveBeenCalledWith(
'Moved test.md to target/test.md'
);
});
describe('undoMove', () => {
it('should successfully undo a move operation', async () => {
const result = await fileMovementService.undoMove(
'destination/test.md',
'source/test.md',
'test.md'
);
expect(result).toBe(true);
expect(mockApp.fileManager.renameFile).toHaveBeenCalledWith(mockFile, 'source/test.md');
});
it('should use custom original path when provided', async () => {
await fileMovementService.moveFile(mockFile, 'target/test.md', {
originalPath: 'custom/original.md',
});
it('should show notifications when enabled', async () => {
await fileMovementService.undoMove(
'destination/test.md',
'source/test.md',
'test.md',
{ showNotifications: true }
);
expect(NoticeManager.info).toHaveBeenCalledWith(
'Attempting to move test.md from destination/test.md to source/test.md'
);
expect(NoticeManager.success).toHaveBeenCalledWith(
'Successfully moved test.md back to source/test.md'
);
});
it('should handle file not found at destination', async () => {
mockApp.vault.getAbstractFileByPath.mockReturnValue(null);
const result = await fileMovementService.undoMove(
'destination/test.md',
'source/test.md',
'test.md'
);
expect(result).toBe(false);
expect(mockApp.fileManager.renameFile).not.toHaveBeenCalled();
});
it('should handle source folder creation failure', async () => {
(ensureFolderExists as jest.Mock).mockResolvedValue(false);
(getParentPath as jest.Mock).mockReturnValue('source');
const result = await fileMovementService.undoMove(
'destination/test.md',
'source/test.md',
'test.md'
);
expect(result).toBe(false);
expect(mockApp.fileManager.renameFile).not.toHaveBeenCalled();
expect(ensureFolderExists).toHaveBeenCalledWith(mockApp, 'source');
});
expect(mockPlugin.historyManager.addEntry).toHaveBeenCalledWith({
sourcePath: 'custom/original.md',
destinationPath: 'target/test.md',
fileName: 'test.md',
});
});
describe('undoMoveFromHistoryEntry', () => {
it('should undo move using history entry', async () => {
const historyEntry = {
id: '123',
sourcePath: 'source/test.md',
destinationPath: 'destination/test.md',
fileName: 'test.md',
timestamp: Date.now()
};
const result = await fileMovementService.undoMoveFromHistoryEntry(historyEntry);
expect(result).toBe(true);
expect(mockApp.fileManager.renameFile).toHaveBeenCalledWith(mockFile, 'source/test.md');
});
it('should handle folder creation failure', async () => {
(ensureFolderExists as jest.Mock).mockResolvedValue(false);
(getParentPath as jest.Mock).mockReturnValue('target');
const result = await fileMovementService.moveFile(
mockFile,
'target/test.md'
);
expect(result).toBe(false);
expect(mockApp.fileManager.renameFile).not.toHaveBeenCalled();
expect(ensureFolderExists).toHaveBeenCalledWith(mockApp, 'target');
});
describe('batchMoveFiles', () => {
const operations = [
{ file: mockFile, targetPath: 'target1/test.md' },
{ file: { ...mockFile, name: 'test2.md', path: 'source/test2.md' } as TFile, targetPath: 'target2/test2.md' }
];
it('should handle file rename failure', async () => {
mockApp.fileManager.renameFile.mockRejectedValue(
new Error('Rename failed')
);
it('should successfully batch move all files', async () => {
const result = await fileMovementService.batchMoveFiles(operations);
expect(result.successful).toBe(2);
expect(result.failed).toBe(0);
expect(result.errors).toHaveLength(0);
expect(mockApp.fileManager.renameFile).toHaveBeenCalledTimes(2);
});
const result = await fileMovementService.moveFile(
mockFile,
'target/test.md'
);
it('should handle partial failures', async () => {
mockApp.fileManager.renameFile
.mockResolvedValueOnce(undefined) // First call succeeds
.mockRejectedValueOnce(new Error('Failed')); // Second call fails
const result = await fileMovementService.batchMoveFiles(operations);
expect(result.successful).toBe(1);
expect(result.failed).toBe(1);
expect(result.errors).toHaveLength(1);
});
it('should call progress callback', async () => {
const onProgress = jest.fn();
await fileMovementService.batchMoveFiles(operations, { onProgress });
expect(onProgress).toHaveBeenCalledTimes(2);
expect(onProgress).toHaveBeenNthCalledWith(1, 0, 2, 'test.md');
expect(onProgress).toHaveBeenNthCalledWith(2, 1, 2, 'test2.md');
});
it('should show batch notifications when enabled', async () => {
await fileMovementService.batchMoveFiles(operations, { showNotifications: true });
expect(NoticeManager.success).toHaveBeenCalledWith('Successfully moved 2 files!');
});
it('should show warning for partial failures', async () => {
mockApp.fileManager.renameFile
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('Failed'));
await fileMovementService.batchMoveFiles(operations, { showNotifications: true });
expect(NoticeManager.warning).toHaveBeenCalledWith('Moved 1 files with 1 errors.');
});
expect(result).toBe(false);
});
it('should always mark plugin move end even on errors', async () => {
mockApp.fileManager.renameFile.mockRejectedValue(
new Error('Rename failed')
);
await fileMovementService.moveFile(mockFile, 'target/test.md');
expect(fileMovementService.isInPluginMove()).toBe(false);
});
});
describe('moveFileToFolder', () => {
it('should combine folder path with filename', async () => {
(combinePath as jest.Mock).mockReturnValue('target/folder/test.md');
(getParentPath as jest.Mock).mockReturnValue('target/folder');
const result = await fileMovementService.moveFileToFolder(
mockFile,
'target/folder'
);
expect(result).toBe(true);
expect(mockApp.fileManager.renameFile).toHaveBeenCalledWith(
mockFile,
'target/folder/test.md'
);
});
it('should handle root folder', async () => {
(combinePath as jest.Mock).mockReturnValue('test.md');
(getParentPath as jest.Mock).mockReturnValue('');
await fileMovementService.moveFileToFolder(mockFile, '');
expect(mockApp.fileManager.renameFile).toHaveBeenCalledWith(
mockFile,
'test.md'
);
});
});
describe('undoMove', () => {
it('should successfully undo a move operation', async () => {
const result = await fileMovementService.undoMove(
'destination/test.md',
'source/test.md',
'test.md'
);
expect(result).toBe(true);
expect(mockApp.fileManager.renameFile).toHaveBeenCalledWith(
mockFile,
'source/test.md'
);
});
it('should show notifications when enabled', async () => {
await fileMovementService.undoMove(
'destination/test.md',
'source/test.md',
'test.md',
{ showNotifications: true }
);
expect(NoticeManager.info).toHaveBeenCalledWith(
'Attempting to move test.md from destination/test.md to source/test.md'
);
expect(NoticeManager.success).toHaveBeenCalledWith(
'Successfully moved test.md back to source/test.md'
);
});
it('should handle file not found at destination', async () => {
mockApp.vault.getAbstractFileByPath.mockReturnValue(null);
const result = await fileMovementService.undoMove(
'destination/test.md',
'source/test.md',
'test.md'
);
expect(result).toBe(false);
expect(mockApp.fileManager.renameFile).not.toHaveBeenCalled();
});
it('should handle source folder creation failure', async () => {
(ensureFolderExists as jest.Mock).mockResolvedValue(false);
(getParentPath as jest.Mock).mockReturnValue('source');
const result = await fileMovementService.undoMove(
'destination/test.md',
'source/test.md',
'test.md'
);
expect(result).toBe(false);
expect(mockApp.fileManager.renameFile).not.toHaveBeenCalled();
expect(ensureFolderExists).toHaveBeenCalledWith(mockApp, 'source');
});
});
describe('undoMoveFromHistoryEntry', () => {
it('should undo move using history entry', async () => {
const historyEntry = {
id: '123',
sourcePath: 'source/test.md',
destinationPath: 'destination/test.md',
fileName: 'test.md',
timestamp: Date.now(),
};
const result =
await fileMovementService.undoMoveFromHistoryEntry(historyEntry);
expect(result).toBe(true);
expect(mockApp.fileManager.renameFile).toHaveBeenCalledWith(
mockFile,
'source/test.md'
);
});
});
describe('batchMoveFiles', () => {
const operations = [
{ file: mockFile, targetPath: 'target1/test.md' },
{
file: {
...mockFile,
name: 'test2.md',
path: 'source/test2.md',
} as TFile,
targetPath: 'target2/test2.md',
},
];
it('should successfully batch move all files', async () => {
const result = await fileMovementService.batchMoveFiles(operations);
expect(result.successful).toBe(2);
expect(result.failed).toBe(0);
expect(result.errors).toHaveLength(0);
expect(mockApp.fileManager.renameFile).toHaveBeenCalledTimes(2);
});
it('should handle partial failures', async () => {
mockApp.fileManager.renameFile
.mockResolvedValueOnce(undefined) // First call succeeds
.mockRejectedValueOnce(new Error('Failed')); // Second call fails
const result = await fileMovementService.batchMoveFiles(operations);
expect(result.successful).toBe(1);
expect(result.failed).toBe(1);
expect(result.errors).toHaveLength(1);
});
it('should call progress callback', async () => {
const onProgress = jest.fn();
await fileMovementService.batchMoveFiles(operations, { onProgress });
expect(onProgress).toHaveBeenCalledTimes(2);
expect(onProgress).toHaveBeenNthCalledWith(1, 0, 2, 'test.md');
expect(onProgress).toHaveBeenNthCalledWith(2, 1, 2, 'test2.md');
});
it('should show batch notifications when enabled', async () => {
await fileMovementService.batchMoveFiles(operations, {
showNotifications: true,
});
expect(NoticeManager.success).toHaveBeenCalledWith(
'Successfully moved 2 files!'
);
});
it('should show warning for partial failures', async () => {
mockApp.fileManager.renameFile
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('Failed'));
await fileMovementService.batchMoveFiles(operations, {
showNotifications: true,
});
expect(NoticeManager.warning).toHaveBeenCalledWith(
'Moved 1 files with 1 errors.'
);
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -6,216 +6,223 @@ const mockGetAllTags = getAllTags as jest.MockedFunction<typeof getAllTags>;
mockGetAllTags.mockReturnValue([]);
describe('MetadataExtractor', () => {
let mockApp: any;
let mockFile: TFile;
let metadataExtractor: MetadataExtractor;
let mockApp: any;
let mockFile: TFile;
let metadataExtractor: MetadataExtractor;
beforeEach(() => {
mockApp = {
metadataCache: {
getFileCache: jest.fn()
},
vault: {
read: jest.fn(),
getMarkdownFiles: jest.fn()
}
};
beforeEach(() => {
mockApp = {
metadataCache: {
getFileCache: jest.fn(),
},
vault: {
read: jest.fn(),
getMarkdownFiles: jest.fn(),
},
};
// Reset all mocks
jest.clearAllMocks();
// Reset all mocks
jest.clearAllMocks();
mockFile = {
name: 'test.md',
path: 'test/test.md',
stat: {
ctime: Date.now(),
mtime: Date.now()
}
} as TFile;
mockFile = {
name: 'test.md',
path: 'test/test.md',
stat: {
ctime: Date.now(),
mtime: Date.now(),
},
} as TFile;
metadataExtractor = new MetadataExtractor(mockApp);
metadataExtractor = new MetadataExtractor(mockApp);
});
describe('extractFileMetadata', () => {
it('should extract complete metadata successfully', async () => {
mockApp.metadataCache.getFileCache.mockReturnValue({
frontmatter: { status: 'completed', priority: 'high' },
tags: [],
});
mockApp.vault.read.mockResolvedValue(
'# Test content\n\nSome content here'
);
const result = await metadataExtractor.extractFileMetadata(mockFile);
expect(result.fileName).toBe('test.md');
expect(result.filePath).toBe('test/test.md');
expect(result.tags).toEqual([]);
expect(result.properties).toEqual({
status: 'completed',
priority: 'high',
});
expect(result.fileContent).toBe('# Test content\n\nSome content here');
expect(result.createdAt).toBeInstanceOf(Date);
expect(result.updatedAt).toBeInstanceOf(Date);
});
describe('extractFileMetadata', () => {
it('should extract complete metadata successfully', async () => {
mockApp.metadataCache.getFileCache.mockReturnValue({
frontmatter: { status: 'completed', priority: 'high' },
tags: []
});
mockApp.vault.read.mockResolvedValue('# Test content\n\nSome content here');
it('should handle file read error gracefully', async () => {
mockApp.metadataCache.getFileCache.mockReturnValue({
frontmatter: { status: 'completed' },
tags: [],
});
mockApp.vault.read.mockRejectedValue(new Error('File read failed'));
const result = await metadataExtractor.extractFileMetadata(mockFile);
const result = await metadataExtractor.extractFileMetadata(mockFile);
expect(result.fileName).toBe('test.md');
expect(result.filePath).toBe('test/test.md');
expect(result.tags).toEqual([]);
expect(result.properties).toEqual({ status: 'completed', priority: 'high' });
expect(result.fileContent).toBe('# Test content\n\nSome content here');
expect(result.createdAt).toBeInstanceOf(Date);
expect(result.updatedAt).toBeInstanceOf(Date);
});
it('should handle file read error gracefully', async () => {
mockApp.metadataCache.getFileCache.mockReturnValue({
frontmatter: { status: 'completed' },
tags: []
});
mockApp.vault.read.mockRejectedValue(new Error('File read failed'));
const result = await metadataExtractor.extractFileMetadata(mockFile);
expect(result.fileName).toBe('test.md');
expect(result.filePath).toBe('test/test.md');
expect(result.tags).toEqual([]);
expect(result.fileContent).toBe(''); // Empty content on error
expect(result.properties).toEqual({ status: 'completed' });
});
it('should handle metadata cache error gracefully', async () => {
mockApp.metadataCache.getFileCache.mockImplementation(() => {
throw new Error('Cache access failed');
});
const result = await metadataExtractor.extractFileMetadata(mockFile);
expect(result.fileName).toBe('test.md');
expect(result.filePath).toBe('test/test.md');
expect(result.tags).toEqual([]);
expect(result.properties).toEqual({});
expect(result.fileContent).toBe('');
expect(result.createdAt).toBeNull();
expect(result.updatedAt).toBeNull();
});
it('should handle null stat gracefully', async () => {
const fileWithNullStat = { ...mockFile, stat: null } as any;
const result = await metadataExtractor.extractFileMetadata(fileWithNullStat);
expect(result.createdAt).toBeNull();
expect(result.updatedAt).toBeNull();
});
it('should extract tags from cache', async () => {
mockApp.metadataCache.getFileCache.mockReturnValue({
frontmatter: {},
tags: [{ tag: '#tag1' }, { tag: '#tag2/subtag' }]
});
// Mock getAllTags to return the tags from cache
mockGetAllTags.mockReturnValue(['#tag1', '#tag2/subtag']);
const result = await metadataExtractor.extractFileMetadata(mockFile);
expect(result.tags).toEqual(['#tag1', '#tag2/subtag']);
});
expect(result.fileName).toBe('test.md');
expect(result.filePath).toBe('test/test.md');
expect(result.tags).toEqual([]);
expect(result.fileContent).toBe(''); // Empty content on error
expect(result.properties).toEqual({ status: 'completed' });
});
describe('extractBasicMetadata', () => {
it('should extract basic metadata without content', async () => {
mockApp.metadataCache.getFileCache.mockReturnValue({
frontmatter: { status: 'pending' },
tags: []
});
it('should handle metadata cache error gracefully', async () => {
mockApp.metadataCache.getFileCache.mockImplementation(() => {
throw new Error('Cache access failed');
});
// Mock getAllTags to return empty array
mockGetAllTags.mockReturnValue([]);
const result = await metadataExtractor.extractFileMetadata(mockFile);
const result = await metadataExtractor.extractBasicMetadata(mockFile);
expect(result.fileName).toBe('test.md');
expect(result.filePath).toBe('test/test.md');
expect(result.tags).toEqual([]);
expect(result.properties).toEqual({ status: 'pending' });
expect(result.createdAt).toBeInstanceOf(Date);
expect(result.updatedAt).toBeInstanceOf(Date);
expect(result).not.toHaveProperty('fileContent');
});
it('should handle basic metadata cache error', async () => {
mockApp.metadataCache.getFileCache.mockImplementation(() => {
throw new Error('Cache error');
});
const result = await metadataExtractor.extractBasicMetadata(mockFile);
expect(result.fileName).toBe('test.md');
expect(result.filePath).toBe('test/test.md');
expect(result.tags).toEqual([]);
expect(result.properties).toEqual({});
expect(result.createdAt).toBeNull();
expect(result.updatedAt).toBeNull();
});
expect(result.fileName).toBe('test.md');
expect(result.filePath).toBe('test/test.md');
expect(result.tags).toEqual([]);
expect(result.properties).toEqual({});
expect(result.fileContent).toBe('');
expect(result.createdAt).toBeNull();
expect(result.updatedAt).toBeNull();
});
describe('extractAllTags', () => {
it('should extract tags from all markdown files', () => {
const mockFiles = [
{ ...mockFile, name: 'file1.md', path: 'file1.md' },
{ ...mockFile, name: 'file2.md', path: 'file2.md' }
] as TFile[];
it('should handle null stat gracefully', async () => {
const fileWithNullStat = { ...mockFile, stat: null } as any;
mockApp.vault.getMarkdownFiles.mockReturnValue(mockFiles);
mockApp.metadataCache.getFileCache
.mockReturnValueOnce({ tags: [{ tag: '#tag1' }] })
.mockReturnValueOnce({ tags: [{ tag: '#tag2' }, { tag: '#tag3' }] });
const result =
await metadataExtractor.extractFileMetadata(fileWithNullStat);
// Mock getAllTags to return the tags for each file
mockGetAllTags
.mockReturnValueOnce(['#tag1'])
.mockReturnValueOnce(['#tag2', '#tag3']);
const result = metadataExtractor.extractAllTags();
expect(result).toBeInstanceOf(Set);
expect(Array.from(result)).toEqual(['#tag1', '#tag2', '#tag3']);
});
it('should handle files with no tags', () => {
const mockFiles = [
{ ...mockFile, name: 'file1.md', path: 'file1.md' }
] as TFile[];
mockApp.vault.getMarkdownFiles.mockReturnValue(mockFiles);
mockApp.metadataCache.getFileCache.mockReturnValue({});
const result = metadataExtractor.extractAllTags();
expect(result.size).toBe(0);
});
it('should handle cache errors gracefully', () => {
const mockFiles = [
{ ...mockFile, name: 'file1.md', path: 'file1.md' },
{ ...mockFile, name: 'file2.md', path: 'file2.md' }
] as TFile[];
mockApp.vault.getMarkdownFiles.mockReturnValue(mockFiles);
mockApp.metadataCache.getFileCache
.mockImplementation(() => { throw new Error('Cache error'); });
const result = metadataExtractor.extractAllTags();
expect(result.size).toBe(0); // Should not throw, just return empty set
});
it('should deduplicate tags', () => {
const mockFiles = [
{ ...mockFile, name: 'file1.md', path: 'file1.md' },
{ ...mockFile, name: 'file2.md', path: 'file2.md' }
] as TFile[];
mockApp.vault.getMarkdownFiles.mockReturnValue(mockFiles);
mockApp.metadataCache.getFileCache.mockReturnValue({
tags: [{ tag: '#duplicate' }, { tag: '#unique' }]
});
// Mock getAllTags to return the tags
mockGetAllTags.mockReturnValue(['#duplicate', '#unique']);
const result = metadataExtractor.extractAllTags();
expect(Array.from(result)).toEqual(['#duplicate', '#unique']);
});
expect(result.createdAt).toBeNull();
expect(result.updatedAt).toBeNull();
});
it('should extract tags from cache', async () => {
mockApp.metadataCache.getFileCache.mockReturnValue({
frontmatter: {},
tags: [{ tag: '#tag1' }, { tag: '#tag2/subtag' }],
});
// Mock getAllTags to return the tags from cache
mockGetAllTags.mockReturnValue(['#tag1', '#tag2/subtag']);
const result = await metadataExtractor.extractFileMetadata(mockFile);
expect(result.tags).toEqual(['#tag1', '#tag2/subtag']);
});
});
describe('extractBasicMetadata', () => {
it('should extract basic metadata without content', async () => {
mockApp.metadataCache.getFileCache.mockReturnValue({
frontmatter: { status: 'pending' },
tags: [],
});
// Mock getAllTags to return empty array
mockGetAllTags.mockReturnValue([]);
const result = await metadataExtractor.extractBasicMetadata(mockFile);
expect(result.fileName).toBe('test.md');
expect(result.filePath).toBe('test/test.md');
expect(result.tags).toEqual([]);
expect(result.properties).toEqual({ status: 'pending' });
expect(result.createdAt).toBeInstanceOf(Date);
expect(result.updatedAt).toBeInstanceOf(Date);
expect(result).not.toHaveProperty('fileContent');
});
it('should handle basic metadata cache error', async () => {
mockApp.metadataCache.getFileCache.mockImplementation(() => {
throw new Error('Cache error');
});
const result = await metadataExtractor.extractBasicMetadata(mockFile);
expect(result.fileName).toBe('test.md');
expect(result.filePath).toBe('test/test.md');
expect(result.tags).toEqual([]);
expect(result.properties).toEqual({});
expect(result.createdAt).toBeNull();
expect(result.updatedAt).toBeNull();
});
});
describe('extractAllTags', () => {
it('should extract tags from all markdown files', () => {
const mockFiles = [
{ ...mockFile, name: 'file1.md', path: 'file1.md' },
{ ...mockFile, name: 'file2.md', path: 'file2.md' },
] as TFile[];
mockApp.vault.getMarkdownFiles.mockReturnValue(mockFiles);
mockApp.metadataCache.getFileCache
.mockReturnValueOnce({ tags: [{ tag: '#tag1' }] })
.mockReturnValueOnce({ tags: [{ tag: '#tag2' }, { tag: '#tag3' }] });
// Mock getAllTags to return the tags for each file
mockGetAllTags
.mockReturnValueOnce(['#tag1'])
.mockReturnValueOnce(['#tag2', '#tag3']);
const result = metadataExtractor.extractAllTags();
expect(result).toBeInstanceOf(Set);
expect(Array.from(result)).toEqual(['#tag1', '#tag2', '#tag3']);
});
it('should handle files with no tags', () => {
const mockFiles = [
{ ...mockFile, name: 'file1.md', path: 'file1.md' },
] as TFile[];
mockApp.vault.getMarkdownFiles.mockReturnValue(mockFiles);
mockApp.metadataCache.getFileCache.mockReturnValue({});
const result = metadataExtractor.extractAllTags();
expect(result.size).toBe(0);
});
it('should handle cache errors gracefully', () => {
const mockFiles = [
{ ...mockFile, name: 'file1.md', path: 'file1.md' },
{ ...mockFile, name: 'file2.md', path: 'file2.md' },
] as TFile[];
mockApp.vault.getMarkdownFiles.mockReturnValue(mockFiles);
mockApp.metadataCache.getFileCache.mockImplementation(() => {
throw new Error('Cache error');
});
const result = metadataExtractor.extractAllTags();
expect(result.size).toBe(0); // Should not throw, just return empty set
});
it('should deduplicate tags', () => {
const mockFiles = [
{ ...mockFile, name: 'file1.md', path: 'file1.md' },
{ ...mockFile, name: 'file2.md', path: 'file2.md' },
] as TFile[];
mockApp.vault.getMarkdownFiles.mockReturnValue(mockFiles);
mockApp.metadataCache.getFileCache.mockReturnValue({
tags: [{ tag: '#duplicate' }, { tag: '#unique' }],
});
// Mock getAllTags to return the tags
mockGetAllTags.mockReturnValue(['#duplicate', '#unique']);
const result = metadataExtractor.extractAllTags();
expect(Array.from(result)).toEqual(['#duplicate', '#unique']);
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -3,333 +3,449 @@ import { FileMetadata } from '../../types/Common';
import { Rule } from '../../types/Rule';
describe('RuleMatcher', () => {
let ruleMatcher: RuleMatcher;
let mockMetadata: FileMetadata;
let ruleMatcher: RuleMatcher;
let mockMetadata: FileMetadata;
beforeEach(() => {
ruleMatcher = new RuleMatcher();
mockMetadata = {
fileName: 'test.md',
filePath: 'folder/test.md',
tags: ['#work', '#project/alpha'],
properties: {
status: 'active',
priority: 'high',
count: 42
},
fileContent: 'This is a test file with some content',
createdAt: new Date('2023-01-01T10:00:00Z'),
updatedAt: new Date('2023-01-02T15:30:00Z')
};
beforeEach(() => {
ruleMatcher = new RuleMatcher();
mockMetadata = {
fileName: 'test.md',
filePath: 'folder/test.md',
tags: ['#work', '#project/alpha'],
properties: {
status: 'active',
priority: 'high',
count: 42,
},
fileContent: 'This is a test file with some content',
createdAt: new Date('2023-01-01T10:00:00Z'),
updatedAt: new Date('2023-01-02T15:30:00Z'),
};
});
describe('matchTag', () => {
it('should match exact tags', () => {
expect(ruleMatcher.matchTag(['#work', '#project'], '#work')).toBe(true);
expect(ruleMatcher.matchTag(['#work', '#project'], '#home')).toBe(false);
});
describe('matchTag', () => {
it('should match exact tags', () => {
expect(ruleMatcher.matchTag(['#work', '#project'], '#work')).toBe(true);
expect(ruleMatcher.matchTag(['#work', '#project'], '#home')).toBe(false);
});
it('should match parent tags with subtags', () => {
expect(ruleMatcher.matchTag(['#work/urgent', '#project'], '#work')).toBe(true);
expect(ruleMatcher.matchTag(['#project/alpha/beta'], '#project')).toBe(true);
expect(ruleMatcher.matchTag(['#project/alpha/beta'], '#project/alpha')).toBe(true);
});
it('should not match unrelated tags', () => {
expect(ruleMatcher.matchTag(['#work'], '#project')).toBe(false);
expect(ruleMatcher.matchTag(['#workplace'], '#work')).toBe(false);
});
it('should match parent tags with subtags', () => {
expect(ruleMatcher.matchTag(['#work/urgent', '#project'], '#work')).toBe(
true
);
expect(ruleMatcher.matchTag(['#project/alpha/beta'], '#project')).toBe(
true
);
expect(
ruleMatcher.matchTag(['#project/alpha/beta'], '#project/alpha')
).toBe(true);
});
describe('matchProperty', () => {
const properties = {
status: 'active',
priority: 'high',
count: 42,
empty: '',
nullValue: null
};
it('should not match unrelated tags', () => {
expect(ruleMatcher.matchTag(['#work'], '#project')).toBe(false);
expect(ruleMatcher.matchTag(['#workplace'], '#work')).toBe(false);
});
});
it('should match property existence', () => {
expect(ruleMatcher.matchProperty(properties, 'status')).toBe(true);
expect(ruleMatcher.matchProperty(properties, 'priority')).toBe(true);
expect(ruleMatcher.matchProperty(properties, 'nonexistent')).toBe(false);
expect(ruleMatcher.matchProperty(properties, 'nullValue')).toBe(false);
});
describe('matchProperty', () => {
const properties = {
status: 'active',
priority: 'high',
count: 42,
empty: '',
nullValue: null,
};
it('should match exact property values', () => {
expect(ruleMatcher.matchProperty(properties, 'status:active')).toBe(true);
expect(ruleMatcher.matchProperty(properties, 'priority:high')).toBe(true);
expect(ruleMatcher.matchProperty(properties, 'count:42')).toBe(true);
expect(ruleMatcher.matchProperty(properties, 'status:inactive')).toBe(false);
});
it('should handle case insensitive matching', () => {
expect(ruleMatcher.matchProperty(properties, 'status:ACTIVE')).toBe(true);
expect(ruleMatcher.matchProperty(properties, 'priority:HIGH')).toBe(true);
});
it('should handle empty values', () => {
expect(ruleMatcher.matchProperty(properties, 'empty:')).toBe(true);
expect(ruleMatcher.matchProperty(properties, 'empty:something')).toBe(false);
});
it('should match property existence', () => {
expect(ruleMatcher.matchProperty(properties, 'status')).toBe(true);
expect(ruleMatcher.matchProperty(properties, 'priority')).toBe(true);
expect(ruleMatcher.matchProperty(properties, 'nonexistent')).toBe(false);
expect(ruleMatcher.matchProperty(properties, 'nullValue')).toBe(false);
});
describe('matchFileName', () => {
it('should match exact file names', () => {
expect(ruleMatcher.matchFileName('test.md', 'test.md')).toBe(true);
expect(ruleMatcher.matchFileName('Daily Test.md', 'Daily Test.md')).toBe(true);
expect(ruleMatcher.matchFileName('test.md', 'other.md')).toBe(false);
});
it('should match wildcard patterns with *', () => {
// Test cases from the user's issue
expect(ruleMatcher.matchFileName('Daily Test.md', 'Daily*')).toBe(true);
expect(ruleMatcher.matchFileName('Daily Notes.md', 'Daily*')).toBe(true);
expect(ruleMatcher.matchFileName('Course - Introduction.md', 'Course*')).toBe(true);
expect(ruleMatcher.matchFileName('Course - Advanced.md', 'Course*')).toBe(true);
// Edge cases
expect(ruleMatcher.matchFileName('test.md', 'test*')).toBe(true);
expect(ruleMatcher.matchFileName('test.txt', 'test*')).toBe(true);
expect(ruleMatcher.matchFileName('other.md', 'test*')).toBe(false);
expect(ruleMatcher.matchFileName('pretest.md', 'test*')).toBe(false);
});
it('should match wildcard patterns with ?', () => {
expect(ruleMatcher.matchFileName('test1.md', 'test?.md')).toBe(true);
expect(ruleMatcher.matchFileName('testA.md', 'test?.md')).toBe(true);
expect(ruleMatcher.matchFileName('test.md', 'test?.md')).toBe(false);
expect(ruleMatcher.matchFileName('test12.md', 'test?.md')).toBe(false);
});
it('should be case insensitive', () => {
expect(ruleMatcher.matchFileName('Daily Test.md', 'daily*')).toBe(true);
expect(ruleMatcher.matchFileName('daily test.md', 'Daily*')).toBe(true);
expect(ruleMatcher.matchFileName('COURSE - Intro.md', 'course*')).toBe(true);
});
it('should handle complex patterns', () => {
expect(ruleMatcher.matchFileName('Course - Introduction.md', 'Course*')).toBe(true);
expect(ruleMatcher.matchFileName('Course - Advanced Topics.md', 'Course*')).toBe(true);
expect(ruleMatcher.matchFileName('Meeting Notes.md', 'Meeting*')).toBe(true);
expect(ruleMatcher.matchFileName('Daily Test.md', 'Daily*')).toBe(true);
});
it('should handle patterns with special regex characters', () => {
// Test that special regex characters are properly escaped
expect(ruleMatcher.matchFileName('test.file.md', 'test.file*')).toBe(true);
expect(ruleMatcher.matchFileName('test+file.md', 'test+file*')).toBe(true);
expect(ruleMatcher.matchFileName('test[file].md', 'test[file]*')).toBe(true);
expect(ruleMatcher.matchFileName('test(file).md', 'test(file)*')).toBe(true);
});
it('should handle empty patterns', () => {
expect(ruleMatcher.matchFileName('test.md', '')).toBe(false);
expect(ruleMatcher.matchFileName('', 'test*')).toBe(false);
expect(ruleMatcher.matchFileName('', '')).toBe(true);
});
it('should match exact property values', () => {
expect(ruleMatcher.matchProperty(properties, 'status:active')).toBe(true);
expect(ruleMatcher.matchProperty(properties, 'priority:high')).toBe(true);
expect(ruleMatcher.matchProperty(properties, 'count:42')).toBe(true);
expect(ruleMatcher.matchProperty(properties, 'status:inactive')).toBe(
false
);
});
describe('evaluateCriteria', () => {
it('should evaluate tag criteria', () => {
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'tag:#work')).toBe(true);
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'tag:#project')).toBe(true);
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'tag:#home')).toBe(false);
});
it('should evaluate fileName criteria', () => {
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'fileName:test.md')).toBe(true);
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'fileName:other.md')).toBe(false);
});
it('should evaluate fileName criteria with wildcards', () => {
// Test wildcard patterns
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'fileName:test*')).toBe(true);
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'fileName:*.md')).toBe(true);
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'fileName:test?.md')).toBe(false); // ? requires exactly one character
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'fileName:other*')).toBe(false);
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'fileName:*.txt')).toBe(false);
});
it('should handle real-world fileName patterns from user issue', () => {
// Create metadata for the specific files mentioned in the issue
const dailyTestMetadata = {
...mockMetadata,
fileName: 'Daily Test.md'
};
const courseMetadata = {
...mockMetadata,
fileName: 'Course - Introduction.md'
};
// Test the exact patterns the user tried
expect(ruleMatcher.evaluateCriteria(dailyTestMetadata, 'fileName:Daily')).toBe(false); // No wildcard
expect(ruleMatcher.evaluateCriteria(dailyTestMetadata, 'fileName:Daily*')).toBe(true); // With wildcard
expect(ruleMatcher.evaluateCriteria(dailyTestMetadata, 'fileName:Daily -')).toBe(false); // No wildcard
expect(ruleMatcher.evaluateCriteria(dailyTestMetadata, 'fileName:Daily *')).toBe(true); // With wildcard (space instead of dash)
expect(ruleMatcher.evaluateCriteria(courseMetadata, 'fileName:Course')).toBe(false); // No wildcard
expect(ruleMatcher.evaluateCriteria(courseMetadata, 'fileName:Course*')).toBe(true); // With wildcard
expect(ruleMatcher.evaluateCriteria(courseMetadata, 'fileName:Course -')).toBe(false); // No wildcard
expect(ruleMatcher.evaluateCriteria(courseMetadata, 'fileName:Course - *')).toBe(true); // With wildcard
});
it('should evaluate path criteria', () => {
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'path:folder/test.md')).toBe(true);
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'path:other/path.md')).toBe(false);
});
it('should evaluate content criteria', () => {
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'content:test file')).toBe(true);
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'content:missing text')).toBe(false);
});
it('should evaluate date criteria', () => {
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'created_at:2023-01-01')).toBe(true);
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'updated_at:2023-01-02')).toBe(true);
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'created_at:2023-02-01')).toBe(false);
});
it('should evaluate property criteria', () => {
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'property:status:active')).toBe(true);
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'property:priority')).toBe(true);
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'property:status:inactive')).toBe(false);
});
it('should handle invalid criteria format', () => {
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'invalid_format')).toBe(false);
expect(ruleMatcher.evaluateCriteria(mockMetadata, '')).toBe(false);
});
it('should handle case insensitive matching', () => {
expect(ruleMatcher.matchProperty(properties, 'status:ACTIVE')).toBe(true);
expect(ruleMatcher.matchProperty(properties, 'priority:HIGH')).toBe(true);
});
describe('evaluateFilter', () => {
it('should pass when no filter is set', () => {
expect(ruleMatcher.evaluateFilter(mockMetadata, [], false)).toBe(true);
expect(ruleMatcher.evaluateFilter(mockMetadata, [], true)).toBe(true);
});
it('should handle empty values', () => {
expect(ruleMatcher.matchProperty(properties, 'empty:')).toBe(true);
expect(ruleMatcher.matchProperty(properties, 'empty:something')).toBe(
false
);
});
});
it('should handle blacklist filter', () => {
// File matches blacklist filter - should not pass
expect(ruleMatcher.evaluateFilter(mockMetadata, ['tag:#work'], false)).toBe(false);
// File doesn't match blacklist filter - should pass
expect(ruleMatcher.evaluateFilter(mockMetadata, ['tag:#home'], false)).toBe(true);
});
it('should handle whitelist filter', () => {
// File matches whitelist filter - should pass
expect(ruleMatcher.evaluateFilter(mockMetadata, ['tag:#work'], true)).toBe(true);
// File doesn't match whitelist filter - should not pass
expect(ruleMatcher.evaluateFilter(mockMetadata, ['tag:#home'], true)).toBe(false);
});
it('should handle multiple filters', () => {
const multipleFilters = ['tag:#work', 'fileName:test.md'];
// Blacklist: if any filter matches, file is blocked
expect(ruleMatcher.evaluateFilter(mockMetadata, multipleFilters, false)).toBe(false);
// Whitelist: all filters must match for file to pass
expect(ruleMatcher.evaluateFilter(mockMetadata, multipleFilters, true)).toBe(true);
const mixedFilters = ['tag:#work', 'fileName:other.md'];
expect(ruleMatcher.evaluateFilter(mockMetadata, mixedFilters, true)).toBe(false);
});
describe('matchFileName', () => {
it('should match exact file names', () => {
expect(ruleMatcher.matchFileName('test.md', 'test.md')).toBe(true);
expect(ruleMatcher.matchFileName('Daily Test.md', 'Daily Test.md')).toBe(
true
);
expect(ruleMatcher.matchFileName('test.md', 'other.md')).toBe(false);
});
describe('findMatchingRule', () => {
const rules: Rule[] = [
{ criteria: 'tag:#work', path: 'work-folder' },
{ criteria: 'tag:#project', path: 'project-folder' },
{ criteria: 'fileName:test.md', path: 'test-folder' },
{ criteria: 'property:status:active', path: 'active-folder' }
];
it('should match wildcard patterns with *', () => {
// Test cases from the user's issue
expect(ruleMatcher.matchFileName('Daily Test.md', 'Daily*')).toBe(true);
expect(ruleMatcher.matchFileName('Daily Notes.md', 'Daily*')).toBe(true);
expect(
ruleMatcher.matchFileName('Course - Introduction.md', 'Course*')
).toBe(true);
expect(ruleMatcher.matchFileName('Course - Advanced.md', 'Course*')).toBe(
true
);
it('should find matching rule', () => {
const result = ruleMatcher.findMatchingRule(mockMetadata, rules);
expect(result).not.toBeNull();
expect(result?.path).toBe('work-folder'); // First matching rule
});
it('should return null when no rule matches', () => {
const noMatchMetadata: FileMetadata = {
...mockMetadata,
tags: ['#home'],
fileName: 'other.md',
properties: { status: 'inactive' }
};
const result = ruleMatcher.findMatchingRule(noMatchMetadata, rules);
expect(result).toBeNull();
});
it('should respect rule priority based on specificity', () => {
const specificRules: Rule[] = [
{ criteria: 'tag:#project', path: 'general-project' },
{ criteria: 'tag:#project/alpha', path: 'specific-project' }
];
const result = ruleMatcher.findMatchingRule(mockMetadata, specificRules);
expect(result?.path).toBe('specific-project'); // More specific rule should win
});
// Edge cases
expect(ruleMatcher.matchFileName('test.md', 'test*')).toBe(true);
expect(ruleMatcher.matchFileName('test.txt', 'test*')).toBe(true);
expect(ruleMatcher.matchFileName('other.md', 'test*')).toBe(false);
expect(ruleMatcher.matchFileName('pretest.md', 'test*')).toBe(false);
});
describe('sortRulesBySpecificity', () => {
it('should sort tag rules by specificity', () => {
const rules: Rule[] = [
{ criteria: 'tag:#work', path: 'work' },
{ criteria: 'tag:#work/urgent/critical', path: 'critical' },
{ criteria: 'tag:#work/urgent', path: 'urgent' },
{ criteria: 'fileName:test.md', path: 'test' }
];
const sorted = ruleMatcher.sortRulesBySpecificity(rules);
expect(sorted[0].path).toBe('critical'); // Most specific tag
expect(sorted[1].path).toBe('urgent'); // Medium specific tag
expect(sorted[2].path).toBe('work'); // Least specific tag
expect(sorted[3].path).toBe('test'); // Non-tag rule maintains position
});
it('should maintain order for non-tag rules', () => {
const rules: Rule[] = [
{ criteria: 'fileName:test.md', path: 'test' },
{ criteria: 'property:status:active', path: 'active' },
{ criteria: 'content:important', path: 'important' }
];
const sorted = ruleMatcher.sortRulesBySpecificity(rules);
// Order should remain the same for non-tag rules
expect(sorted.map(r => r.path)).toEqual(['test', 'active', 'important']);
});
it('should match wildcard patterns with ?', () => {
expect(ruleMatcher.matchFileName('test1.md', 'test?.md')).toBe(true);
expect(ruleMatcher.matchFileName('testA.md', 'test?.md')).toBe(true);
expect(ruleMatcher.matchFileName('test.md', 'test?.md')).toBe(false);
expect(ruleMatcher.matchFileName('test12.md', 'test?.md')).toBe(false);
});
describe('getFilterMatchDetails', () => {
it('should return passes=true when no filter', () => {
const result = ruleMatcher.getFilterMatchDetails(mockMetadata, [], false);
expect(result.passes).toBe(true);
expect(result.blockingFilter).toBeUndefined();
});
it('should provide blocking filter details for blacklist', () => {
const result = ruleMatcher.getFilterMatchDetails(mockMetadata, ['tag:#work'], false);
expect(result.passes).toBe(false);
expect(result.blockingFilter).toBe('tag:#work');
expect(result.blockReason).toBe('Blocked by blacklist filter');
});
it('should provide blocking filter details for whitelist', () => {
const result = ruleMatcher.getFilterMatchDetails(mockMetadata, ['tag:#home'], true);
expect(result.passes).toBe(false);
expect(result.blockingFilter).toBe('tag:#home');
expect(result.blockReason).toBe('Not in whitelist');
});
it('should return passes=true when filter passes', () => {
const result = ruleMatcher.getFilterMatchDetails(mockMetadata, ['tag:#work'], true);
expect(result.passes).toBe(true);
expect(result.blockingFilter).toBeUndefined();
});
it('should be case insensitive', () => {
expect(ruleMatcher.matchFileName('Daily Test.md', 'daily*')).toBe(true);
expect(ruleMatcher.matchFileName('daily test.md', 'Daily*')).toBe(true);
expect(ruleMatcher.matchFileName('COURSE - Intro.md', 'course*')).toBe(
true
);
});
it('should handle complex patterns', () => {
expect(
ruleMatcher.matchFileName('Course - Introduction.md', 'Course*')
).toBe(true);
expect(
ruleMatcher.matchFileName('Course - Advanced Topics.md', 'Course*')
).toBe(true);
expect(ruleMatcher.matchFileName('Meeting Notes.md', 'Meeting*')).toBe(
true
);
expect(ruleMatcher.matchFileName('Daily Test.md', 'Daily*')).toBe(true);
});
it('should handle patterns with special regex characters', () => {
// Test that special regex characters are properly escaped
expect(ruleMatcher.matchFileName('test.file.md', 'test.file*')).toBe(
true
);
expect(ruleMatcher.matchFileName('test+file.md', 'test+file*')).toBe(
true
);
expect(ruleMatcher.matchFileName('test[file].md', 'test[file]*')).toBe(
true
);
expect(ruleMatcher.matchFileName('test(file).md', 'test(file)*')).toBe(
true
);
});
it('should handle empty patterns', () => {
expect(ruleMatcher.matchFileName('test.md', '')).toBe(false);
expect(ruleMatcher.matchFileName('', 'test*')).toBe(false);
expect(ruleMatcher.matchFileName('', '')).toBe(true);
});
});
describe('evaluateCriteria', () => {
it('should evaluate tag criteria', () => {
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'tag:#work')).toBe(
true
);
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'tag:#project')).toBe(
true
);
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'tag:#home')).toBe(
false
);
});
it('should evaluate fileName criteria', () => {
expect(
ruleMatcher.evaluateCriteria(mockMetadata, 'fileName:test.md')
).toBe(true);
expect(
ruleMatcher.evaluateCriteria(mockMetadata, 'fileName:other.md')
).toBe(false);
});
it('should evaluate fileName criteria with wildcards', () => {
// Test wildcard patterns
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'fileName:test*')).toBe(
true
);
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'fileName:*.md')).toBe(
true
);
expect(
ruleMatcher.evaluateCriteria(mockMetadata, 'fileName:test?.md')
).toBe(false); // ? requires exactly one character
expect(
ruleMatcher.evaluateCriteria(mockMetadata, 'fileName:other*')
).toBe(false);
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'fileName:*.txt')).toBe(
false
);
});
it('should handle real-world fileName patterns from user issue', () => {
// Create metadata for the specific files mentioned in the issue
const dailyTestMetadata = {
...mockMetadata,
fileName: 'Daily Test.md',
};
const courseMetadata = {
...mockMetadata,
fileName: 'Course - Introduction.md',
};
// Test the exact patterns the user tried
expect(
ruleMatcher.evaluateCriteria(dailyTestMetadata, 'fileName:Daily')
).toBe(false); // No wildcard
expect(
ruleMatcher.evaluateCriteria(dailyTestMetadata, 'fileName:Daily*')
).toBe(true); // With wildcard
expect(
ruleMatcher.evaluateCriteria(dailyTestMetadata, 'fileName:Daily -')
).toBe(false); // No wildcard
expect(
ruleMatcher.evaluateCriteria(dailyTestMetadata, 'fileName:Daily *')
).toBe(true); // With wildcard (space instead of dash)
expect(
ruleMatcher.evaluateCriteria(courseMetadata, 'fileName:Course')
).toBe(false); // No wildcard
expect(
ruleMatcher.evaluateCriteria(courseMetadata, 'fileName:Course*')
).toBe(true); // With wildcard
expect(
ruleMatcher.evaluateCriteria(courseMetadata, 'fileName:Course -')
).toBe(false); // No wildcard
expect(
ruleMatcher.evaluateCriteria(courseMetadata, 'fileName:Course - *')
).toBe(true); // With wildcard
});
it('should evaluate path criteria', () => {
expect(
ruleMatcher.evaluateCriteria(mockMetadata, 'path:folder/test.md')
).toBe(true);
expect(
ruleMatcher.evaluateCriteria(mockMetadata, 'path:other/path.md')
).toBe(false);
});
it('should evaluate content criteria', () => {
expect(
ruleMatcher.evaluateCriteria(mockMetadata, 'content:test file')
).toBe(true);
expect(
ruleMatcher.evaluateCriteria(mockMetadata, 'content:missing text')
).toBe(false);
});
it('should evaluate date criteria', () => {
expect(
ruleMatcher.evaluateCriteria(mockMetadata, 'created_at:2023-01-01')
).toBe(true);
expect(
ruleMatcher.evaluateCriteria(mockMetadata, 'updated_at:2023-01-02')
).toBe(true);
expect(
ruleMatcher.evaluateCriteria(mockMetadata, 'created_at:2023-02-01')
).toBe(false);
});
it('should evaluate property criteria', () => {
expect(
ruleMatcher.evaluateCriteria(mockMetadata, 'property:status:active')
).toBe(true);
expect(
ruleMatcher.evaluateCriteria(mockMetadata, 'property:priority')
).toBe(true);
expect(
ruleMatcher.evaluateCriteria(mockMetadata, 'property:status:inactive')
).toBe(false);
});
it('should handle invalid criteria format', () => {
expect(ruleMatcher.evaluateCriteria(mockMetadata, 'invalid_format')).toBe(
false
);
expect(ruleMatcher.evaluateCriteria(mockMetadata, '')).toBe(false);
});
});
describe('evaluateFilter', () => {
it('should pass when no filter is set', () => {
expect(ruleMatcher.evaluateFilter(mockMetadata, [], false)).toBe(true);
expect(ruleMatcher.evaluateFilter(mockMetadata, [], true)).toBe(true);
});
it('should handle blacklist filter', () => {
// File matches blacklist filter - should not pass
expect(
ruleMatcher.evaluateFilter(mockMetadata, ['tag:#work'], false)
).toBe(false);
// File doesn't match blacklist filter - should pass
expect(
ruleMatcher.evaluateFilter(mockMetadata, ['tag:#home'], false)
).toBe(true);
});
it('should handle whitelist filter', () => {
// File matches whitelist filter - should pass
expect(
ruleMatcher.evaluateFilter(mockMetadata, ['tag:#work'], true)
).toBe(true);
// File doesn't match whitelist filter - should not pass
expect(
ruleMatcher.evaluateFilter(mockMetadata, ['tag:#home'], true)
).toBe(false);
});
it('should handle multiple filters', () => {
const multipleFilters = ['tag:#work', 'fileName:test.md'];
// Blacklist: if any filter matches, file is blocked
expect(
ruleMatcher.evaluateFilter(mockMetadata, multipleFilters, false)
).toBe(false);
// Whitelist: all filters must match for file to pass
expect(
ruleMatcher.evaluateFilter(mockMetadata, multipleFilters, true)
).toBe(true);
const mixedFilters = ['tag:#work', 'fileName:other.md'];
expect(ruleMatcher.evaluateFilter(mockMetadata, mixedFilters, true)).toBe(
false
);
});
});
describe('findMatchingRule', () => {
const rules: Rule[] = [
{ criteria: 'tag:#work', path: 'work-folder' },
{ criteria: 'tag:#project', path: 'project-folder' },
{ criteria: 'fileName:test.md', path: 'test-folder' },
{ criteria: 'property:status:active', path: 'active-folder' },
];
it('should find matching rule', () => {
const result = ruleMatcher.findMatchingRule(mockMetadata, rules);
expect(result).not.toBeNull();
expect(result?.path).toBe('work-folder'); // First matching rule
});
it('should return null when no rule matches', () => {
const noMatchMetadata: FileMetadata = {
...mockMetadata,
tags: ['#home'],
fileName: 'other.md',
properties: { status: 'inactive' },
};
const result = ruleMatcher.findMatchingRule(noMatchMetadata, rules);
expect(result).toBeNull();
});
it('should respect rule priority based on specificity', () => {
const specificRules: Rule[] = [
{ criteria: 'tag:#project', path: 'general-project' },
{ criteria: 'tag:#project/alpha', path: 'specific-project' },
];
const result = ruleMatcher.findMatchingRule(mockMetadata, specificRules);
expect(result?.path).toBe('specific-project'); // More specific rule should win
});
});
describe('sortRulesBySpecificity', () => {
it('should sort tag rules by specificity', () => {
const rules: Rule[] = [
{ criteria: 'tag:#work', path: 'work' },
{ criteria: 'tag:#work/urgent/critical', path: 'critical' },
{ criteria: 'tag:#work/urgent', path: 'urgent' },
{ criteria: 'fileName:test.md', path: 'test' },
];
const sorted = ruleMatcher.sortRulesBySpecificity(rules);
expect(sorted[0].path).toBe('critical'); // Most specific tag
expect(sorted[1].path).toBe('urgent'); // Medium specific tag
expect(sorted[2].path).toBe('work'); // Least specific tag
expect(sorted[3].path).toBe('test'); // Non-tag rule maintains position
});
it('should maintain order for non-tag rules', () => {
const rules: Rule[] = [
{ criteria: 'fileName:test.md', path: 'test' },
{ criteria: 'property:status:active', path: 'active' },
{ criteria: 'content:important', path: 'important' },
];
const sorted = ruleMatcher.sortRulesBySpecificity(rules);
// Order should remain the same for non-tag rules
expect(sorted.map(r => r.path)).toEqual(['test', 'active', 'important']);
});
});
describe('getFilterMatchDetails', () => {
it('should return passes=true when no filter', () => {
const result = ruleMatcher.getFilterMatchDetails(mockMetadata, [], false);
expect(result.passes).toBe(true);
expect(result.blockingFilter).toBeUndefined();
});
it('should provide blocking filter details for blacklist', () => {
const result = ruleMatcher.getFilterMatchDetails(
mockMetadata,
['tag:#work'],
false
);
expect(result.passes).toBe(false);
expect(result.blockingFilter).toBe('tag:#work');
expect(result.blockReason).toBe('Blocked by blacklist filter');
});
it('should provide blocking filter details for whitelist', () => {
const result = ruleMatcher.getFilterMatchDetails(
mockMetadata,
['tag:#home'],
true
);
expect(result.passes).toBe(false);
expect(result.blockingFilter).toBe('tag:#home');
expect(result.blockReason).toBe('Not in whitelist');
});
it('should return passes=true when filter passes', () => {
const result = ruleMatcher.getFilterMatchDetails(
mockMetadata,
['tag:#work'],
true
);
expect(result.passes).toBe(true);
expect(result.blockingFilter).toBeUndefined();
});
});
});

View file

@ -6,149 +6,151 @@ import { TFile } from 'obsidian';
const setIntervalMock = jest.fn();
const clearIntervalMock = jest.fn();
global.window = Object.assign(global.window || {}, {
setInterval: setIntervalMock,
clearInterval: clearIntervalMock
setInterval: setIntervalMock,
clearInterval: clearIntervalMock,
});
describe('TriggerEventHandler', () => {
let plugin: NoteMoverShortcutPlugin;
let triggerHandler: TriggerEventHandler;
let mockFile: TFile;
let plugin: NoteMoverShortcutPlugin;
let triggerHandler: TriggerEventHandler;
let mockFile: TFile;
beforeEach(() => {
setIntervalMock.mockClear();
clearIntervalMock.mockClear();
beforeEach(() => {
setIntervalMock.mockClear();
clearIntervalMock.mockClear();
// Mock File
mockFile = {
path: 'test/path/test.md',
name: 'test.md',
extension: 'md'
} as TFile;
// Mock File
mockFile = {
path: 'test/path/test.md',
name: 'test.md',
extension: 'md',
} as TFile;
// Mock Plugin
plugin = {
settings: {
enablePeriodicMovement: false,
periodicMovementInterval: 5,
enableOnEditTrigger: false
},
noteMover: {
moveAllFilesInVaultPeriodic: jest.fn(),
moveFileBasedOnTags: jest.fn()
},
registerEvent: jest.fn().mockReturnValue(() => {}),
app: {
vault: {
on: jest.fn().mockImplementation((eventName, callback) => ({
eventName,
callback
}))
}
}
} as unknown as NoteMoverShortcutPlugin;
// Mock Plugin
plugin = {
settings: {
enablePeriodicMovement: false,
periodicMovementInterval: 5,
enableOnEditTrigger: false,
},
noteMover: {
moveAllFilesInVaultPeriodic: jest.fn(),
moveFileBasedOnTags: jest.fn(),
},
registerEvent: jest.fn().mockReturnValue(() => {}),
app: {
vault: {
on: jest.fn().mockImplementation((eventName, callback) => ({
eventName,
callback,
})),
},
},
} as unknown as NoteMoverShortcutPlugin;
triggerHandler = new TriggerEventHandler(plugin);
triggerHandler = new TriggerEventHandler(plugin);
});
describe('togglePeriodic', () => {
it('should clear existing interval when called', () => {
// Set up existing interval
const mockIntervalId = 123;
(triggerHandler as any).periodicIntervalId = mockIntervalId;
triggerHandler.togglePeriodic();
expect(window.clearInterval).toHaveBeenCalledWith(mockIntervalId);
});
describe('togglePeriodic', () => {
it('should clear existing interval when called', () => {
// Set up existing interval
const mockIntervalId = 123;
(triggerHandler as any).periodicIntervalId = mockIntervalId;
triggerHandler.togglePeriodic();
expect(window.clearInterval).toHaveBeenCalledWith(mockIntervalId);
});
it('should start new interval if periodic movement is enabled', () => {
plugin.settings.enablePeriodicMovement = true;
plugin.settings.periodicMovementInterval = 10;
it('should start new interval if periodic movement is enabled', () => {
plugin.settings.enablePeriodicMovement = true;
plugin.settings.periodicMovementInterval = 10;
triggerHandler.togglePeriodic();
expect(window.setInterval).toHaveBeenCalled();
// Verify interval ID is set
expect((triggerHandler as any).periodicIntervalId).not.toBeNull();
});
triggerHandler.togglePeriodic();
it('should not start interval if periodic movement is disabled', () => {
plugin.settings.enablePeriodicMovement = false;
triggerHandler.togglePeriodic();
expect(window.setInterval).not.toHaveBeenCalled();
expect((triggerHandler as any).periodicIntervalId).toBeNull();
});
it('should call moveAllFilesInVaultPeriodic when interval fires', async () => {
plugin.settings.enablePeriodicMovement = true;
plugin.settings.periodicMovementInterval = 1; // 1 minute
triggerHandler.togglePeriodic();
// Get the callback function passed to setInterval
const intervalCallback = setIntervalMock.mock.calls[0][0];
const intervalMs = setIntervalMock.mock.calls[0][1];
expect(intervalMs).toBe(60 * 1000); // 1 minute in ms
// Call the callback
await intervalCallback();
expect(plugin.noteMover.moveAllFilesInVaultPeriodic).toHaveBeenCalled();
});
expect(window.setInterval).toHaveBeenCalled();
// Verify interval ID is set
expect((triggerHandler as any).periodicIntervalId).not.toBeNull();
});
describe('toggleOnEditListener', () => {
it('should unregister existing listener when called', () => {
const mockUnregister = jest.fn();
(triggerHandler as any).onEditUnregister = mockUnregister;
triggerHandler.toggleOnEditListener();
expect(mockUnregister).toHaveBeenCalled();
expect((triggerHandler as any).onEditUnregister).toBeNull();
});
it('should not start interval if periodic movement is disabled', () => {
plugin.settings.enablePeriodicMovement = false;
it('should register new listener if on-edit trigger is enabled', () => {
(plugin.settings as any).enableOnEditTrigger = true;
const mockUnregister = jest.fn();
plugin.registerEvent = jest.fn().mockReturnValue(mockUnregister);
triggerHandler.toggleOnEditListener();
expect(plugin.registerEvent).toHaveBeenCalled();
expect((triggerHandler as any).onEditUnregister).toBe(mockUnregister);
});
triggerHandler.togglePeriodic();
it('should not register listener if on-edit trigger is disabled', () => {
(plugin.settings as any).enableOnEditTrigger = false;
triggerHandler.toggleOnEditListener();
expect(plugin.registerEvent).not.toHaveBeenCalled();
expect((triggerHandler as any).onEditUnregister).toBeNull();
});
it('should register event listener when on-edit trigger is enabled', () => {
(plugin.settings as any).enableOnEditTrigger = true;
triggerHandler.toggleOnEditListener();
expect(plugin.registerEvent).toHaveBeenCalled();
expect((triggerHandler as any).onEditUnregister).not.toBeNull();
});
it('should handle file modification events correctly', () => {
(plugin.settings as any).enableOnEditTrigger = true;
triggerHandler.toggleOnEditListener();
// Verify that the event listener was registered
expect(plugin.registerEvent).toHaveBeenCalled();
expect(plugin.app.vault.on).toHaveBeenCalledWith('modify', expect.any(Function));
});
expect(window.setInterval).not.toHaveBeenCalled();
expect((triggerHandler as any).periodicIntervalId).toBeNull();
});
it('should call moveAllFilesInVaultPeriodic when interval fires', async () => {
plugin.settings.enablePeriodicMovement = true;
plugin.settings.periodicMovementInterval = 1; // 1 minute
triggerHandler.togglePeriodic();
// Get the callback function passed to setInterval
const intervalCallback = setIntervalMock.mock.calls[0][0];
const intervalMs = setIntervalMock.mock.calls[0][1];
expect(intervalMs).toBe(60 * 1000); // 1 minute in ms
// Call the callback
await intervalCallback();
expect(plugin.noteMover.moveAllFilesInVaultPeriodic).toHaveBeenCalled();
});
});
describe('toggleOnEditListener', () => {
it('should unregister existing listener when called', () => {
const mockUnregister = jest.fn();
(triggerHandler as any).onEditUnregister = mockUnregister;
triggerHandler.toggleOnEditListener();
expect(mockUnregister).toHaveBeenCalled();
expect((triggerHandler as any).onEditUnregister).toBeNull();
});
it('should register new listener if on-edit trigger is enabled', () => {
(plugin.settings as any).enableOnEditTrigger = true;
plugin.registerEvent = jest.fn();
triggerHandler.toggleOnEditListener();
expect(plugin.registerEvent).toHaveBeenCalled();
expect((triggerHandler as any).onEditUnregister).toBeDefined();
});
it('should not register listener if on-edit trigger is disabled', () => {
(plugin.settings as any).enableOnEditTrigger = false;
triggerHandler.toggleOnEditListener();
expect(plugin.registerEvent).not.toHaveBeenCalled();
expect((triggerHandler as any).onEditUnregister).toBeNull();
});
it('should register event listener when on-edit trigger is enabled', () => {
(plugin.settings as any).enableOnEditTrigger = true;
triggerHandler.toggleOnEditListener();
expect(plugin.registerEvent).toHaveBeenCalled();
expect((triggerHandler as any).onEditUnregister).not.toBeNull();
});
it('should handle file modification events correctly', () => {
(plugin.settings as any).enableOnEditTrigger = true;
triggerHandler.toggleOnEditListener();
// Verify that the event listener was registered
expect(plugin.registerEvent).toHaveBeenCalled();
expect(plugin.app.vault.on).toHaveBeenCalledWith(
'modify',
expect.any(Function)
);
});
});
});

View file

@ -1,86 +1,89 @@
import NoteMoverShortcutPlugin from "main";
import { Editor, MarkdownView, Notice } from "obsidian";
import { HistoryModal } from "../modals/HistoryModal";
import { UpdateModal } from "../modals/UpdateModal";
import { PreviewModal } from "../modals/PreviewModal";
import { createError, handleError } from "../utils/Error";
import { NoticeManager } from "../utils/NoticeManager";
import NoteMoverShortcutPlugin from 'main';
import { Editor, MarkdownView, Notice } from 'obsidian';
import { HistoryModal } from '../modals/HistoryModal';
import { UpdateModal } from '../modals/UpdateModal';
import { PreviewModal } from '../modals/PreviewModal';
import { createError, handleError } from '../utils/Error';
import { NoticeManager } from '../utils/NoticeManager';
export class CommandHandler {
constructor(private plugin: NoteMoverShortcutPlugin) {}
constructor(private plugin: NoteMoverShortcutPlugin) {}
setup(): void {
setup(): void {
// Singe note move command
this.plugin.addCommand({
id: 'trigger-note-movement',
name: 'Move active note to note folder',
editorCallback: (editor: Editor, view: MarkdownView) => {
this.plugin.noteMover.moveFocusedNoteToDestination();
},
});
// Singe note move command
this.plugin.addCommand({
id: 'trigger-note-movement',
name: 'Move active note to note folder',
editorCallback: (editor: Editor, view: MarkdownView) => {
this.plugin.noteMover.moveFocusedNoteToDestination();
},
});
// Bulk movement command
this.plugin.addCommand({
id: 'trigger-note-bulk-move',
name: 'Move all files in vault',
callback: () => {
this.plugin.noteMover.moveAllFilesInVault();
},
});
// Bulk movement command
this.plugin.addCommand({
id: 'trigger-note-bulk-move',
name: 'Move all files in vault',
callback: () => {
this.plugin.noteMover.moveAllFilesInVault();
},
});
// History command
this.plugin.addCommand({
id: 'show-history',
name: 'Show history',
callback: () => {
new HistoryModal(this.plugin.app, this.plugin.historyManager).open();
},
});
// History command
this.plugin.addCommand({
id: 'show-history',
name: 'Show history',
callback: () => {
new HistoryModal(this.plugin.app, this.plugin.historyManager).open();
}
});
// Update modal command (for testing/manual trigger)
this.plugin.addCommand({
id: 'show-update-modal',
name: 'Show update modal',
callback: () => {
this.plugin.updateManager.showUpdateModal(true);
},
});
// Update modal command (for testing/manual trigger)
this.plugin.addCommand({
id: 'show-update-modal',
name: 'Show update modal',
callback: () => {
this.plugin.updateManager.showUpdateModal(true);
}
});
// Preview bulk movement command
this.plugin.addCommand({
id: 'preview-bulk-movement',
name: 'Preview bulk movement for all files',
callback: async () => {
try {
const preview =
await this.plugin.noteMover.generateVaultMovePreview();
new PreviewModal(this.plugin.app, this.plugin, preview).open();
} catch (error) {
handleError(error, 'Error generating preview', false);
NoticeManager.error(
`Error generating preview: ${error instanceof Error ? error.message : String(error)}`
);
}
},
});
// Preview bulk movement command
this.plugin.addCommand({
id: 'preview-bulk-movement',
name: 'Preview bulk movement for all files',
callback: async () => {
try {
const preview = await this.plugin.noteMover.generateVaultMovePreview();
new PreviewModal(this.plugin.app, this.plugin, preview).open();
} catch (error) {
handleError(error, "Error generating preview", false);
NoticeManager.error(`Error generating preview: ${error instanceof Error ? error.message : String(error)}`);
}
}
});
// Preview single note movement command
this.plugin.addCommand({
id: 'preview-note-movement',
name: 'Preview active note movement',
editorCallback: async (editor: Editor, view: MarkdownView) => {
try {
const preview = await this.plugin.noteMover.generateActiveNotePreview();
if (preview) {
new PreviewModal(this.plugin.app, this.plugin, preview).open();
} else {
NoticeManager.warning('No active note to preview.');
}
} catch (error) {
handleError(error, "Error generating preview", false);
NoticeManager.error(`Error generating preview: ${error instanceof Error ? error.message : String(error)}`);
}
}
});
}
// Preview single note movement command
this.plugin.addCommand({
id: 'preview-note-movement',
name: 'Preview active note movement',
editorCallback: async (editor: Editor, view: MarkdownView) => {
try {
const preview =
await this.plugin.noteMover.generateActiveNotePreview();
if (preview) {
new PreviewModal(this.plugin.app, this.plugin, preview).open();
} else {
NoticeManager.warning('No active note to preview.');
}
} catch (error) {
handleError(error, 'Error generating preview', false);
NoticeManager.error(
`Error generating preview: ${error instanceof Error ? error.message : String(error)}`
);
}
},
});
}
}

View file

@ -3,10 +3,10 @@ const errorSpy = jest.fn();
const warningSpy = jest.fn();
jest.mock('../../utils/NoticeManager', () => ({
error: errorSpy,
warning: warningSpy,
info: jest.fn(),
success: jest.fn()
error: errorSpy,
warning: warningSpy,
info: jest.fn(),
success: jest.fn(),
}));
import { CommandHandler } from '../CommandHandler';
@ -21,113 +21,112 @@ const open = jest.fn();
const showUpdateModal = jest.fn();
jest.mock('../../modals/HistoryModal', () => {
return {
HistoryModal: jest.fn().mockImplementation(() => ({ open })),
};
return {
HistoryModal: jest.fn().mockImplementation(() => ({ open })),
};
});
jest.mock('../../modals/PreviewModal', () => {
return {
PreviewModal: jest.fn().mockImplementation(() => ({ open })),
};
return {
PreviewModal: jest.fn().mockImplementation(() => ({ open })),
};
});
describe('CommandHandler', () => {
let pluginMock: any;
let handler: CommandHandler;
let pluginMock: any;
let handler: CommandHandler;
beforeEach(() => {
addCommand.mockClear();
moveFocusedNoteToDestination.mockClear();
moveAllFilesInVault.mockClear();
generateVaultMovePreview.mockClear();
generateActiveNotePreview.mockClear();
open.mockClear();
showUpdateModal.mockClear();
beforeEach(() => {
addCommand.mockClear();
moveFocusedNoteToDestination.mockClear();
moveAllFilesInVault.mockClear();
generateVaultMovePreview.mockClear();
generateActiveNotePreview.mockClear();
open.mockClear();
showUpdateModal.mockClear();
pluginMock = {
addCommand,
noteMover: {
moveFocusedNoteToDestination,
moveAllFilesInVault,
generateVaultMovePreview,
generateActiveNotePreview,
},
app: {},
historyManager: {},
updateManager: {
showUpdateModal,
},
};
handler = new CommandHandler(pluginMock);
});
pluginMock = {
addCommand,
noteMover: {
moveFocusedNoteToDestination,
moveAllFilesInVault,
generateVaultMovePreview,
generateActiveNotePreview,
},
app: {},
historyManager: {},
updateManager: {
showUpdateModal,
},
};
handler = new CommandHandler(pluginMock);
});
it('registers all commands correctly', () => {
handler.setup();
expect(addCommand).toHaveBeenCalledTimes(6);
const calls = addCommand.mock.calls;
expect(calls[0][0].id).toBe('trigger-note-movement');
expect(calls[1][0].id).toBe('trigger-note-bulk-move');
expect(calls[2][0].id).toBe('show-history');
expect(calls[3][0].id).toBe('show-update-modal');
expect(calls[4][0].id).toBe('preview-bulk-movement');
expect(calls[5][0].id).toBe('preview-note-movement');
});
it('registers all commands correctly', () => {
handler.setup();
expect(addCommand).toHaveBeenCalledTimes(6);
const calls = addCommand.mock.calls;
expect(calls[0][0].id).toBe('trigger-note-movement');
expect(calls[1][0].id).toBe('trigger-note-bulk-move');
expect(calls[2][0].id).toBe('show-history');
expect(calls[3][0].id).toBe('show-update-modal');
expect(calls[4][0].id).toBe('preview-bulk-movement');
expect(calls[5][0].id).toBe('preview-note-movement');
});
it('calls moveFocusedNoteToDestination in editor callback', () => {
handler.setup();
// Extract and execute editor callback from the first command
const editorCallback = addCommand.mock.calls[0][0].editorCallback;
editorCallback({}, {});
expect(moveFocusedNoteToDestination).toHaveBeenCalled();
});
it('calls moveFocusedNoteToDestination in editor callback', () => {
handler.setup();
// Extract and execute editor callback from the first command
const editorCallback = addCommand.mock.calls[0][0].editorCallback;
editorCallback({}, {});
expect(moveFocusedNoteToDestination).toHaveBeenCalled();
});
it('calls moveAllFilesInVault in bulk callback', () => {
handler.setup();
const callback = addCommand.mock.calls[1][0].callback;
callback();
expect(moveAllFilesInVault).toHaveBeenCalled();
});
it('calls moveAllFilesInVault in bulk callback', () => {
handler.setup();
const callback = addCommand.mock.calls[1][0].callback;
callback();
expect(moveAllFilesInVault).toHaveBeenCalled();
});
it('opens HistoryModal in history callback', () => {
handler.setup();
const callback = addCommand.mock.calls[2][0].callback;
callback();
expect(open).toHaveBeenCalled();
});
it('opens HistoryModal in history callback', () => {
handler.setup();
const callback = addCommand.mock.calls[2][0].callback;
callback();
expect(open).toHaveBeenCalled();
});
it('calls showUpdateModal in update modal callback', () => {
handler.setup();
const callback = addCommand.mock.calls[3][0].callback;
callback();
expect(showUpdateModal).toHaveBeenCalledWith(true);
});
it('calls showUpdateModal in update modal callback', () => {
handler.setup();
const callback = addCommand.mock.calls[3][0].callback;
callback();
expect(showUpdateModal).toHaveBeenCalledWith(true);
});
it('handles preview bulk movement callback with success', async () => {
const mockPreview = { successfulMoves: [], blockedMoves: [] };
generateVaultMovePreview.mockResolvedValue(mockPreview);
handler.setup();
const callback = addCommand.mock.calls[4][0].callback;
await callback();
expect(generateVaultMovePreview).toHaveBeenCalled();
expect(open).toHaveBeenCalled();
});
it('handles preview bulk movement callback with success', async () => {
const mockPreview = { successfulMoves: [], blockedMoves: [] };
generateVaultMovePreview.mockResolvedValue(mockPreview);
it('handles preview note movement callback with success', async () => {
const mockPreview = { successfulMoves: [], blockedMoves: [] };
generateActiveNotePreview.mockResolvedValue(mockPreview);
handler.setup();
const callback = addCommand.mock.calls[4][0].callback;
await callback();
handler.setup();
const editorCallback = addCommand.mock.calls[5][0].editorCallback;
await editorCallback({}, {});
expect(generateVaultMovePreview).toHaveBeenCalled();
expect(open).toHaveBeenCalled();
});
expect(generateActiveNotePreview).toHaveBeenCalled();
expect(open).toHaveBeenCalled();
});
it('handles preview note movement callback with success', async () => {
const mockPreview = { successfulMoves: [], blockedMoves: [] };
generateActiveNotePreview.mockResolvedValue(mockPreview);
// Error handling tests would require complex mock setup
// These are covered by the existing tests that verify the commands are registered correctly
handler.setup();
const editorCallback = addCommand.mock.calls[5][0].editorCallback;
await editorCallback({}, {});
});
expect(generateActiveNotePreview).toHaveBeenCalled();
expect(open).toHaveBeenCalled();
});
// Error handling tests would require complex mock setup
// These are covered by the existing tests that verify the commands are registered correctly
});

View file

@ -3,150 +3,165 @@ import { Modal, App, Setting, ButtonComponent } from 'obsidian';
export type ModalSize = 'small' | 'medium' | 'large';
export interface BaseModalOptions {
title?: string;
titleIcon?: string;
cssClass?: string;
size?: ModalSize;
autoFocus?: boolean;
focusSelector?: string;
title?: string;
titleIcon?: string;
cssClass?: string;
size?: ModalSize;
autoFocus?: boolean;
focusSelector?: string;
}
export abstract class BaseModal extends Modal {
protected options: BaseModalOptions;
protected options: BaseModalOptions;
constructor(app: App, options: BaseModalOptions = {}) {
super(app);
this.options = {
autoFocus: true,
focusSelector: '.mod-cta',
...options
};
constructor(app: App, options: BaseModalOptions = {}) {
super(app);
this.options = {
autoFocus: true,
focusSelector: '.mod-cta',
...options,
};
}
onOpen() {
const { contentEl } = this;
this.clearContent();
this.addCssClass();
this.setModalSize();
this.createTitle();
this.createContent();
this.setupAutoFocus();
}
onClose() {
this.clearContent();
}
/**
* Clear the modal content
*/
protected clearContent(): void {
const { contentEl } = this;
contentEl.empty();
}
/**
* Add CSS class to the modal content
*/
protected addCssClass(): void {
const { contentEl } = this;
if (this.options.cssClass) {
contentEl.addClass(this.options.cssClass);
}
}
onOpen() {
const { contentEl } = this;
this.clearContent();
this.addCssClass();
this.setModalSize();
this.createTitle();
this.createContent();
this.setupAutoFocus();
/**
* Set modal size based on options using CSS classes
*/
protected setModalSize(): void {
const modalContainer = this.contentEl.parentElement;
if (modalContainer && this.options.size) {
// Remove any existing size classes
modalContainer.classList.remove(
'modal-size-small',
'modal-size-medium',
'modal-size-large'
);
// Add the appropriate size class
modalContainer.classList.add(`modal-size-${this.options.size}`);
}
}
onClose() {
this.clearContent();
/**
* Create modal title if provided
*/
protected createTitle(): void {
if (!this.options.title) return;
const { contentEl } = this;
if (this.options.titleIcon) {
const titleContainer = contentEl.createEl('div', {
cls: 'modal-title-container',
});
const titleIcon = titleContainer.createEl('span', {
cls: 'modal-title-icon',
});
titleIcon.innerHTML = this.options.titleIcon;
titleContainer.createEl('h2', {
text: this.options.title,
cls: 'modal-title',
});
} else {
contentEl.createEl('h2', {
text: this.options.title,
cls: 'modal-title',
});
}
}
/**
* Clear the modal content
*/
protected clearContent(): void {
const { contentEl } = this;
contentEl.empty();
}
/**
* Setup auto focus on specified element
*/
protected setupAutoFocus(): void {
if (!this.options.autoFocus || !this.options.focusSelector) return;
/**
* Add CSS class to the modal content
*/
protected addCssClass(): void {
const { contentEl } = this;
if (this.options.cssClass) {
contentEl.addClass(this.options.cssClass);
}
}
setTimeout(() => {
const focusElement = this.contentEl.querySelector(
this.options.focusSelector!
) as HTMLElement;
if (focusElement && typeof focusElement.focus === 'function') {
focusElement.focus();
}
}, 10);
}
/**
* Set modal size based on options using CSS classes
*/
protected setModalSize(): void {
const modalContainer = this.contentEl.parentElement;
if (modalContainer && this.options.size) {
// Remove any existing size classes
modalContainer.classList.remove('modal-size-small', 'modal-size-medium', 'modal-size-large');
// Add the appropriate size class
modalContainer.classList.add(`modal-size-${this.options.size}`);
}
}
/**
* Create a button container with consistent styling
*/
protected createButtonContainer(
container: HTMLElement,
cssClass = 'modal-button-container'
): HTMLElement {
return container.createEl('div', { cls: cssClass });
}
/**
* Create modal title if provided
*/
protected createTitle(): void {
if (!this.options.title) return;
/**
* Create a button with consistent styling
*/
protected createButton(
container: HTMLElement,
text: string,
onClick: () => void,
options: {
isPrimary?: boolean;
isWarning?: boolean;
icon?: string;
tooltip?: string;
} = {}
): void {
new Setting(container).addButton((btn: ButtonComponent) => {
btn.setButtonText(text);
if (options.icon) btn.setIcon(options.icon);
if (options.tooltip) btn.setTooltip(options.tooltip);
if (options.isWarning) btn.setWarning();
if (options.isPrimary) btn.setCta();
btn.onClick(onClick);
});
}
const { contentEl } = this;
if (this.options.titleIcon) {
const titleContainer = contentEl.createEl('div', { cls: 'modal-title-container' });
const titleIcon = titleContainer.createEl('span', { cls: 'modal-title-icon' });
titleIcon.innerHTML = this.options.titleIcon;
titleContainer.createEl('h2', {
text: this.options.title,
cls: 'modal-title'
});
} else {
contentEl.createEl('h2', {
text: this.options.title,
cls: 'modal-title'
});
}
}
/**
* Create a section with consistent styling
*/
protected createSection(
container: HTMLElement,
cssClass = 'modal-section'
): HTMLElement {
return container.createEl('div', { cls: cssClass });
}
/**
* Setup auto focus on specified element
*/
protected setupAutoFocus(): void {
if (!this.options.autoFocus || !this.options.focusSelector) return;
setTimeout(() => {
const focusElement = this.contentEl.querySelector(this.options.focusSelector!) as HTMLElement;
if (focusElement && typeof focusElement.focus === 'function') {
focusElement.focus();
}
}, 10);
}
/**
* Create a button container with consistent styling
*/
protected createButtonContainer(container: HTMLElement, cssClass: string = 'modal-button-container'): HTMLElement {
return container.createEl('div', { cls: cssClass });
}
/**
* Create a button with consistent styling
*/
protected createButton(
container: HTMLElement,
text: string,
onClick: () => void,
options: {
isPrimary?: boolean;
isWarning?: boolean;
icon?: string;
tooltip?: string;
} = {}
): void {
new Setting(container)
.addButton((btn: ButtonComponent) => {
btn.setButtonText(text);
if (options.icon) btn.setIcon(options.icon);
if (options.tooltip) btn.setTooltip(options.tooltip);
if (options.isWarning) btn.setWarning();
if (options.isPrimary) btn.setCta();
btn.onClick(onClick);
});
}
/**
* Create a section with consistent styling
*/
protected createSection(container: HTMLElement, cssClass: string = 'modal-section'): HTMLElement {
return container.createEl('div', { cls: cssClass });
}
/**
* Abstract method that must be implemented by subclasses to create modal content
*/
protected abstract createContent(): void;
/**
* Abstract method that must be implemented by subclasses to create modal content
*/
protected abstract createContent(): void;
}

View file

@ -2,89 +2,89 @@ import { App } from 'obsidian';
import { BaseModal, BaseModalOptions } from './BaseModal';
export interface ConfirmModalOptions extends BaseModalOptions {
title: string;
message: string;
confirmText?: string;
cancelText?: string;
danger?: boolean;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
danger?: boolean;
}
export class ConfirmModal extends BaseModal {
private confirmOptions: ConfirmModalOptions;
private resolvePromise: (value: boolean) => void = () => {};
private confirmOptions: ConfirmModalOptions;
private resolvePromise: (value: boolean) => void = () => {};
constructor(app: App, options: ConfirmModalOptions) {
super(app, {
cssClass: 'confirm-modal',
size: 'small',
...options
});
this.confirmOptions = {
confirmText: 'OK',
cancelText: 'Cancel',
danger: false,
...options
};
}
constructor(app: App, options: ConfirmModalOptions) {
super(app, {
cssClass: 'confirm-modal',
size: 'small',
...options,
});
this.confirmOptions = {
confirmText: 'OK',
cancelText: 'Cancel',
danger: false,
...options,
};
}
protected createContent(): void {
const { contentEl } = this;
protected createContent(): void {
const { contentEl } = this;
// Message
const messageEl = contentEl.createEl('div', {
cls: 'confirm-modal-message'
});
messageEl.innerHTML = this.confirmOptions.message;
// Message
const messageEl = contentEl.createEl('div', {
cls: 'confirm-modal-message',
});
messageEl.innerHTML = this.confirmOptions.message;
// Button container
const buttonContainer = this.createButtonContainer(contentEl);
// Button container
const buttonContainer = this.createButtonContainer(contentEl);
// Cancel button
this.createButton(
buttonContainer,
this.confirmOptions.cancelText || 'Cancel',
() => {
this.resolvePromise(false);
this.close();
}
);
// Confirm button
this.createButton(
buttonContainer,
this.confirmOptions.confirmText || 'OK',
() => {
this.resolvePromise(true);
this.close();
},
{
isPrimary: true,
isWarning: this.confirmOptions.danger
}
);
}
onClose() {
super.onClose();
// Ensure promise is resolved even if modal is closed without clicking a button
// Cancel button
this.createButton(
buttonContainer,
this.confirmOptions.cancelText || 'Cancel',
() => {
this.resolvePromise(false);
}
this.close();
}
);
/**
* Shows the confirmation modal and returns a promise that resolves to true if confirmed, false if cancelled
*/
confirm(): Promise<boolean> {
return new Promise((resolve) => {
this.resolvePromise = resolve;
this.open();
});
}
// Confirm button
this.createButton(
buttonContainer,
this.confirmOptions.confirmText || 'OK',
() => {
this.resolvePromise(true);
this.close();
},
{
isPrimary: true,
isWarning: this.confirmOptions.danger,
}
);
}
/**
* Static helper method to quickly show a confirmation dialog
*/
static async show(app: App, options: ConfirmModalOptions): Promise<boolean> {
const modal = new ConfirmModal(app, options);
return modal.confirm();
}
onClose() {
super.onClose();
// Ensure promise is resolved even if modal is closed without clicking a button
this.resolvePromise(false);
}
/**
* Shows the confirmation modal and returns a promise that resolves to true if confirmed, false if cancelled
*/
confirm(): Promise<boolean> {
return new Promise(resolve => {
this.resolvePromise = resolve;
this.open();
});
}
/**
* Static helper method to quickly show a confirmation dialog
*/
static async show(app: App, options: ConfirmModalOptions): Promise<boolean> {
const modal = new ConfirmModal(app, options);
return modal.confirm();
}
}

View file

@ -2,250 +2,297 @@ import { Setting, App, TFile } from 'obsidian';
import { HistoryManager } from '../core/HistoryManager';
import { BulkOperation, HistoryEntry, TimeFilter } from '../types/HistoryEntry';
import { NoticeManager } from '../utils/NoticeManager';
import { NOTIFICATION_CONSTANTS, SETTINGS_CONSTANTS } from '../config/constants';
import {
NOTIFICATION_CONSTANTS,
SETTINGS_CONSTANTS,
} from '../config/constants';
import { BaseModal, BaseModalOptions } from './BaseModal';
export class HistoryModal extends BaseModal {
private currentTimeFilter: TimeFilter = 'all';
private currentTimeFilter: TimeFilter = 'all';
constructor(
app: App,
private historyManager: HistoryManager,
options: BaseModalOptions = {}
) {
super(app, {
cssClass: 'note-mover-history-modal',
size: 'large',
...options
});
constructor(
app: App,
private historyManager: HistoryManager,
options: BaseModalOptions = {}
) {
super(app, {
cssClass: 'note-mover-history-modal',
size: 'large',
...options,
});
}
protected createContent(): void {
const { contentEl } = this;
// Create time filter dropdown
this.createTimeFilterDropdown(contentEl);
const history = this.historyManager.getFilteredHistory(
this.currentTimeFilter
);
const bulkOperations = this.historyManager.getFilteredBulkOperations(
this.currentTimeFilter
);
if (history.length === 0 && bulkOperations.length === 0) {
contentEl.createEl('p', {
text: 'No history entries available for the selected time period.',
});
return;
}
protected createContent(): void {
const { contentEl } = this;
const historyList = contentEl.createEl('div', { cls: 'history-list' });
// Create time filter dropdown
this.createTimeFilterDropdown(contentEl);
// Create a combined list of operations sorted by timestamp
const allOperations: Array<
| { type: 'bulk'; data: BulkOperation }
| { type: 'single'; data: HistoryEntry }
> = [];
const history = this.historyManager.getFilteredHistory(this.currentTimeFilter);
const bulkOperations = this.historyManager.getFilteredBulkOperations(this.currentTimeFilter);
if (history.length === 0 && bulkOperations.length === 0) {
contentEl.createEl('p', { text: 'No history entries available for the selected time period.' });
return;
}
// Add bulk operations
bulkOperations.forEach(bulkOp => {
allOperations.push({ type: 'bulk', data: bulkOp });
});
const historyList = contentEl.createEl('div', { cls: 'history-list' });
// Add ONLY single operations that are NOT part of any bulk operation
const singleEntries = history.filter(entry => !entry.bulkOperationId);
singleEntries.forEach(entry => {
allOperations.push({ type: 'single', data: entry });
});
// Create a combined list of operations sorted by timestamp
const allOperations: Array<{type: 'bulk', data: BulkOperation} | {type: 'single', data: HistoryEntry}> = [];
// Add bulk operations
bulkOperations.forEach(bulkOp => {
allOperations.push({type: 'bulk', data: bulkOp});
// Sort by timestamp (most recent first)
allOperations.sort((a, b) => b.data.timestamp - a.data.timestamp);
allOperations.forEach(operation => {
if (operation.type === 'bulk') {
this.createBulkOperationEntry(historyList, operation.data);
} else {
this.createSingleEntry(historyList, operation.data);
}
});
}
private createTimeFilterDropdown(container: HTMLElement): void {
const filterContainer = container.createEl('div', {
cls: 'time-filter-container',
});
new Setting(filterContainer)
.setName(SETTINGS_CONSTANTS.UI_TEXTS.TIME_FILTER_LABEL)
.addDropdown(dropdown => {
dropdown
.addOption('all', SETTINGS_CONSTANTS.UI_TEXTS.TIME_FILTER_ALL)
.addOption('today', SETTINGS_CONSTANTS.UI_TEXTS.TIME_FILTER_TODAY)
.addOption('week', SETTINGS_CONSTANTS.UI_TEXTS.TIME_FILTER_WEEK)
.addOption('month', SETTINGS_CONSTANTS.UI_TEXTS.TIME_FILTER_MONTH)
.setValue(this.currentTimeFilter)
.onChange((value: string) => {
this.currentTimeFilter = value as TimeFilter;
this.refreshContent();
});
});
}
private refreshContent(): void {
const { contentEl } = this;
contentEl.empty();
this.createContent();
}
private createBulkOperationEntry(
container: HTMLElement,
bulkOp: BulkOperation
) {
const bulkEntryEl = container.createEl('div', {
cls: 'history-entry bulk-operation',
});
const headerEl = bulkEntryEl.createEl('div', { cls: 'bulk-header' });
const date = new Date(bulkOp.timestamp);
const formattedDate = date.toLocaleString('en-US');
const operationTypeText =
bulkOp.operationType === 'bulk' ? 'Bulk Move' : 'Periodic Move';
const operationIcon = bulkOp.operationType === 'bulk' ? '📦' : '🕐';
headerEl.createEl('div', {
cls: 'bulk-operation-info',
text: `${operationIcon} ${operationTypeText}: ${bulkOp.totalFiles} files - ${formattedDate}`,
});
// Expandable details (opened by default)
const detailsEl = bulkEntryEl.createEl('details', { cls: 'bulk-details' });
detailsEl.open = true;
const summaryEl = detailsEl.createEl('summary', { text: 'Hide files' });
const filesListEl = detailsEl.createEl('div', { cls: 'bulk-files-list' });
// Update summary text based on open state
detailsEl.addEventListener('toggle', () => {
summaryEl.textContent = detailsEl.open ? 'Hide files' : 'Show files';
});
// Sort entries by timestamp for consistent display
const sortedEntries = [...bulkOp.entries].sort(
(a, b) => a.timestamp - b.timestamp
);
sortedEntries.forEach(entry => {
const fileEl = filesListEl.createEl('div', {
cls: 'history-entry bulk-file-entry',
});
const contentEl = fileEl.createEl('div', {
cls: 'history-entry-content',
});
const date = new Date(entry.timestamp);
const formattedDate = date.toLocaleString('en-US');
contentEl.createEl('div', {
cls: 'history-entry-info',
text: `📄 ${entry.fileName} - ${formattedDate}`,
});
contentEl.createEl('div', {
cls: 'history-entry-paths',
text: `${entry.sourcePath}${entry.destinationPath}`,
});
// Individual buttons for each file
new Setting(fileEl)
.addButton(button => {
button
.setIcon('file-text')
.setTooltip(`Open ${entry.fileName}`)
.onClick(() => {
// Try to open the file at its current location (destination path)
const file = this.app.vault.getAbstractFileByPath(
entry.destinationPath
);
if (file && file instanceof TFile) {
this.app.workspace.getLeaf().openFile(file);
this.close(); // Close the modal after opening the file
} else {
// If file not found at destination, try source path
const sourceFile = this.app.vault.getAbstractFileByPath(
entry.sourcePath
);
if (sourceFile && sourceFile instanceof TFile) {
this.app.workspace.getLeaf().openFile(sourceFile);
this.close(); // Close the modal after opening the file
} else {
NoticeManager.error(`Could not find file ${entry.fileName}`, {
duration: NOTIFICATION_CONSTANTS.DURATION_OVERRIDE,
});
}
}
});
})
.addButton(button => {
button
.setIcon('undo')
.setTooltip(`Undo move for ${entry.fileName}`)
.onClick(async () => {
const success = await this.historyManager.undoEntry(entry.id);
if (success) {
this.onOpen(); // Refresh the modal
} else {
// Show error message
NoticeManager.error(
`Could not undo move for ${entry.fileName}. File may have been moved or deleted.`,
{ duration: NOTIFICATION_CONSTANTS.DURATION_OVERRIDE }
);
}
});
});
// Add ONLY single operations that are NOT part of any bulk operation
const singleEntries = history.filter(entry => !entry.bulkOperationId);
singleEntries.forEach(entry => {
allOperations.push({type: 'single', data: entry});
});
// Sort by timestamp (most recent first)
allOperations.sort((a, b) => b.data.timestamp - a.data.timestamp);
});
allOperations.forEach(operation => {
if (operation.type === 'bulk') {
this.createBulkOperationEntry(historyList, operation.data);
// Bulk undo button
new Setting(bulkEntryEl).addButton(button => {
button
.setButtonText(SETTINGS_CONSTANTS.UI_TEXTS.UNDO_ALL)
.setIcon('undo')
.setTooltip(`Undo entire ${operationTypeText.toLowerCase()}`)
.onClick(async () => {
const success = await this.historyManager.undoBulkOperation(
bulkOp.id
);
if (success) {
this.onOpen(); // Refresh the modal
} else {
// Show error message
NoticeManager.warning(
'Some files could not be moved back. Check console for details.',
{ duration: NOTIFICATION_CONSTANTS.DURATION_OVERRIDE }
);
}
});
});
}
private createSingleEntry(container: HTMLElement, entry: HistoryEntry) {
const entryEl = container.createEl('div', {
cls: 'history-entry single-operation',
});
const contentEl = entryEl.createEl('div', { cls: 'history-entry-content' });
const date = new Date(entry.timestamp);
const formattedDate = date.toLocaleString('en-US');
contentEl.createEl('div', {
cls: 'history-entry-info',
text: `📄 ${entry.fileName} - ${formattedDate}`,
});
contentEl.createEl('div', {
cls: 'history-entry-paths',
text: `${entry.sourcePath}${entry.destinationPath}`,
});
new Setting(entryEl)
.addButton(button => {
button
.setIcon('file-text')
.setTooltip(`Open ${entry.fileName}`)
.onClick(() => {
// Try to open the file at its current location (destination path)
const file = this.app.vault.getAbstractFileByPath(
entry.destinationPath
);
if (file && file instanceof TFile) {
this.app.workspace.getLeaf().openFile(file);
this.close(); // Close the modal after opening the file
} else {
this.createSingleEntry(historyList, operation.data);
}
});
}
private createTimeFilterDropdown(container: HTMLElement): void {
const filterContainer = container.createEl('div', { cls: 'time-filter-container' });
new Setting(filterContainer)
.setName(SETTINGS_CONSTANTS.UI_TEXTS.TIME_FILTER_LABEL)
.addDropdown(dropdown => {
dropdown
.addOption('all', SETTINGS_CONSTANTS.UI_TEXTS.TIME_FILTER_ALL)
.addOption('today', SETTINGS_CONSTANTS.UI_TEXTS.TIME_FILTER_TODAY)
.addOption('week', SETTINGS_CONSTANTS.UI_TEXTS.TIME_FILTER_WEEK)
.addOption('month', SETTINGS_CONSTANTS.UI_TEXTS.TIME_FILTER_MONTH)
.setValue(this.currentTimeFilter)
.onChange((value: string) => {
this.currentTimeFilter = value as TimeFilter;
this.refreshContent();
});
});
}
private refreshContent(): void {
const { contentEl } = this;
contentEl.empty();
this.createContent();
}
private createBulkOperationEntry(container: HTMLElement, bulkOp: BulkOperation) {
const bulkEntryEl = container.createEl('div', { cls: 'history-entry bulk-operation' });
const headerEl = bulkEntryEl.createEl('div', { cls: 'bulk-header' });
const date = new Date(bulkOp.timestamp);
const formattedDate = date.toLocaleString('en-US');
const operationTypeText = bulkOp.operationType === 'bulk' ? 'Bulk Move' : 'Periodic Move';
const operationIcon = bulkOp.operationType === 'bulk' ? '📦' : '🕐';
headerEl.createEl('div', {
cls: 'bulk-operation-info',
text: `${operationIcon} ${operationTypeText}: ${bulkOp.totalFiles} files - ${formattedDate}`
});
// Expandable details (opened by default)
const detailsEl = bulkEntryEl.createEl('details', { cls: 'bulk-details' });
detailsEl.open = true;
const summaryEl = detailsEl.createEl('summary', { text: 'Hide files' });
const filesListEl = detailsEl.createEl('div', { cls: 'bulk-files-list' });
// Update summary text based on open state
detailsEl.addEventListener('toggle', () => {
summaryEl.textContent = detailsEl.open ? 'Hide files' : 'Show files';
});
// Sort entries by timestamp for consistent display
const sortedEntries = [...bulkOp.entries].sort((a, b) => a.timestamp - b.timestamp);
sortedEntries.forEach(entry => {
const fileEl = filesListEl.createEl('div', { cls: 'history-entry bulk-file-entry' });
const contentEl = fileEl.createEl('div', { cls: 'history-entry-content' });
const date = new Date(entry.timestamp);
const formattedDate = date.toLocaleString('en-US');
contentEl.createEl('div', {
cls: 'history-entry-info',
text: `📄 ${entry.fileName} - ${formattedDate}`
});
contentEl.createEl('div', {
cls: 'history-entry-paths',
text: `${entry.sourcePath}${entry.destinationPath}`
});
// Individual buttons for each file
new Setting(fileEl)
.addButton(button => {
button
.setIcon('file-text')
.setTooltip(`Open ${entry.fileName}`)
.onClick(() => {
// Try to open the file at its current location (destination path)
const file = this.app.vault.getAbstractFileByPath(entry.destinationPath);
if (file && file instanceof TFile) {
this.app.workspace.getLeaf().openFile(file);
this.close(); // Close the modal after opening the file
} else {
// If file not found at destination, try source path
const sourceFile = this.app.vault.getAbstractFileByPath(entry.sourcePath);
if (sourceFile && sourceFile instanceof TFile) {
this.app.workspace.getLeaf().openFile(sourceFile);
this.close(); // Close the modal after opening the file
} else {
NoticeManager.error(`Could not find file ${entry.fileName}`, { duration: NOTIFICATION_CONSTANTS.DURATION_OVERRIDE });
}
}
});
})
.addButton(button => {
button
.setIcon('undo')
.setTooltip(`Undo move for ${entry.fileName}`)
.onClick(async () => {
const success = await this.historyManager.undoEntry(entry.id);
if (success) {
this.onOpen(); // Refresh the modal
} else {
// Show error message
NoticeManager.error(`Could not undo move for ${entry.fileName}. File may have been moved or deleted.`, { duration: NOTIFICATION_CONSTANTS.DURATION_OVERRIDE });
}
});
// If file not found at destination, try source path
const sourceFile = this.app.vault.getAbstractFileByPath(
entry.sourcePath
);
if (sourceFile && sourceFile instanceof TFile) {
this.app.workspace.getLeaf().openFile(sourceFile);
this.close(); // Close the modal after opening the file
} else {
NoticeManager.error(`Could not find file ${entry.fileName}`, {
duration: NOTIFICATION_CONSTANTS.DURATION_OVERRIDE,
});
});
}
}
});
})
.addButton(button => {
button
.setIcon('undo')
.setTooltip('Undo')
.onClick(async () => {
const success = await this.historyManager.undoEntry(entry.id);
if (success) {
this.onOpen();
}
});
});
}
// Bulk undo button
new Setting(bulkEntryEl)
.addButton(button => {
button
.setButtonText(SETTINGS_CONSTANTS.UI_TEXTS.UNDO_ALL)
.setIcon('undo')
.setTooltip(`Undo entire ${operationTypeText.toLowerCase()}`)
.onClick(async () => {
const success = await this.historyManager.undoBulkOperation(bulkOp.id);
if (success) {
this.onOpen(); // Refresh the modal
} else {
// Show error message
NoticeManager.warning('Some files could not be moved back. Check console for details.', { duration: NOTIFICATION_CONSTANTS.DURATION_OVERRIDE });
}
});
});
}
private createSingleEntry(container: HTMLElement, entry: HistoryEntry) {
const entryEl = container.createEl('div', { cls: 'history-entry single-operation' });
const contentEl = entryEl.createEl('div', { cls: 'history-entry-content' });
const date = new Date(entry.timestamp);
const formattedDate = date.toLocaleString('en-US');
contentEl.createEl('div', {
cls: 'history-entry-info',
text: `📄 ${entry.fileName} - ${formattedDate}`
});
contentEl.createEl('div', {
cls: 'history-entry-paths',
text: `${entry.sourcePath}${entry.destinationPath}`
});
new Setting(entryEl)
.addButton(button => {
button
.setIcon('file-text')
.setTooltip(`Open ${entry.fileName}`)
.onClick(() => {
// Try to open the file at its current location (destination path)
const file = this.app.vault.getAbstractFileByPath(entry.destinationPath);
if (file && file instanceof TFile) {
this.app.workspace.getLeaf().openFile(file);
this.close(); // Close the modal after opening the file
} else {
// If file not found at destination, try source path
const sourceFile = this.app.vault.getAbstractFileByPath(entry.sourcePath);
if (sourceFile && sourceFile instanceof TFile) {
this.app.workspace.getLeaf().openFile(sourceFile);
this.close(); // Close the modal after opening the file
} else {
NoticeManager.error(`Could not find file ${entry.fileName}`, { duration: NOTIFICATION_CONSTANTS.DURATION_OVERRIDE });
}
}
});
})
.addButton(button => {
button
.setIcon('undo')
.setTooltip('Undo')
.onClick(async () => {
const success = await this.historyManager.undoEntry(entry.id);
if (success) {
this.onOpen();
}
});
});
}
onClose() {
super.onClose();
}
}
onClose() {
super.onClose();
}
}

View file

@ -5,152 +5,166 @@ import { NoticeManager } from '../utils/NoticeManager';
import { BaseModal, BaseModalOptions } from './BaseModal';
export class PreviewModal extends BaseModal {
private movePreview: MovePreview;
private movePreview: MovePreview;
constructor(
app: App,
private plugin: NoteMoverShortcutPlugin,
movePreview: MovePreview,
options: BaseModalOptions = {}
) {
super(app, {
title: 'Move Preview',
titleIcon: '🔍',
cssClass: 'note-mover-preview-modal',
size: 'large',
...options
});
this.movePreview = movePreview;
constructor(
app: App,
private plugin: NoteMoverShortcutPlugin,
movePreview: MovePreview,
options: BaseModalOptions = {}
) {
super(app, {
title: 'Move Preview',
titleIcon: '🔍',
cssClass: 'note-mover-preview-modal',
size: 'large',
...options,
});
this.movePreview = movePreview;
}
protected createContent(): void {
const { contentEl } = this;
// Subtitle with statistics
const stats = this.movePreview;
const subtitle = `${stats.totalFiles} files analyzed • ${stats.successfulMoves.length} will be moved`;
contentEl.createEl('p', {
text: subtitle,
cls: 'modal-subtitle',
});
// Settings info removed - Rules and Filter are always enabled now
// Successful moves section
if (stats.successfulMoves.length > 0) {
this.createSuccessfulMovesSection(contentEl, stats.successfulMoves);
}
protected createContent(): void {
const { contentEl } = this;
// Blocked moves section removed - only files that will be moved are shown
// Subtitle with statistics
const stats = this.movePreview;
const subtitle = `${stats.totalFiles} files analyzed • ${stats.successfulMoves.length} will be moved`;
contentEl.createEl('p', {
text: subtitle,
cls: 'modal-subtitle'
});
// Settings info removed - Rules and Filter are always enabled now
// Successful moves section
if (stats.successfulMoves.length > 0) {
this.createSuccessfulMovesSection(contentEl, stats.successfulMoves);
}
// Blocked moves section removed - only files that will be moved are shown
// No files message
if (stats.totalFiles === 0) {
contentEl.createEl('div', {
cls: 'modal-empty',
text: 'No files found to analyze.'
});
}
// Action buttons
this.createActionButtons(contentEl);
// No files message
if (stats.totalFiles === 0) {
contentEl.createEl('div', {
cls: 'modal-empty',
text: 'No files found to analyze.',
});
}
private createSuccessfulMovesSection(container: HTMLElement, entries: PreviewEntry[]) {
const section = this.createSection(container, 'preview-section preview-section-success');
const header = section.createEl('div', { cls: 'modal-section-header' });
header.innerHTML = `<h3>✅ Files to be moved (${entries.length})</h3>`;
// Action buttons
this.createActionButtons(contentEl);
}
const list = section.createEl('div', { cls: 'modal-list' });
entries.forEach(entry => {
const item = list.createEl('div', { cls: 'modal-list-item preview-item-success' });
const mainInfo = item.createEl('div', { cls: 'preview-item-main' });
const fileName = mainInfo.createEl('div', { cls: 'preview-item-filename' });
fileName.textContent = entry.fileName;
const pathInfo = mainInfo.createEl('div', { cls: 'preview-item-paths' });
pathInfo.innerHTML = `
private createSuccessfulMovesSection(
container: HTMLElement,
entries: PreviewEntry[]
) {
const section = this.createSection(
container,
'preview-section preview-section-success'
);
const header = section.createEl('div', { cls: 'modal-section-header' });
header.innerHTML = `<h3>✅ Files to be moved (${entries.length})</h3>`;
const list = section.createEl('div', { cls: 'modal-list' });
entries.forEach(entry => {
const item = list.createEl('div', {
cls: 'modal-list-item preview-item-success',
});
const mainInfo = item.createEl('div', { cls: 'preview-item-main' });
const fileName = mainInfo.createEl('div', {
cls: 'preview-item-filename',
});
fileName.textContent = entry.fileName;
const pathInfo = mainInfo.createEl('div', { cls: 'preview-item-paths' });
pathInfo.innerHTML = `
<span class="current-path">${entry.currentPath}</span>
<span class="arrow"></span>
<span class="target-path">${entry.targetPath}</span>
`;
const details = item.createEl('div', { cls: 'preview-item-details' });
if (entry.matchedRule) {
const rule = details.createEl('div', { cls: 'preview-item-rule' });
rule.innerHTML = `<span class="rule-label">Rule:</span> <code>${entry.matchedRule}</code>`;
}
if (entry.tags && entry.tags.length > 0) {
const tags = details.createEl('div', { cls: 'preview-item-tags' });
tags.innerHTML = `<span class="tags-label">Tags:</span> ${entry.tags.map(tag => `<span class="tag">${tag}</span>`).join(' ')}`;
}
});
const details = item.createEl('div', { cls: 'preview-item-details' });
if (entry.matchedRule) {
const rule = details.createEl('div', { cls: 'preview-item-rule' });
rule.innerHTML = `<span class="rule-label">Rule:</span> <code>${entry.matchedRule}</code>`;
}
if (entry.tags && entry.tags.length > 0) {
const tags = details.createEl('div', { cls: 'preview-item-tags' });
tags.innerHTML = `<span class="tags-label">Tags:</span> ${entry.tags.map(tag => `<span class="tag">${tag}</span>`).join(' ')}`;
}
});
}
// createBlockedMovesSection removed - only files that will be moved are shown
private createActionButtons(container: HTMLElement) {
const footer = container.createEl('div', { cls: 'modal-footer' });
const buttonContainer = this.createButtonContainer(footer);
// Cancel button
this.createButton(buttonContainer, 'Cancel', () => {
this.close();
});
// Execute moves button (only if there are moves to execute)
if (this.movePreview.successfulMoves.length > 0) {
this.createButton(
buttonContainer,
`Move ${this.movePreview.successfulMoves.length} files`,
() => {
this.executeMoves();
},
{
isPrimary: true,
}
);
}
}
// createBlockedMovesSection removed - only files that will be moved are shown
private async executeMoves() {
// Close modal first
this.close();
private createActionButtons(container: HTMLElement) {
const footer = container.createEl('div', { cls: 'modal-footer' });
const buttonContainer = this.createButtonContainer(footer);
// Cancel button
this.createButton(
buttonContainer,
'Cancel',
() => {
this.close();
}
// Execute the moves for successful entries
const successfulEntries = this.movePreview.successfulMoves;
let movedCount = 0;
let errorCount = 0;
for (const entry of successfulEntries) {
try {
// Find the TFile for this entry
const file = this.app.vault.getAbstractFileByPath(entry.currentPath);
if (file && file instanceof TFile) {
// Use the existing move logic from NoteMoverShortcut
await this.plugin.noteMover.moveFileBasedOnTags(
file,
entry.targetPath || '/',
true
);
movedCount++;
}
} catch (error) {
NoticeManager.error(
`Error moving file ${entry.fileName}: ${error instanceof Error ? error.message : String(error)}`
);
// Execute moves button (only if there are moves to execute)
if (this.movePreview.successfulMoves.length > 0) {
this.createButton(
buttonContainer,
`Move ${this.movePreview.successfulMoves.length} files`,
() => {
this.executeMoves();
},
{
isPrimary: true
}
);
}
errorCount++;
}
}
private async executeMoves() {
// Close modal first
this.close();
// Execute the moves for successful entries
const successfulEntries = this.movePreview.successfulMoves;
let movedCount = 0;
let errorCount = 0;
for (const entry of successfulEntries) {
try {
// Find the TFile for this entry
const file = this.app.vault.getAbstractFileByPath(entry.currentPath);
if (file && file instanceof TFile) {
// Use the existing move logic from NoteMoverShortcut
await this.plugin.noteMover.moveFileBasedOnTags(file, entry.targetPath || '/', true);
movedCount++;
}
} catch (error) {
NoticeManager.error(`Error moving file ${entry.fileName}: ${error instanceof Error ? error.message : String(error)}`);
errorCount++;
}
}
// Show completion notice
if (errorCount === 0) {
NoticeManager.success(`Successfully moved ${movedCount} files!`);
} else {
NoticeManager.warning(`Moved ${movedCount} files with ${errorCount} errors. Check console for details.`);
}
// Show completion notice
if (errorCount === 0) {
NoticeManager.success(`Successfully moved ${movedCount} files!`);
} else {
NoticeManager.warning(
`Moved ${movedCount} files with ${errorCount} errors. Check console for details.`
);
}
}
}

View file

@ -2,131 +2,158 @@ import { Setting, App, ButtonComponent } from 'obsidian';
import { BaseModal, BaseModalOptions } from './BaseModal';
interface ChangelogEntry {
version: string;
changes: {
features?: string[];
bugFixes?: string[];
improvements?: string[];
};
version: string;
changes: {
features?: string[];
bugFixes?: string[];
improvements?: string[];
};
}
export class UpdateModal extends BaseModal {
private currentVersion: string;
private lastSeenVersion: string;
private changelogEntries: ChangelogEntry[];
private currentVersion: string;
private lastSeenVersion: string;
private changelogEntries: ChangelogEntry[];
constructor(
app: App,
currentVersion: string,
lastSeenVersion: string,
changelogEntries: ChangelogEntry[],
options: BaseModalOptions = {}
) {
super(app, {
title: `NoteMover updated to version ${currentVersion}!`,
titleIcon: '🎉',
cssClass: 'note-mover-update-modal',
size: 'medium',
...options
constructor(
app: App,
currentVersion: string,
lastSeenVersion: string,
changelogEntries: ChangelogEntry[],
options: BaseModalOptions = {}
) {
super(app, {
title: `NoteMover updated to version ${currentVersion}!`,
titleIcon: '🎉',
cssClass: 'note-mover-update-modal',
size: 'medium',
...options,
});
this.currentVersion = currentVersion;
this.lastSeenVersion = lastSeenVersion;
this.changelogEntries = changelogEntries;
}
protected createContent(): void {
const { contentEl } = this;
// Subtitle
if (this.lastSeenVersion) {
contentEl.createEl('p', {
text: `What's new since version ${this.lastSeenVersion}?`,
cls: 'modal-subtitle',
});
} else {
contentEl.createEl('p', {
text: 'Here are the latest changes:',
cls: 'modal-subtitle',
});
}
// Changelog Content
const changelogContainer = contentEl.createEl('div', {
cls: 'changelog-container',
});
if (this.changelogEntries.length === 0) {
changelogContainer.createEl('p', {
text: 'No specific changelog information available.',
cls: 'no-changelog-text',
});
} else {
this.renderChangelog(changelogContainer);
}
// Footer with links and button
const footerContainer = contentEl.createEl('div', { cls: 'modal-footer' });
// GitHub Link
const linkContainer = footerContainer.createEl('div', {
cls: 'update-modal-links',
});
const githubLink = linkContainer.createEl('a', {
text: 'View full changelog on GitHub',
href: 'https://github.com/bueckerlars/obsidian-note-mover-shortcut/releases',
cls: 'update-modal-github-link',
});
githubLink.setAttribute('target', '_blank');
// Close button
const buttonContainer = this.createButtonContainer(footerContainer);
this.createButton(
buttonContainer,
'Close',
() => {
this.close();
},
{
isPrimary: true,
}
);
}
private renderChangelog(container: HTMLElement) {
this.changelogEntries.forEach(entry => {
const versionContainer = container.createEl('div', {
cls: 'changelog-version',
});
versionContainer.createEl('h3', {
text: `Version ${entry.version}`,
cls: 'changelog-version-title',
});
if (entry.changes.features && entry.changes.features.length > 0) {
const featuresSection = versionContainer.createEl('div', {
cls: 'changelog-section',
});
this.currentVersion = currentVersion;
this.lastSeenVersion = lastSeenVersion;
this.changelogEntries = changelogEntries;
}
protected createContent(): void {
const { contentEl } = this;
// Subtitle
if (this.lastSeenVersion) {
contentEl.createEl('p', {
text: `What's new since version ${this.lastSeenVersion}?`,
cls: 'modal-subtitle'
});
} else {
contentEl.createEl('p', {
text: 'Here are the latest changes:',
cls: 'modal-subtitle'
});
}
// Changelog Content
const changelogContainer = contentEl.createEl('div', { cls: 'changelog-container' });
if (this.changelogEntries.length === 0) {
changelogContainer.createEl('p', {
text: 'No specific changelog information available.',
cls: 'no-changelog-text'
});
} else {
this.renderChangelog(changelogContainer);
}
// Footer with links and button
const footerContainer = contentEl.createEl('div', { cls: 'modal-footer' });
// GitHub Link
const linkContainer = footerContainer.createEl('div', { cls: 'update-modal-links' });
const githubLink = linkContainer.createEl('a', {
text: 'View full changelog on GitHub',
href: 'https://github.com/bueckerlars/obsidian-note-mover-shortcut/releases',
cls: 'update-modal-github-link'
featuresSection.createEl('h4', {
text: '✨ New Features',
cls: 'changelog-section-title',
});
githubLink.setAttribute('target', '_blank');
// Close button
const buttonContainer = this.createButtonContainer(footerContainer);
this.createButton(
buttonContainer,
'Close',
() => {
this.close();
},
{
isPrimary: true
}
);
}
private renderChangelog(container: HTMLElement) {
this.changelogEntries.forEach(entry => {
const versionContainer = container.createEl('div', { cls: 'changelog-version' });
versionContainer.createEl('h3', {
text: `Version ${entry.version}`,
cls: 'changelog-version-title'
});
if (entry.changes.features && entry.changes.features.length > 0) {
const featuresSection = versionContainer.createEl('div', { cls: 'changelog-section' });
featuresSection.createEl('h4', { text: '✨ New Features', cls: 'changelog-section-title' });
const featuresList = featuresSection.createEl('ul', { cls: 'changelog-list' });
entry.changes.features.forEach(feature => {
featuresList.createEl('li', { text: feature });
});
}
if (entry.changes.improvements && entry.changes.improvements.length > 0) {
const improvementsSection = versionContainer.createEl('div', { cls: 'changelog-section' });
improvementsSection.createEl('h4', { text: '⚡ Improvements', cls: 'changelog-section-title' });
const improvementsList = improvementsSection.createEl('ul', { cls: 'changelog-list' });
entry.changes.improvements.forEach(improvement => {
improvementsList.createEl('li', { text: improvement });
});
}
if (entry.changes.bugFixes && entry.changes.bugFixes.length > 0) {
const bugFixesSection = versionContainer.createEl('div', { cls: 'changelog-section' });
bugFixesSection.createEl('h4', { text: '🐛 Bug Fixes', cls: 'changelog-section-title' });
const bugFixesList = bugFixesSection.createEl('ul', { cls: 'changelog-list' });
entry.changes.bugFixes.forEach(bugFix => {
bugFixesList.createEl('li', { text: bugFix });
});
}
const featuresList = featuresSection.createEl('ul', {
cls: 'changelog-list',
});
}
entry.changes.features.forEach(feature => {
featuresList.createEl('li', { text: feature });
});
}
onClose() {
super.onClose();
}
if (entry.changes.improvements && entry.changes.improvements.length > 0) {
const improvementsSection = versionContainer.createEl('div', {
cls: 'changelog-section',
});
improvementsSection.createEl('h4', {
text: '⚡ Improvements',
cls: 'changelog-section-title',
});
const improvementsList = improvementsSection.createEl('ul', {
cls: 'changelog-list',
});
entry.changes.improvements.forEach(improvement => {
improvementsList.createEl('li', { text: improvement });
});
}
if (entry.changes.bugFixes && entry.changes.bugFixes.length > 0) {
const bugFixesSection = versionContainer.createEl('div', {
cls: 'changelog-section',
});
bugFixesSection.createEl('h4', {
text: '🐛 Bug Fixes',
cls: 'changelog-section-title',
});
const bugFixesList = bugFixesSection.createEl('ul', {
cls: 'changelog-list',
});
entry.changes.bugFixes.forEach(bugFix => {
bugFixesList.createEl('li', { text: bugFix });
});
}
});
}
onClose() {
super.onClose();
}
}

View file

@ -1,75 +1,114 @@
import NoteMoverShortcutPlugin from "main";
import { PluginSettingTab } from "obsidian";
import { HistoryEntry, BulkOperation, RetentionPolicy } from '../types/HistoryEntry';
import { SETTINGS_CONSTANTS } from "../config/constants";
import NoteMoverShortcutPlugin from 'main';
import { PluginSettingTab } from 'obsidian';
import {
PeriodicMovementSettingsSection,
FilterSettingsSection,
RulesSettingsSection,
HistorySettingsSection,
ImportExportSettingsSection
} from "./sections";
HistoryEntry,
BulkOperation,
RetentionPolicy,
} from '../types/HistoryEntry';
import { SETTINGS_CONSTANTS } from '../config/constants';
import {
PeriodicMovementSettingsSection,
FilterSettingsSection,
RulesSettingsSection,
HistorySettingsSection,
ImportExportSettingsSection,
} from './sections';
interface Rule {
criteria: string, // Format: "type: value" (e.g. "tag: #project", "fileName: notes.md")
path: string,
criteria: string; // Format: "type: value" (e.g. "tag: #project", "fileName: notes.md")
path: string;
}
export interface NoteMoverShortcutSettings {
enablePeriodicMovement: boolean,
periodicMovementInterval: number,
enableOnEditTrigger?: boolean,
filter: string[],
isFilterWhitelist: boolean,
rules: Rule[],
onlyMoveNotesWithRules: boolean,
history?: HistoryEntry[],
bulkOperations?: BulkOperation[],
lastSeenVersion?: string,
retentionPolicy?: RetentionPolicy,
enablePeriodicMovement: boolean;
periodicMovementInterval: number;
enableOnEditTrigger?: boolean;
filter: string[];
isFilterWhitelist: boolean;
rules: Rule[];
onlyMoveNotesWithRules: boolean;
history?: HistoryEntry[];
bulkOperations?: BulkOperation[];
lastSeenVersion?: string;
retentionPolicy?: RetentionPolicy;
}
export const DEFAULT_SETTINGS: NoteMoverShortcutSettings = SETTINGS_CONSTANTS.DEFAULT_SETTINGS;
export const DEFAULT_SETTINGS: NoteMoverShortcutSettings =
SETTINGS_CONSTANTS.DEFAULT_SETTINGS;
export class NoteMoverShortcutSettingsTab extends PluginSettingTab {
private periodicMovementSettings: PeriodicMovementSettingsSection;
private filterSettings: FilterSettingsSection;
private rulesSettings: RulesSettingsSection;
private historySettings: HistorySettingsSection;
private importExportSettings: ImportExportSettingsSection;
private periodicMovementSettings: PeriodicMovementSettingsSection;
private filterSettings: FilterSettingsSection;
private rulesSettings: RulesSettingsSection;
private historySettings: HistorySettingsSection;
private importExportSettings: ImportExportSettingsSection;
constructor(private plugin: NoteMoverShortcutPlugin) {
super(plugin.app, plugin);
// Initialize section classes
this.periodicMovementSettings = new PeriodicMovementSettingsSection(plugin, this.containerEl, () => this.display());
this.filterSettings = new FilterSettingsSection(plugin, this.containerEl, () => this.display());
this.rulesSettings = new RulesSettingsSection(plugin, this.containerEl, () => this.display());
this.historySettings = new HistorySettingsSection(plugin, this.containerEl);
this.importExportSettings = new ImportExportSettingsSection(plugin, this.containerEl, () => this.display());
}
constructor(private plugin: NoteMoverShortcutPlugin) {
super(plugin.app, plugin);
display(): void {
this.containerEl.empty();
// Initialize section classes
this.periodicMovementSettings = new PeriodicMovementSettingsSection(
plugin,
this.containerEl,
() => this.display()
);
this.filterSettings = new FilterSettingsSection(
plugin,
this.containerEl,
() => this.display()
);
this.rulesSettings = new RulesSettingsSection(
plugin,
this.containerEl,
() => this.display()
);
this.historySettings = new HistorySettingsSection(plugin, this.containerEl);
this.importExportSettings = new ImportExportSettingsSection(
plugin,
this.containerEl,
() => this.display()
);
}
// Update containerEl references for all sections
this.periodicMovementSettings = new PeriodicMovementSettingsSection(this.plugin, this.containerEl, () => this.display());
this.filterSettings = new FilterSettingsSection(this.plugin, this.containerEl, () => this.display());
this.rulesSettings = new RulesSettingsSection(this.plugin, this.containerEl, () => this.display());
this.historySettings = new HistorySettingsSection(this.plugin, this.containerEl);
this.importExportSettings = new ImportExportSettingsSection(this.plugin, this.containerEl, () => this.display());
display(): void {
this.containerEl.empty();
this.periodicMovementSettings.addTriggerSettings();
// Update containerEl references for all sections
this.periodicMovementSettings = new PeriodicMovementSettingsSection(
this.plugin,
this.containerEl,
() => this.display()
);
this.filterSettings = new FilterSettingsSection(
this.plugin,
this.containerEl,
() => this.display()
);
this.rulesSettings = new RulesSettingsSection(
this.plugin,
this.containerEl,
() => this.display()
);
this.historySettings = new HistorySettingsSection(
this.plugin,
this.containerEl
);
this.importExportSettings = new ImportExportSettingsSection(
this.plugin,
this.containerEl,
() => this.display()
);
this.filterSettings.addFilterSettings();
this.periodicMovementSettings.addTriggerSettings();
this.rulesSettings.addRulesSetting();
this.rulesSettings.addRulesArray();
this.rulesSettings.addAddRuleButtonSetting();
this.filterSettings.addFilterSettings();
this.historySettings.addHistorySettings();
this.rulesSettings.addRulesSetting();
this.rulesSettings.addRulesArray();
this.rulesSettings.addAddRuleButtonSetting();
this.importExportSettings.addImportExportSettings();
}
this.historySettings.addHistorySettings();
this.importExportSettings.addImportExportSettings();
}
}

View file

@ -1,105 +1,117 @@
import NoteMoverShortcutPlugin from "main";
import { App, Setting } from "obsidian";
import { SETTINGS_CONSTANTS } from "../../config/constants";
import NoteMoverShortcutPlugin from 'main';
import { App, Setting } from 'obsidian';
import { SETTINGS_CONSTANTS } from '../../config/constants';
export class FilterSettingsSection {
constructor(
private plugin: NoteMoverShortcutPlugin,
private containerEl: HTMLElement,
private refreshDisplay: () => void
) {}
constructor(
private plugin: NoteMoverShortcutPlugin,
private containerEl: HTMLElement,
private refreshDisplay: () => void
) {}
addFilterSettings(): void {
// Filter settings
new Setting(this.containerEl).setName('Filter').setHeading();
addFilterSettings(): void {
// Filter settings
new Setting(this.containerEl).setName('Filter').setHeading();
new Setting(this.containerEl)
.setName('Filter mode')
.setDesc('Choose between blacklist or whitelist mode')
.addDropdown(dropdown => dropdown
.addOption('blacklist', 'Blacklist (exclude matching files)')
.addOption('whitelist', 'Whitelist (include only matching files)')
.setValue(this.plugin.settings.isFilterWhitelist ? 'whitelist' : 'blacklist')
.onChange(async (value) => {
this.plugin.settings.isFilterWhitelist = value === 'whitelist';
await this.plugin.save_settings();
// Update RuleManager
this.plugin.noteMover.updateRuleManager();
})
);
new Setting(this.containerEl)
.setName('Filter mode')
.setDesc('Choose between blacklist or whitelist mode')
.addDropdown(dropdown =>
dropdown
.addOption('blacklist', 'Blacklist (exclude matching files)')
.addOption('whitelist', 'Whitelist (include only matching files)')
.setValue(
this.plugin.settings.isFilterWhitelist ? 'whitelist' : 'blacklist'
)
.onChange(async value => {
this.plugin.settings.isFilterWhitelist = value === 'whitelist';
await this.plugin.save_settings();
// Update RuleManager
this.plugin.noteMover.updateRuleManager();
})
);
this.addPeriodicMovementFilterArray();
this.addPeriodicMovementFilterArray();
new Setting(this.containerEl)
.addButton(btn => btn
.setButtonText('Add new filter')
.setCta()
.onClick(async () => {
this.plugin.settings.filter.push('');
await this.plugin.save_settings();
// Update RuleManager
this.plugin.noteMover.updateRuleManager();
this.refreshDisplay();
})
);
}
new Setting(this.containerEl).addButton(btn =>
btn
.setButtonText('Add new filter')
.setCta()
.onClick(async () => {
this.plugin.settings.filter.push('');
await this.plugin.save_settings();
// Update RuleManager
this.plugin.noteMover.updateRuleManager();
this.refreshDisplay();
})
);
}
addPeriodicMovementFilterArray(): void {
this.plugin.settings.filter.forEach((filter, index) => {
const s = new Setting(this.containerEl)
.addSearch((cb) => {
// AdvancedSuggest instead of TagSuggest
new (require("../suggesters/AdvancedSuggest")).AdvancedSuggest(this.app, cb.inputEl);
cb.setPlaceholder(SETTINGS_CONSTANTS.PLACEHOLDER_TEXTS.FILTER)
.setValue(filter || "")
.onChange(async (value) => {
// Don't save empty filters
if (!value || value.trim() === "") {
this.plugin.settings.filter[index] = "";
} else {
this.plugin.settings.filter[index] = value;
}
await this.plugin.save_settings();
// Update RuleManager
this.plugin.noteMover.updateRuleManager();
});
// @ts-ignore
cb.containerEl.addClass("note_mover_search");
})
.addExtraButton(btn => btn
.setIcon('up-chevron-glyph')
.onClick(() => {
this.moveFilter(index, -1);
})
)
.addExtraButton(btn => btn
.setIcon('down-chevron-glyph')
.onClick(() => {
this.moveFilter(index, 1);
})
)
.addExtraButton(btn => btn
.setIcon('cross')
.onClick(async () => {
this.plugin.settings.filter.splice(index, 1);
await this.plugin.save_settings();
// Update RuleManager
this.plugin.noteMover.updateRuleManager();
this.refreshDisplay();
})
);
s.infoEl.remove();
});
}
addPeriodicMovementFilterArray(): void {
this.plugin.settings.filter.forEach((filter, index) => {
const s = new Setting(this.containerEl)
.addSearch(cb => {
// AdvancedSuggest instead of TagSuggest
new (require('../suggesters/AdvancedSuggest').AdvancedSuggest)(
this.app,
cb.inputEl
);
cb.setPlaceholder(SETTINGS_CONSTANTS.PLACEHOLDER_TEXTS.FILTER)
.setValue(filter || '')
.onChange(async value => {
// Don't save empty filters
if (!value || value.trim() === '') {
this.plugin.settings.filter[index] = '';
} else {
this.plugin.settings.filter[index] = value;
}
await this.plugin.save_settings();
// Update RuleManager
this.plugin.noteMover.updateRuleManager();
});
// @ts-ignore
cb.containerEl.addClass('note_mover_search');
})
.addExtraButton(btn =>
btn.setIcon('up-chevron-glyph').onClick(() => {
this.moveFilter(index, -1);
})
)
.addExtraButton(btn =>
btn.setIcon('down-chevron-glyph').onClick(() => {
this.moveFilter(index, 1);
})
)
.addExtraButton(btn =>
btn.setIcon('cross').onClick(async () => {
this.plugin.settings.filter.splice(index, 1);
await this.plugin.save_settings();
// Update RuleManager
this.plugin.noteMover.updateRuleManager();
this.refreshDisplay();
})
);
s.infoEl.remove();
});
}
moveFilter(index: number, direction: number) {
const newIndex = Math.max(0, Math.min(this.plugin.settings.filter.length - 1, index + direction));
[this.plugin.settings.filter[index], this.plugin.settings.filter[newIndex]] = [this.plugin.settings.filter[newIndex], this.plugin.settings.filter[index]];
this.plugin.save_settings();
this.refreshDisplay();
}
moveFilter(index: number, direction: number) {
const newIndex = Math.max(
0,
Math.min(this.plugin.settings.filter.length - 1, index + direction)
);
[
this.plugin.settings.filter[index],
this.plugin.settings.filter[newIndex],
] = [
this.plugin.settings.filter[newIndex],
this.plugin.settings.filter[index],
];
this.plugin.save_settings();
this.refreshDisplay();
}
private get app(): App {
return this.plugin.app;
}
private get app(): App {
return this.plugin.app;
}
}

View file

@ -1,92 +1,109 @@
import NoteMoverShortcutPlugin from "main";
import { App, Setting } from "obsidian";
import { ConfirmModal } from "../../modals/ConfirmModal";
import { SETTINGS_CONSTANTS, HISTORY_CONSTANTS } from "../../config/constants";
import { RetentionPolicy } from "../../types/HistoryEntry";
import NoteMoverShortcutPlugin from 'main';
import { App, Setting } from 'obsidian';
import { ConfirmModal } from '../../modals/ConfirmModal';
import { SETTINGS_CONSTANTS, HISTORY_CONSTANTS } from '../../config/constants';
import { RetentionPolicy } from '../../types/HistoryEntry';
export class HistorySettingsSection {
constructor(
private plugin: NoteMoverShortcutPlugin,
private containerEl: HTMLElement
) {}
constructor(
private plugin: NoteMoverShortcutPlugin,
private containerEl: HTMLElement
) {}
addHistorySettings(): void {
new Setting(this.containerEl).setName('History').setHeading();
addHistorySettings(): void {
new Setting(this.containerEl).setName('History').setHeading();
// Retention Policy Settings
this.addRetentionPolicySettings();
// Retention Policy Settings
this.addRetentionPolicySettings();
// Clear History Button
new Setting(this.containerEl)
.setName('Clear history')
.setDesc('Clears the history of moved notes')
.addButton(btn => btn
.setButtonText(SETTINGS_CONSTANTS.UI_TEXTS.CLEAR_HISTORY)
.setWarning()
.onClick(async () => {
const confirmed = await ConfirmModal.show(this.app, {
title: SETTINGS_CONSTANTS.UI_TEXTS.CLEAR_HISTORY_TITLE,
message: SETTINGS_CONSTANTS.UI_TEXTS.CLEAR_HISTORY_MESSAGE,
confirmText: SETTINGS_CONSTANTS.UI_TEXTS.CLEAR_HISTORY_CONFIRM,
cancelText: SETTINGS_CONSTANTS.UI_TEXTS.CLEAR_HISTORY_CANCEL,
danger: true
});
if (confirmed) {
await this.plugin.historyManager.clearHistory();
}
})
);
}
// Clear History Button
new Setting(this.containerEl)
.setName('Clear history')
.setDesc('Clears the history of moved notes')
.addButton(btn =>
btn
.setButtonText(SETTINGS_CONSTANTS.UI_TEXTS.CLEAR_HISTORY)
.setWarning()
.onClick(async () => {
const confirmed = await ConfirmModal.show(this.app, {
title: SETTINGS_CONSTANTS.UI_TEXTS.CLEAR_HISTORY_TITLE,
message: SETTINGS_CONSTANTS.UI_TEXTS.CLEAR_HISTORY_MESSAGE,
confirmText: SETTINGS_CONSTANTS.UI_TEXTS.CLEAR_HISTORY_CONFIRM,
cancelText: SETTINGS_CONSTANTS.UI_TEXTS.CLEAR_HISTORY_CANCEL,
danger: true,
});
private addRetentionPolicySettings(): void {
// Initialize retention policy if not set
if (!this.plugin.settings.retentionPolicy) {
this.plugin.settings.retentionPolicy = { ...HISTORY_CONSTANTS.DEFAULT_RETENTION_POLICY };
}
if (confirmed) {
await this.plugin.historyManager.clearHistory();
}
})
);
}
const retentionPolicy = this.plugin.settings.retentionPolicy;
private addRetentionPolicySettings(): void {
// Initialize retention policy if not set
if (!this.plugin.settings.retentionPolicy) {
this.plugin.settings.retentionPolicy = {
...HISTORY_CONSTANTS.DEFAULT_RETENTION_POLICY,
};
}
// Retention Policy Section - Single line with value input and unit dropdown
new Setting(this.containerEl)
.setName(SETTINGS_CONSTANTS.UI_TEXTS.RETENTION_POLICY_TITLE)
.setDesc(SETTINGS_CONSTANTS.UI_TEXTS.RETENTION_POLICY_DESC)
.addText(text => text
.setPlaceholder('30')
.setValue(retentionPolicy.value.toString())
.onChange(async (value: string) => {
const numValue = parseInt(value);
if (!isNaN(numValue) && numValue > 0) {
this.plugin.settings.retentionPolicy!.value = numValue;
await (this.plugin as any).save_settings();
}
})
)
.addDropdown(dropdown => dropdown
.addOption('days', SETTINGS_CONSTANTS.UI_TEXTS.RETENTION_POLICY_DAYS)
.addOption('weeks', SETTINGS_CONSTANTS.UI_TEXTS.RETENTION_POLICY_WEEKS)
.addOption('months', SETTINGS_CONSTANTS.UI_TEXTS.RETENTION_POLICY_MONTHS)
.setValue(retentionPolicy.unit)
.onChange(async (value: string) => {
this.plugin.settings.retentionPolicy!.unit = value as 'days' | 'weeks' | 'months';
await (this.plugin as any).save_settings();
})
);
const retentionPolicy = this.plugin.settings.retentionPolicy;
// Cleanup button
new Setting(this.containerEl)
.setName('Clean up old entries')
.setDesc(`Remove history entries older than ${retentionPolicy.value} ${retentionPolicy.unit}`)
.addButton(btn => btn
.setButtonText('Clean up now')
.setCta()
.onClick(async () => {
await this.plugin.historyManager.cleanupOldEntries();
})
);
}
// Retention Policy Section - Single line with value input and unit dropdown
new Setting(this.containerEl)
.setName(SETTINGS_CONSTANTS.UI_TEXTS.RETENTION_POLICY_TITLE)
.setDesc(SETTINGS_CONSTANTS.UI_TEXTS.RETENTION_POLICY_DESC)
.addText(text =>
text
.setPlaceholder('30')
.setValue(retentionPolicy.value.toString())
.onChange(async (value: string) => {
const numValue = parseInt(value);
if (!isNaN(numValue) && numValue > 0) {
this.plugin.settings.retentionPolicy!.value = numValue;
await (this.plugin as any).save_settings();
}
})
)
.addDropdown(dropdown =>
dropdown
.addOption('days', SETTINGS_CONSTANTS.UI_TEXTS.RETENTION_POLICY_DAYS)
.addOption(
'weeks',
SETTINGS_CONSTANTS.UI_TEXTS.RETENTION_POLICY_WEEKS
)
.addOption(
'months',
SETTINGS_CONSTANTS.UI_TEXTS.RETENTION_POLICY_MONTHS
)
.setValue(retentionPolicy.unit)
.onChange(async (value: string) => {
this.plugin.settings.retentionPolicy!.unit = value as
| 'days'
| 'weeks'
| 'months';
await (this.plugin as any).save_settings();
})
);
private get app(): App {
return this.plugin.app;
}
// Cleanup button
new Setting(this.containerEl)
.setName('Clean up old entries')
.setDesc(
`Remove history entries older than ${retentionPolicy.value} ${retentionPolicy.unit}`
)
.addButton(btn =>
btn
.setButtonText('Clean up now')
.setCta()
.onClick(async () => {
await this.plugin.historyManager.cleanupOldEntries();
})
);
}
private get app(): App {
return this.plugin.app;
}
}

View file

@ -1,211 +1,206 @@
import NoteMoverShortcutPlugin from "main";
import { App, Setting } from "obsidian";
import { createError, handleError } from "src/utils/Error";
import { NoticeManager } from "src/utils/NoticeManager";
import { ConfirmModal } from "../../modals/ConfirmModal";
import { SettingsValidator } from "src/utils/SettingsValidator";
import { SETTINGS_CONSTANTS } from "../../config/constants";
import NoteMoverShortcutPlugin from 'main';
import { App, Setting } from 'obsidian';
import { createError, handleError } from 'src/utils/Error';
import { NoticeManager } from 'src/utils/NoticeManager';
import { ConfirmModal } from '../../modals/ConfirmModal';
import { SettingsValidator } from 'src/utils/SettingsValidator';
import { SETTINGS_CONSTANTS } from '../../config/constants';
export class ImportExportSettingsSection {
constructor(
private plugin: NoteMoverShortcutPlugin,
private containerEl: HTMLElement,
private refreshDisplay: () => void
) {}
constructor(
private plugin: NoteMoverShortcutPlugin,
private containerEl: HTMLElement,
private refreshDisplay: () => void
) {}
addImportExportSettings(): void {
new Setting(this.containerEl).setName('Import/Export').setHeading();
addImportExportSettings(): void {
new Setting(this.containerEl).setName('Import/Export').setHeading();
// Export Settings Button
new Setting(this.containerEl)
.setName('Export settings')
.setDesc('Export your current settings as a JSON file')
.addButton(btn => btn
.setButtonText(SETTINGS_CONSTANTS.UI_TEXTS.EXPORT_SETTINGS)
.setCta()
.onClick(async () => {
await this.exportSettings();
})
);
// Export Settings Button
new Setting(this.containerEl)
.setName('Export settings')
.setDesc('Export your current settings as a JSON file')
.addButton(btn =>
btn
.setButtonText(SETTINGS_CONSTANTS.UI_TEXTS.EXPORT_SETTINGS)
.setCta()
.onClick(async () => {
await this.exportSettings();
})
);
// Import Settings Button
new Setting(this.containerEl)
.setName('Import settings')
.setDesc('Import settings from a JSON file')
.addButton(btn => btn
.setButtonText(SETTINGS_CONSTANTS.UI_TEXTS.IMPORT_SETTINGS)
.setWarning()
.onClick(async () => {
await this.importSettings();
})
);
}
// Import Settings Button
new Setting(this.containerEl)
.setName('Import settings')
.setDesc('Import settings from a JSON file')
.addButton(btn =>
btn
.setButtonText(SETTINGS_CONSTANTS.UI_TEXTS.IMPORT_SETTINGS)
.setWarning()
.onClick(async () => {
await this.importSettings();
})
);
}
private async exportSettings(): Promise<void> {
try {
// Create a clean copy of settings for export, excluding history
const settingsToExport = { ...this.plugin.settings };
// Remove history-related fields from export
delete settingsToExport.history;
delete settingsToExport.bulkOperations;
// Convert to JSON string with proper formatting
const jsonString = JSON.stringify(settingsToExport, null, 2);
// Create blob and download
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
// Create download link
const link = document.createElement('a');
link.href = url;
link.download = `note-mover-settings-${new Date().toISOString().split('T')[0]}.json`;
// Trigger download
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up
URL.revokeObjectURL(url);
// Show success message
NoticeManager.info(
SETTINGS_CONSTANTS.UI_TEXTS.EXPORT_SUCCESS
);
} catch (error) {
handleError(
createError(`Export failed: ${error.message}`),
'Export settings',
false
);
}
}
private async exportSettings(): Promise<void> {
try {
// Create a clean copy of settings for export, excluding history
const settingsToExport = { ...this.plugin.settings };
private async importSettings(): Promise<void> {
try {
// Create file input element
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.style.display = 'none';
// Add to DOM temporarily
document.body.appendChild(input);
// Handle file selection
const file = await new Promise<File>((resolve, reject) => {
input.onchange = (e) => {
const target = e.target as HTMLInputElement;
if (target.files && target.files[0]) {
resolve(target.files[0]);
} else {
reject(new Error('No file selected'));
}
};
input.click();
});
// Clean up input element
document.body.removeChild(input);
// Read file content
const fileContent = await this.readFileAsText(file);
// Parse JSON
let importedSettings: any;
try {
importedSettings = JSON.parse(fileContent);
} catch (parseError) {
handleError(
createError(SETTINGS_CONSTANTS.UI_TEXTS.INVALID_FILE),
'Import settings',
false
);
return;
}
// Validate settings
const validation = SettingsValidator.validateSettings(importedSettings);
if (!validation.isValid) {
const errorMessage = `${SETTINGS_CONSTANTS.UI_TEXTS.VALIDATION_ERRORS}:\n${validation.errors.join('\n')}`;
handleError(
createError(errorMessage),
'Import settings',
false
);
return;
}
// Show warnings if any
if (validation.warnings.length > 0) {
NoticeManager.warning(
`Import warnings: ${validation.warnings.join(', ')}`
);
}
// Show confirmation modal
const confirmed = await ConfirmModal.show(this.app, {
title: SETTINGS_CONSTANTS.UI_TEXTS.IMPORT_SETTINGS_TITLE,
message: SETTINGS_CONSTANTS.UI_TEXTS.IMPORT_SETTINGS_MESSAGE,
confirmText: SETTINGS_CONSTANTS.UI_TEXTS.IMPORT_SETTINGS_CONFIRM,
cancelText: SETTINGS_CONSTANTS.UI_TEXTS.IMPORT_SETTINGS_CANCEL,
danger: true
});
if (confirmed) {
// Sanitize and apply settings
const sanitizedSettings = SettingsValidator.sanitizeSettings(importedSettings);
// Merge with current settings (preserve some fields if needed)
this.plugin.settings = {
...this.plugin.settings,
...sanitizedSettings
};
// Save settings
await this.plugin.save_settings();
// Update RuleManager
this.plugin.noteMover.updateRuleManager();
// Refresh the settings display
this.refreshDisplay();
// Show success message
NoticeManager.success(
SETTINGS_CONSTANTS.UI_TEXTS.IMPORT_SUCCESS
);
}
} catch (error) {
if (error.message !== 'File selection cancelled') {
handleError(
createError(`Import failed: ${error.message}`),
'Import settings',
false
);
}
}
}
// Remove history-related fields from export
delete settingsToExport.history;
delete settingsToExport.bulkOperations;
private readFileAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
if (e.target?.result) {
resolve(e.target.result as string);
} else {
reject(new Error('Failed to read file'));
}
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsText(file);
});
}
// Convert to JSON string with proper formatting
const jsonString = JSON.stringify(settingsToExport, null, 2);
private get app(): App {
return this.plugin.app;
}
// Create blob and download
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
// Create download link
const link = document.createElement('a');
link.href = url;
link.download = `note-mover-settings-${new Date().toISOString().split('T')[0]}.json`;
// Trigger download
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up
URL.revokeObjectURL(url);
// Show success message
NoticeManager.info(SETTINGS_CONSTANTS.UI_TEXTS.EXPORT_SUCCESS);
} catch (error) {
handleError(
createError(`Export failed: ${error.message}`),
'Export settings',
false
);
}
}
private async importSettings(): Promise<void> {
try {
// Create file input element
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.style.display = 'none';
// Add to DOM temporarily
document.body.appendChild(input);
// Handle file selection
const file = await new Promise<File>((resolve, reject) => {
input.onchange = e => {
const target = e.target as HTMLInputElement;
if (target.files && target.files[0]) {
resolve(target.files[0]);
} else {
reject(new Error('No file selected'));
}
};
input.click();
});
// Clean up input element
document.body.removeChild(input);
// Read file content
const fileContent = await this.readFileAsText(file);
// Parse JSON
let importedSettings: any;
try {
importedSettings = JSON.parse(fileContent);
} catch (parseError) {
handleError(
createError(SETTINGS_CONSTANTS.UI_TEXTS.INVALID_FILE),
'Import settings',
false
);
return;
}
// Validate settings
const validation = SettingsValidator.validateSettings(importedSettings);
if (!validation.isValid) {
const errorMessage = `${SETTINGS_CONSTANTS.UI_TEXTS.VALIDATION_ERRORS}:\n${validation.errors.join('\n')}`;
handleError(createError(errorMessage), 'Import settings', false);
return;
}
// Show warnings if any
if (validation.warnings.length > 0) {
NoticeManager.warning(
`Import warnings: ${validation.warnings.join(', ')}`
);
}
// Show confirmation modal
const confirmed = await ConfirmModal.show(this.app, {
title: SETTINGS_CONSTANTS.UI_TEXTS.IMPORT_SETTINGS_TITLE,
message: SETTINGS_CONSTANTS.UI_TEXTS.IMPORT_SETTINGS_MESSAGE,
confirmText: SETTINGS_CONSTANTS.UI_TEXTS.IMPORT_SETTINGS_CONFIRM,
cancelText: SETTINGS_CONSTANTS.UI_TEXTS.IMPORT_SETTINGS_CANCEL,
danger: true,
});
if (confirmed) {
// Sanitize and apply settings
const sanitizedSettings =
SettingsValidator.sanitizeSettings(importedSettings);
// Merge with current settings (preserve some fields if needed)
this.plugin.settings = {
...this.plugin.settings,
...sanitizedSettings,
};
// Save settings
await this.plugin.save_settings();
// Update RuleManager
this.plugin.noteMover.updateRuleManager();
// Refresh the settings display
this.refreshDisplay();
// Show success message
NoticeManager.success(SETTINGS_CONSTANTS.UI_TEXTS.IMPORT_SUCCESS);
}
} catch (error) {
if (error.message !== 'File selection cancelled') {
handleError(
createError(`Import failed: ${error.message}`),
'Import settings',
false
);
}
}
}
private readFileAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = e => {
if (e.target?.result) {
resolve(e.target.result as string);
} else {
reject(new Error('Failed to read file'));
}
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsText(file);
});
}
private get app(): App {
return this.plugin.app;
}
}

View file

@ -1,81 +1,96 @@
import NoteMoverShortcutPlugin from "main";
import { App, Setting } from "obsidian";
import { createError, handleError } from "src/utils/Error";
import { SETTINGS_CONSTANTS } from "../../config/constants";
import NoteMoverShortcutPlugin from 'main';
import { App, Setting } from 'obsidian';
import { createError, handleError } from 'src/utils/Error';
import { SETTINGS_CONSTANTS } from '../../config/constants';
export class TriggerSettingsSection {
constructor(
private plugin: NoteMoverShortcutPlugin,
private containerEl: HTMLElement,
private refreshDisplay: () => void
) {}
constructor(
private plugin: NoteMoverShortcutPlugin,
private containerEl: HTMLElement,
private refreshDisplay: () => void
) {}
addTriggerSettings(): void {
new Setting(this.containerEl).setName('Triggers').setHeading();
addTriggerSettings(): void {
new Setting(this.containerEl).setName('Triggers').setHeading();
// On Edit Trigger Toggle
new Setting(this.containerEl)
.setName('Enable on-edit trigger')
.setDesc('Automatically check and move the edited note when a file is modified')
.addToggle(toggle => toggle
.setValue((this.plugin.settings as any).enableOnEditTrigger === true)
.onChange(async (value) => {
(this.plugin.settings as any).enableOnEditTrigger = value;
await this.plugin.save_settings();
// Activate/Deactivate on-edit listener
// Uses new TriggerEventHandler
(this.plugin as any).triggerHandler?.toggleOnEditListener();
// On Edit Trigger Toggle
new Setting(this.containerEl)
.setName('Enable on-edit trigger')
.setDesc(
'Automatically check and move the edited note when a file is modified'
)
.addToggle(toggle =>
toggle
.setValue((this.plugin.settings as any).enableOnEditTrigger === true)
.onChange(async value => {
(this.plugin.settings as any).enableOnEditTrigger = value;
await this.plugin.save_settings();
// Activate/Deactivate on-edit listener
// Uses new TriggerEventHandler
(this.plugin as any).triggerHandler?.toggleOnEditListener();
// Force refresh display
this.refreshDisplay();
})
);
// Force refresh display
this.refreshDisplay();
})
);
// Periodic Movement Toggle
new Setting(this.containerEl)
.setName('Enable periodic movement')
.setDesc('Enable the periodic movement of notes')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enablePeriodicMovement)
.onChange(async (value) => {
this.plugin.settings.enablePeriodicMovement = value;
await this.plugin.save_settings();
// Toggle interval based on the new value via TriggerEventHandler
(this.plugin as any).triggerHandler?.togglePeriodic();
// Force refresh display
this.refreshDisplay();
})
);
if (this.plugin.settings.enablePeriodicMovement) {
new Setting(this.containerEl)
.setName('Periodic movement interval')
.setDesc('Set the interval for the periodic movement of notes in minutes')
.addText(text => text
.setPlaceholder(SETTINGS_CONSTANTS.PLACEHOLDER_TEXTS.INTERVAL)
.setValue(this.plugin.settings.periodicMovementInterval.toString())
.onChange(async (value) => {
const interval = parseInt(value);
if (isNaN(interval)) {
handleError(createError('Interval must be a number'), 'Periodic movement interval setting', false);
return;
}
if (interval < 1) {
handleError(createError('Interval must be greater than 0'), 'Periodic movement interval setting', false);
return;
}
// Periodic Movement Toggle
new Setting(this.containerEl)
.setName('Enable periodic movement')
.setDesc('Enable the periodic movement of notes')
.addToggle(toggle =>
toggle
.setValue(this.plugin.settings.enablePeriodicMovement)
.onChange(async value => {
this.plugin.settings.enablePeriodicMovement = value;
await this.plugin.save_settings();
// Toggle interval based on the new value via TriggerEventHandler
(this.plugin as any).triggerHandler?.togglePeriodic();
this.plugin.settings.periodicMovementInterval = interval;
await this.plugin.save_settings();
// If periodic is enabled, restart interval via handler
(this.plugin as any).triggerHandler?.togglePeriodic();
})
);
}
}
// Force refresh display
this.refreshDisplay();
})
);
private get app(): App {
return this.plugin.app;
}
if (this.plugin.settings.enablePeriodicMovement) {
new Setting(this.containerEl)
.setName('Periodic movement interval')
.setDesc(
'Set the interval for the periodic movement of notes in minutes'
)
.addText(text =>
text
.setPlaceholder(SETTINGS_CONSTANTS.PLACEHOLDER_TEXTS.INTERVAL)
.setValue(this.plugin.settings.periodicMovementInterval.toString())
.onChange(async value => {
const interval = parseInt(value);
if (isNaN(interval)) {
handleError(
createError('Interval must be a number'),
'Periodic movement interval setting',
false
);
return;
}
if (interval < 1) {
handleError(
createError('Interval must be greater than 0'),
'Periodic movement interval setting',
false
);
return;
}
this.plugin.settings.periodicMovementInterval = interval;
await this.plugin.save_settings();
// If periodic is enabled, restart interval via handler
(this.plugin as any).triggerHandler?.togglePeriodic();
})
);
}
}
private get app(): App {
return this.plugin.app;
}
}

View file

@ -1,141 +1,148 @@
import NoteMoverShortcutPlugin from "main";
import { App, Setting } from "obsidian";
import { FolderSuggest } from "../suggesters/FolderSuggest";
import { createError, handleError } from "src/utils/Error";
import { SETTINGS_CONSTANTS } from "../../config/constants";
import NoteMoverShortcutPlugin from 'main';
import { App, Setting } from 'obsidian';
import { FolderSuggest } from '../suggesters/FolderSuggest';
import { createError, handleError } from 'src/utils/Error';
import { SETTINGS_CONSTANTS } from '../../config/constants';
export class RulesSettingsSection {
constructor(
private plugin: NoteMoverShortcutPlugin,
private containerEl: HTMLElement,
private refreshDisplay: () => void
) {}
constructor(
private plugin: NoteMoverShortcutPlugin,
private containerEl: HTMLElement,
private refreshDisplay: () => void
) {}
addRulesSetting(): void {
new Setting(this.containerEl).setName('Rules').setHeading();
addRulesSetting(): void {
new Setting(this.containerEl).setName('Rules').setHeading();
const descUseRules = document.createDocumentFragment();
descUseRules.append(
'The NoteMover will move files to the folder associated with the specified criteria.',
document.createElement('br'),
'Criteria can be tags, filenames, paths, content, properties, or dates. If multiple rules match, the first one will be applied.',
);
const descUseRules = document.createDocumentFragment();
descUseRules.append(
'The NoteMover will move files to the folder associated with the specified criteria.',
document.createElement('br'),
'Criteria can be tags, filenames, paths, content, properties, or dates. If multiple rules match, the first one will be applied.'
);
new Setting(this.containerEl)
.setName('Rules description')
.setDesc(descUseRules);
new Setting(this.containerEl)
.setName('Rules description')
.setDesc(descUseRules);
const descOnlyMoveWithRules = document.createDocumentFragment();
descOnlyMoveWithRules.append(
'When enabled, only files that match defined rules will be moved. Files without matching rules will be left untouched.',
document.createElement('br'),
'When disabled, files without matching rules will be moved to the root folder.',
);
const descOnlyMoveWithRules = document.createDocumentFragment();
descOnlyMoveWithRules.append(
'When enabled, only files that match defined rules will be moved. Files without matching rules will be left untouched.',
document.createElement('br'),
'When disabled, files without matching rules will be moved to the root folder.'
);
new Setting(this.containerEl)
.setName('Only move files with rules')
.setDesc(descOnlyMoveWithRules)
.addToggle(toggle => toggle
.setValue(this.plugin.settings.onlyMoveNotesWithRules)
.onChange(async (value) => {
this.plugin.settings.onlyMoveNotesWithRules = value;
await this.plugin.save_settings();
// Update RuleManager
this.plugin.noteMover.updateRuleManager();
})
);
}
new Setting(this.containerEl)
.setName('Only move files with rules')
.setDesc(descOnlyMoveWithRules)
.addToggle(toggle =>
toggle
.setValue(this.plugin.settings.onlyMoveNotesWithRules)
.onChange(async value => {
this.plugin.settings.onlyMoveNotesWithRules = value;
await this.plugin.save_settings();
// Update RuleManager
this.plugin.noteMover.updateRuleManager();
})
);
}
addAddRuleButtonSetting(): void {
new Setting(this.containerEl)
.addButton(btn => btn
.setButtonText('Add new rule')
.setCta()
.onClick(async () => {
this.plugin.settings.rules.push({ criteria: '', path: '' });
await this.plugin.save_settings();
// Update RuleManager
this.plugin.noteMover.updateRuleManager();
this.refreshDisplay();
})
);
}
addAddRuleButtonSetting(): void {
new Setting(this.containerEl).addButton(btn =>
btn
.setButtonText('Add new rule')
.setCta()
.onClick(async () => {
this.plugin.settings.rules.push({ criteria: '', path: '' });
await this.plugin.save_settings();
// Update RuleManager
this.plugin.noteMover.updateRuleManager();
this.refreshDisplay();
})
);
}
addRulesArray(): void {
this.plugin.settings.rules.forEach((rule, index) => {
const s = new Setting(this.containerEl)
.addSearch((cb) => {
// AdvancedSuggest instead of TagSuggest
new (require("../suggesters/AdvancedSuggest")).AdvancedSuggest(this.app, cb.inputEl);
cb.setPlaceholder(SETTINGS_CONSTANTS.PLACEHOLDER_TEXTS.CRITERIA)
.setValue(rule.criteria)
.onChange(async (value) => {
if (value && this.plugin.settings.rules.some(
(rule) => rule.criteria === value
)
) {
handleError(
createError("This criteria already has a folder associated with it"),
"Rules setting",
false
);
return;
}
addRulesArray(): void {
this.plugin.settings.rules.forEach((rule, index) => {
const s = new Setting(this.containerEl)
.addSearch(cb => {
// AdvancedSuggest instead of TagSuggest
new (require('../suggesters/AdvancedSuggest').AdvancedSuggest)(
this.app,
cb.inputEl
);
cb.setPlaceholder(SETTINGS_CONSTANTS.PLACEHOLDER_TEXTS.CRITERIA)
.setValue(rule.criteria)
.onChange(async value => {
if (
value &&
this.plugin.settings.rules.some(rule => rule.criteria === value)
) {
handleError(
createError(
'This criteria already has a folder associated with it'
),
'Rules setting',
false
);
return;
}
// Save Setting
this.plugin.settings.rules[index].criteria = value;
await this.plugin.save_settings();
// Update RuleManager
this.plugin.noteMover.updateRuleManager();
});
// @ts-ignore
cb.containerEl.addClass("note_mover_search");
})
.addSearch((cb) => {
new FolderSuggest(this.app, cb.inputEl);
cb.setPlaceholder(SETTINGS_CONSTANTS.PLACEHOLDER_TEXTS.PATH)
.setValue(rule.path)
.onChange(async (value) => {
this.plugin.settings.rules[index].path = value;
await this.plugin.save_settings();
});
// @ts-ignore
cb.containerEl.addClass("note_mover_search");
})
.addExtraButton(btn => btn
.setIcon('up-chevron-glyph')
.onClick(() => {
this.moveRule(index, -1);
})
)
.addExtraButton(btn => btn
.setIcon('down-chevron-glyph')
.onClick(() => {
this.moveRule(index, 1);
})
)
.addExtraButton(btn => btn
.setIcon('cross')
.onClick(async () => {
this.plugin.settings.rules.splice(index, 1);
await this.plugin.save_settings();
// Update RuleManager
this.plugin.noteMover.updateRuleManager();
this.refreshDisplay();
})
);
s.infoEl.remove();
});
}
// Save Setting
this.plugin.settings.rules[index].criteria = value;
await this.plugin.save_settings();
// Update RuleManager
this.plugin.noteMover.updateRuleManager();
});
// @ts-ignore
cb.containerEl.addClass('note_mover_search');
})
.addSearch(cb => {
new FolderSuggest(this.app, cb.inputEl);
cb.setPlaceholder(SETTINGS_CONSTANTS.PLACEHOLDER_TEXTS.PATH)
.setValue(rule.path)
.onChange(async value => {
this.plugin.settings.rules[index].path = value;
await this.plugin.save_settings();
});
// @ts-ignore
cb.containerEl.addClass('note_mover_search');
})
.addExtraButton(btn =>
btn.setIcon('up-chevron-glyph').onClick(() => {
this.moveRule(index, -1);
})
)
.addExtraButton(btn =>
btn.setIcon('down-chevron-glyph').onClick(() => {
this.moveRule(index, 1);
})
)
.addExtraButton(btn =>
btn.setIcon('cross').onClick(async () => {
this.plugin.settings.rules.splice(index, 1);
await this.plugin.save_settings();
// Update RuleManager
this.plugin.noteMover.updateRuleManager();
this.refreshDisplay();
})
);
s.infoEl.remove();
});
}
moveRule(index: number, direction: number) {
const newIndex = Math.max(0, Math.min(this.plugin.settings.rules.length - 1, index + direction));
[this.plugin.settings.rules[index], this.plugin.settings.rules[newIndex]] = [this.plugin.settings.rules[newIndex], this.plugin.settings.rules[index]];
this.plugin.save_settings();
this.refreshDisplay();
}
moveRule(index: number, direction: number) {
const newIndex = Math.max(
0,
Math.min(this.plugin.settings.rules.length - 1, index + direction)
);
[this.plugin.settings.rules[index], this.plugin.settings.rules[newIndex]] =
[this.plugin.settings.rules[newIndex], this.plugin.settings.rules[index]];
this.plugin.save_settings();
this.refreshDisplay();
}
private get app(): App {
return this.plugin.app;
}
private get app(): App {
return this.plugin.app;
}
}

View file

@ -1,185 +1,204 @@
import { App, AbstractInputSuggest, TFolder, TAbstractFile } from "obsidian";
import { MetadataExtractor } from "../../core/MetadataExtractor";
import { App, AbstractInputSuggest, TFolder, TAbstractFile } from 'obsidian';
import { MetadataExtractor } from '../../core/MetadataExtractor';
export type SuggestType = "tag" | "content" | "fileName" | "created_at" | "path" | "updated_at" | "property";
export type SuggestType =
| 'tag'
| 'content'
| 'fileName'
| 'created_at'
| 'path'
| 'updated_at'
| 'property';
const SUGGEST_TYPES: { label: string, value: SuggestType }[] = [
{ label: "tag: ", value: "tag" },
{ label: "content: ", value: "content" },
{ label: "fileName: ", value: "fileName" },
{ label: "created_at: ", value: "created_at" },
{ label: "path: ", value: "path" },
{ label: "updated_at: ", value: "updated_at" },
{ label: "property: ", value: "property" },
const SUGGEST_TYPES: { label: string; value: SuggestType }[] = [
{ label: 'tag: ', value: 'tag' },
{ label: 'content: ', value: 'content' },
{ label: 'fileName: ', value: 'fileName' },
{ label: 'created_at: ', value: 'created_at' },
{ label: 'path: ', value: 'path' },
{ label: 'updated_at: ', value: 'updated_at' },
{ label: 'property: ', value: 'property' },
];
export class AdvancedSuggest extends AbstractInputSuggest<string> {
private selectedType: SuggestType | null = null;
private tags: Set<string> = new Set();
private folders: TFolder[] = [];
private fileNames: string[] = [];
private propertyKeys: Set<string> = new Set();
private propertyValues: Map<string, Set<string>> = new Map();
private metadataExtractor: MetadataExtractor;
private selectedType: SuggestType | null = null;
private tags: Set<string> = new Set();
private folders: TFolder[] = [];
private fileNames: string[] = [];
private propertyKeys: Set<string> = new Set();
private propertyValues: Map<string, Set<string>> = new Map();
private metadataExtractor: MetadataExtractor;
constructor(app: App, private inputEl: HTMLInputElement) {
super(app, inputEl);
this.metadataExtractor = new MetadataExtractor(app);
this.loadTags();
this.loadFolders();
this.loadFileNames();
this.loadPropertyKeysAndValues();
}
constructor(
app: App,
private inputEl: HTMLInputElement
) {
super(app, inputEl);
this.metadataExtractor = new MetadataExtractor(app);
this.loadTags();
this.loadFolders();
this.loadFileNames();
this.loadPropertyKeysAndValues();
}
private loadTags(): void {
this.tags = this.metadataExtractor.extractAllTags();
}
private loadTags(): void {
this.tags = this.metadataExtractor.extractAllTags();
}
private loadFolders(): void {
const abstractFiles = this.app.vault.getAllLoadedFiles();
this.folders = abstractFiles.filter(f => f instanceof TFolder) as TFolder[];
}
private loadFolders(): void {
const abstractFiles = this.app.vault.getAllLoadedFiles();
this.folders = abstractFiles.filter(f => f instanceof TFolder) as TFolder[];
}
private loadFileNames(): void {
const files = this.app.vault.getMarkdownFiles();
this.fileNames = files.map(f => f.name);
}
private loadFileNames(): void {
const files = this.app.vault.getMarkdownFiles();
this.fileNames = files.map(f => f.name);
}
private loadPropertyKeysAndValues(): void {
const files = this.app.vault.getMarkdownFiles();
files.forEach(file => {
const cachedMetadata = this.app.metadataCache.getFileCache(file);
if (cachedMetadata?.frontmatter) {
Object.entries(cachedMetadata.frontmatter).forEach(([key, value]) => {
// Skip Obsidian internal properties
if (!key.startsWith('position') && key !== 'tags') {
this.propertyKeys.add(key);
// Collect values for this property
if (!this.propertyValues.has(key)) {
this.propertyValues.set(key, new Set());
}
// Convert value to string and add to the set
if (value !== null && value !== undefined) {
const valueStr = String(value);
this.propertyValues.get(key)!.add(valueStr);
}
}
});
private loadPropertyKeysAndValues(): void {
const files = this.app.vault.getMarkdownFiles();
files.forEach(file => {
const cachedMetadata = this.app.metadataCache.getFileCache(file);
if (cachedMetadata?.frontmatter) {
Object.entries(cachedMetadata.frontmatter).forEach(([key, value]) => {
// Skip Obsidian internal properties
if (!key.startsWith('position') && key !== 'tags') {
this.propertyKeys.add(key);
// Collect values for this property
if (!this.propertyValues.has(key)) {
this.propertyValues.set(key, new Set());
}
// Convert value to string and add to the set
if (value !== null && value !== undefined) {
const valueStr = String(value);
this.propertyValues.get(key)!.add(valueStr);
}
}
});
}
});
}
getSuggestions(query: string): string[] {
// Check if a valid type is selected (at the beginning of the query)
const typeMatch = SUGGEST_TYPES.find(t => query.startsWith(t.label));
if (!typeMatch || query.trim() === '') {
this.selectedType = null;
// Typ-Suggestions anzeigen
return SUGGEST_TYPES.filter(t =>
t.label.toLowerCase().includes(query.toLowerCase())
).map(t => t.label);
} else {
this.selectedType = typeMatch.value;
// Special handling for property type with potential key:value structure
if (this.selectedType === 'property') {
return this.getPropertySuggestions(query, typeMatch.label);
}
// Type was selected, now provide specific suggestions
const q = query.replace(/^[^:]+:\s*/, '').toLowerCase();
switch (this.selectedType) {
case 'tag':
return Array.from(this.tags)
.filter(tag => tag.toLowerCase().includes(q))
.map(tag => `${typeMatch.label}${tag}`);
case 'fileName':
return this.fileNames
.filter(name => name.toLowerCase().includes(q))
.map(name => `${typeMatch.label}${name}`);
case 'path':
return this.folders
.map(f => f.path)
.filter(path => path.toLowerCase().includes(q))
.map(path => `${typeMatch.label}${path}`);
case 'content':
// Placeholder: Here we could suggest common words or similar
return [];
case 'created_at':
case 'updated_at':
// Placeholder: Here we could suggest date values
return [];
default:
return [];
}
}
}
private getPropertySuggestions(query: string, typeLabel: string): string[] {
// Remove "property: " prefix
const afterType = query.substring(typeLabel.length);
// Check if we already have a property key with colon (property:key:)
const colonIndex = afterType.indexOf(':');
if (colonIndex === -1) {
// No colon yet, suggest property keys
const q = afterType.toLowerCase();
return Array.from(this.propertyKeys)
.filter(key => key.toLowerCase().includes(q))
.map(key => `${typeLabel}${key}`);
} else {
// We have property:key:, now suggest values for this key
const propertyKey = afterType.substring(0, colonIndex);
const valueQuery = afterType.substring(colonIndex + 1).toLowerCase();
const valuesForKey = this.propertyValues.get(propertyKey);
if (valuesForKey) {
return Array.from(valuesForKey)
.filter(value => value.toLowerCase().includes(valueQuery))
.map(value => `${typeLabel}${propertyKey}:${value}`);
}
return [];
}
}
renderSuggestion(value: string, el: HTMLElement): void {
el.setText(value);
}
selectSuggestion(value: string): void {
// If a type was selected, set the field to the type and allow further suggestions
const typeMatch = SUGGEST_TYPES.find(t => value === t.label);
if (typeMatch) {
this.selectedType = typeMatch.value;
this.inputEl.value = value;
this.inputEl.trigger('input');
return;
}
getSuggestions(query: string): string[] {
// Check if a valid type is selected (at the beginning of the query)
const typeMatch = SUGGEST_TYPES.find(t => query.startsWith(t.label));
if (!typeMatch || query.trim() === "") {
this.selectedType = null;
// Typ-Suggestions anzeigen
return SUGGEST_TYPES
.filter(t => t.label.toLowerCase().includes(query.toLowerCase()))
.map(t => t.label);
} else {
this.selectedType = typeMatch.value;
// Special handling for property type with potential key:value structure
if (this.selectedType === "property") {
return this.getPropertySuggestions(query, typeMatch.label);
}
// Type was selected, now provide specific suggestions
const q = query.replace(/^[^:]+:\s*/, "").toLowerCase();
switch (this.selectedType) {
case "tag":
return Array.from(this.tags).filter(tag => tag.toLowerCase().includes(q)).map(tag => `${typeMatch.label}${tag}`);
case "fileName":
return this.fileNames.filter(name => name.toLowerCase().includes(q)).map(name => `${typeMatch.label}${name}`);
case "path":
return this.folders.map(f => f.path).filter(path => path.toLowerCase().includes(q)).map(path => `${typeMatch.label}${path}`);
case "content":
// Placeholder: Here we could suggest common words or similar
return [];
case "created_at":
case "updated_at":
// Placeholder: Here we could suggest date values
return [];
default:
return [];
}
}
}
// Special handling for property suggestions
if (this.selectedType === 'property' && value.startsWith('property: ')) {
const afterType = value.substring('property: '.length);
const colonCount = (afterType.match(/:/g) || []).length;
private getPropertySuggestions(query: string, typeLabel: string): string[] {
// Remove "property: " prefix
const afterType = query.substring(typeLabel.length);
// Check if we already have a property key with colon (property:key:)
const colonIndex = afterType.indexOf(':');
if (colonIndex === -1) {
// No colon yet, suggest property keys
const q = afterType.toLowerCase();
return Array.from(this.propertyKeys)
.filter(key => key.toLowerCase().includes(q))
.map(key => `${typeLabel}${key}`);
} else {
// We have property:key:, now suggest values for this key
const propertyKey = afterType.substring(0, colonIndex);
const valueQuery = afterType.substring(colonIndex + 1).toLowerCase();
const valuesForKey = this.propertyValues.get(propertyKey);
if (valuesForKey) {
return Array.from(valuesForKey)
.filter(value => value.toLowerCase().includes(valueQuery))
.map(value => `${typeLabel}${propertyKey}:${value}`);
}
return [];
}
}
renderSuggestion(value: string, el: HTMLElement): void {
el.setText(value);
}
selectSuggestion(value: string): void {
// If a type was selected, set the field to the type and allow further suggestions
const typeMatch = SUGGEST_TYPES.find(t => value === t.label);
if (typeMatch) {
this.selectedType = typeMatch.value;
this.inputEl.value = value;
this.inputEl.trigger("input");
return;
}
// Special handling for property suggestions
if (this.selectedType === "property" && value.startsWith("property: ")) {
const afterType = value.substring("property: ".length);
const colonCount = (afterType.match(/:/g) || []).length;
if (colonCount === 0) {
// This is a property key, allow further input for values
this.inputEl.value = value + ":";
this.inputEl.trigger("input");
return;
} else {
// This is a complete property:key:value, finalize selection
this.inputEl.value = value;
this.inputEl.trigger("input");
this.close();
return;
}
}
// Default handling for other types
const currentType = this.selectedType ? SUGGEST_TYPES.find(t => t.value === this.selectedType) : null;
if (currentType && !value.startsWith(currentType.label)) {
this.inputEl.value = `${currentType.label}${value}`;
} else {
this.inputEl.value = value;
}
// Important: trigger input event so onChange in Settings is called
this.inputEl.trigger("input");
if (colonCount === 0) {
// This is a property key, allow further input for values
this.inputEl.value = value + ':';
this.inputEl.trigger('input');
return;
} else {
// This is a complete property:key:value, finalize selection
this.inputEl.value = value;
this.inputEl.trigger('input');
this.close();
return;
}
}
}
// Default handling for other types
const currentType = this.selectedType
? SUGGEST_TYPES.find(t => t.value === this.selectedType)
: null;
if (currentType && !value.startsWith(currentType.label)) {
this.inputEl.value = `${currentType.label}${value}`;
} else {
this.inputEl.value = value;
}
// Important: trigger input event so onChange in Settings is called
this.inputEl.trigger('input');
this.close();
}
}

View file

@ -1,36 +1,41 @@
import { AbstractInputSuggest, App, TAbstractFile, TFolder } from "obsidian";
import { GENERAL_CONSTANTS } from "../../config/constants";
import { AbstractInputSuggest, App, TAbstractFile, TFolder } from 'obsidian';
import { GENERAL_CONSTANTS } from '../../config/constants';
export class FolderSuggest extends AbstractInputSuggest<TFolder> {
constructor(
app: App,
private inputEl: HTMLInputElement
) {
super(app, inputEl);
}
constructor(app: App, private inputEl: HTMLInputElement) {
super(app, inputEl);
}
protected getSuggestions(query: string): TFolder[] | Promise<TFolder[]> {
const abstractFiles = this.app.vault.getAllLoadedFiles();
const folders: TFolder[] = [];
const lowerCaseInputStr = query.toLowerCase();
protected getSuggestions(query: string): TFolder[] | Promise<TFolder[]> {
const abstractFiles = this.app.vault.getAllLoadedFiles();
const folders: TFolder[] = [];
const lowerCaseInputStr = query.toLowerCase();
abstractFiles.forEach((folder: TAbstractFile) => {
if (
folder instanceof TFolder &&
folder.path.toLowerCase().contains(lowerCaseInputStr)
) {
folders.push(folder);
}
});
abstractFiles.forEach((folder: TAbstractFile) => {
if (
folder instanceof TFolder &&
folder.path.toLowerCase().contains(lowerCaseInputStr)
) {
folders.push(folder);
}
});
return folders.slice(
0,
GENERAL_CONSTANTS.SUGGESTION_LIMITS.FOLDER_SUGGESTIONS
);
}
return folders.slice(0, GENERAL_CONSTANTS.SUGGESTION_LIMITS.FOLDER_SUGGESTIONS);
}
renderSuggestion(value: TFolder, el: HTMLElement): void {
el.setText(value.path);
}
renderSuggestion(value: TFolder, el: HTMLElement): void {
el.setText(value.path);
}
selectSuggestion(value: TFolder): void {
this.setValue(value.path);
this.inputEl.trigger("input");
this.close();
}
selectSuggestion(value: TFolder): void {
this.setValue(value.path);
this.inputEl.trigger('input');
this.close();
}
}

View file

@ -1,33 +1,38 @@
import { App, AbstractInputSuggest } from "obsidian";
import { MetadataExtractor } from "../../core/MetadataExtractor";
import { App, AbstractInputSuggest } from 'obsidian';
import { MetadataExtractor } from '../../core/MetadataExtractor';
export class TagSuggest extends AbstractInputSuggest<string> {
private tags: Set<string>;
private metadataExtractor: MetadataExtractor;
private tags: Set<string>;
private metadataExtractor: MetadataExtractor;
constructor(app: App, private inputEl: HTMLInputElement) {
super(app, inputEl);
this.tags = new Set();
this.metadataExtractor = new MetadataExtractor(app);
this.loadTags();
}
private loadTags(): void {
this.tags = this.metadataExtractor.extractAllTags();
}
constructor(
app: App,
private inputEl: HTMLInputElement
) {
super(app, inputEl);
this.tags = new Set();
this.metadataExtractor = new MetadataExtractor(app);
this.loadTags();
}
getSuggestions(inputStr: string): string[] {
const lowerInput = inputStr.toLowerCase();
return Array.from(this.tags).filter(tag => tag.toLowerCase().includes(lowerInput));
}
private loadTags(): void {
this.tags = this.metadataExtractor.extractAllTags();
}
renderSuggestion(value: string, el: HTMLElement): void {
el.setText(value);
}
getSuggestions(inputStr: string): string[] {
const lowerInput = inputStr.toLowerCase();
return Array.from(this.tags).filter(tag =>
tag.toLowerCase().includes(lowerInput)
);
}
selectSuggestion(value: string): void {
this.inputEl.value = value;
this.inputEl.trigger("input");
this.close();
}
renderSuggestion(value: string, el: HTMLElement): void {
el.setText(value);
}
selectSuggestion(value: string): void {
this.inputEl.value = value;
this.inputEl.trigger('input');
this.close();
}
}

View file

@ -2,7 +2,12 @@
* Common type definitions used throughout the application
*/
export type NotificationType = 'info' | 'error' | 'update' | 'success' | 'warning';
export type NotificationType =
| 'info'
| 'error'
| 'update'
| 'success'
| 'warning';
export type OperationType = 'single' | 'bulk' | 'periodic';
@ -10,11 +15,11 @@ export type OperationType = 'single' | 'bulk' | 'periodic';
* Complete metadata extracted from a file for rule matching and processing
*/
export interface FileMetadata {
fileName: string;
filePath: string;
tags: string[];
properties: Record<string, any>;
fileContent: string;
createdAt: Date | null;
updatedAt: Date | null;
fileName: string;
filePath: string;
tags: string[];
properties: Record<string, any>;
fileContent: string;
createdAt: Date | null;
updatedAt: Date | null;
}

View file

@ -1,19 +1,19 @@
export interface HistoryEntry {
id: string;
sourcePath: string;
destinationPath: string;
timestamp: number;
fileName: string;
bulkOperationId?: string; // Groups entries that belong to the same bulk operation
operationType?: 'single' | 'bulk' | 'periodic'; // Type of operation
id: string;
sourcePath: string;
destinationPath: string;
timestamp: number;
fileName: string;
bulkOperationId?: string; // Groups entries that belong to the same bulk operation
operationType?: 'single' | 'bulk' | 'periodic'; // Type of operation
}
export interface BulkOperation {
id: string;
operationType: 'bulk' | 'periodic';
timestamp: number;
entries: HistoryEntry[];
totalFiles: number;
id: string;
operationType: 'bulk' | 'periodic';
timestamp: number;
entries: HistoryEntry[];
totalFiles: number;
}
// Time filter options for history modal
@ -21,6 +21,6 @@ export type TimeFilter = 'all' | 'today' | 'week' | 'month';
// Retention policy settings
export interface RetentionPolicy {
value: number;
unit: 'days' | 'weeks' | 'months';
}
value: number;
unit: 'days' | 'weeks' | 'months';
}

View file

@ -1,29 +1,29 @@
export interface PreviewEntry {
/** The file being analyzed */
fileName: string;
/** Current path of the file */
currentPath: string;
/** Target destination path if move would succeed */
targetPath: string | null;
/** Whether the file would be moved successfully */
willBeMoved: boolean;
/** The rule that would be applied (if any) */
matchedRule?: string;
/** The reason why the file is blocked (if blocked) */
blockReason?: string;
/** The filter that blocked the move (if any) */
blockingFilter?: string;
/** File tags for display */
tags: string[];
/** The file being analyzed */
fileName: string;
/** Current path of the file */
currentPath: string;
/** Target destination path if move would succeed */
targetPath: string | null;
/** Whether the file would be moved successfully */
willBeMoved: boolean;
/** The rule that would be applied (if any) */
matchedRule?: string;
/** The reason why the file is blocked (if blocked) */
blockReason?: string;
/** The filter that blocked the move (if any) */
blockingFilter?: string;
/** File tags for display */
tags: string[];
}
export interface MovePreview {
/** Files that would be successfully moved */
successfulMoves: PreviewEntry[];
/** Total number of files analyzed */
totalFiles: number;
/** Settings used for this preview */
settings: {
isFilterWhitelist: boolean;
};
/** Files that would be successfully moved */
successfulMoves: PreviewEntry[];
/** Total number of files analyzed */
totalFiles: number;
/** Settings used for this preview */
settings: {
isFilterWhitelist: boolean;
};
}

View file

@ -1,4 +1,4 @@
export interface Rule {
criteria: string; // Format: "type: value" (z.B. "tag: #project", "fileName: notes.md", "path: documents/")
path: string;
}
criteria: string; // Format: "type: value" (z.B. "tag: #project", "fileName: notes.md", "path: documents/")
path: string;
}

View file

@ -2,11 +2,11 @@ import type { HistoryEntry } from '../HistoryEntry';
// Valid example
const validEntry: HistoryEntry = {
id: '1',
sourcePath: '/source/path',
destinationPath: '/destination/path',
timestamp: Date.now(),
fileName: 'note.md',
id: '1',
sourcePath: '/source/path',
destinationPath: '/destination/path',
timestamp: Date.now(),
fileName: 'note.md',
};
// Invalid examples (these lines should trigger TypeScript errors when uncommented)
@ -24,4 +24,4 @@ const validEntry: HistoryEntry = {
// destinationPath: '/destination/path',
// timestamp: 'not-a-number', // falscher Typ
// fileName: 'note.md',
// };
// };

View file

@ -1,7 +1,7 @@
// Simplified error handling utilities
// Credits go to SilentVoid13 Templater Plugin: https://github.com/SilentVoid13/Templater
import { NoticeManager } from "./NoticeManager";
import { NoticeManager } from './NoticeManager';
/**
* Creates a standardized error with context information
@ -10,9 +10,9 @@ import { NoticeManager } from "./NoticeManager";
* @returns A new Error instance with enhanced message
*/
export function createError(message: string, context?: string): Error {
const fullMessage = context ? `${context}: ${message}` : message;
const error = new Error(fullMessage);
return error;
const fullMessage = context ? `${context}: ${message}` : message;
const error = new Error(fullMessage);
return error;
}
/**
@ -21,17 +21,22 @@ export function createError(message: string, context?: string): Error {
* @param context - Additional context about where the error occurred
* @param shouldThrow - Whether to re-throw the error after logging
*/
export function handleError(error: unknown, context?: string, shouldThrow: boolean = true): void {
const errorMessage = error instanceof Error
? error.message
: (typeof error === 'object' && error !== null
? JSON.stringify(error)
: String(error));
const fullMessage = context ? `${context}: ${errorMessage}` : errorMessage;
NoticeManager.error(fullMessage);
if (shouldThrow) {
throw error instanceof Error ? error : new Error(errorMessage);
}
}
export function handleError(
error: unknown,
context?: string,
shouldThrow = true
): void {
const errorMessage =
error instanceof Error
? error.message
: typeof error === 'object' && error !== null
? JSON.stringify(error)
: String(error);
const fullMessage = context ? `${context}: ${errorMessage}` : errorMessage;
NoticeManager.error(fullMessage);
if (shouldThrow) {
throw error instanceof Error ? error : new Error(errorMessage);
}
}

View file

@ -1,124 +1,130 @@
import { Notice } from "obsidian";
import { NOTIFICATION_CONSTANTS } from "../config/constants";
import { type NotificationType } from "../types/Common";
import { Notice } from 'obsidian';
import { NOTIFICATION_CONSTANTS } from '../config/constants';
import { type NotificationType } from '../types/Common';
export interface NoticeOptions {
duration?: number;
showUndoButton?: boolean;
onUndo?: () => void;
undoText?: string;
duration?: number;
showUndoButton?: boolean;
onUndo?: () => void;
undoText?: string;
}
export class NoticeManager {
/**
* Shows a notice with the specified type and message
* @param type - The type of notice to show
* @param message - The message to display
* @param options - Additional options for the notice
*/
public static show(
type: NotificationType,
message: string,
options: NoticeOptions = {}
): Notice {
const duration =
options.duration ?? NOTIFICATION_CONSTANTS.DEFAULT_DURATIONS[type];
const notice = new Notice('', duration);
const noticeEl = notice.noticeEl;
/**
* Shows a notice with the specified type and message
* @param type - The type of notice to show
* @param message - The message to display
* @param options - Additional options for the notice
*/
public static show(type: NotificationType, message: string, options: NoticeOptions = {}): Notice {
const duration = options.duration ?? NOTIFICATION_CONSTANTS.DEFAULT_DURATIONS[type];
const notice = new Notice("", duration);
const noticeEl = notice.noticeEl;
// Add title
const title = document.createElement('b');
title.textContent = NOTIFICATION_CONSTANTS.DEFAULT_TITLES[type];
noticeEl.appendChild(title);
noticeEl.appendChild(document.createElement('br'));
// Add title
const title = document.createElement("b");
title.textContent = NOTIFICATION_CONSTANTS.DEFAULT_TITLES[type];
noticeEl.appendChild(title);
noticeEl.appendChild(document.createElement("br"));
// Add message
const messageEl = document.createElement("span");
messageEl.textContent = message;
noticeEl.appendChild(messageEl);
// Add undo button if requested
if (options.showUndoButton && options.onUndo) {
const undoButton = document.createElement("button");
undoButton.textContent = options.undoText ?? "Undo";
undoButton.className = "mod-warning notice-undo-button";
undoButton.onclick = () => {
try {
options.onUndo!();
notice.hide();
} catch (error) {
console.error('Error in undo button click handler:', error);
this.show('error', `Error undoing action: ${error instanceof Error ? error.message : String(error)}`);
}
};
noticeEl.appendChild(undoButton);
// Add message
const messageEl = document.createElement('span');
messageEl.textContent = message;
noticeEl.appendChild(messageEl);
// Add undo button if requested
if (options.showUndoButton && options.onUndo) {
const undoButton = document.createElement('button');
undoButton.textContent = options.undoText ?? 'Undo';
undoButton.className = 'mod-warning notice-undo-button';
undoButton.onclick = () => {
try {
options.onUndo!();
notice.hide();
} catch (error) {
console.error('Error in undo button click handler:', error);
this.show(
'error',
`Error undoing action: ${error instanceof Error ? error.message : String(error)}`
);
}
return notice;
};
noticeEl.appendChild(undoButton);
}
/**
* Shows an info notice
* @param message - The message to display
* @param options - Additional options
*/
public static info(message: string, options?: NoticeOptions): Notice {
return this.show('info', message, options);
}
return notice;
}
/**
* Shows an error notice
* @param message - The message to display
* @param options - Additional options
*/
public static error(message: string, options?: NoticeOptions): Notice {
return this.show('error', message, options);
}
/**
* Shows an info notice
* @param message - The message to display
* @param options - Additional options
*/
public static info(message: string, options?: NoticeOptions): Notice {
return this.show('info', message, options);
}
/**
* Shows an update notice
* @param message - The message to display
* @param options - Additional options
*/
public static update(message: string, options?: NoticeOptions): Notice {
return this.show('update', message, options);
}
/**
* Shows an error notice
* @param message - The message to display
* @param options - Additional options
*/
public static error(message: string, options?: NoticeOptions): Notice {
return this.show('error', message, options);
}
/**
* Shows a success notice
* @param message - The message to display
* @param options - Additional options
*/
public static success(message: string, options?: NoticeOptions): Notice {
return this.show('success', message, options);
}
/**
* Shows an update notice
* @param message - The message to display
* @param options - Additional options
*/
public static update(message: string, options?: NoticeOptions): Notice {
return this.show('update', message, options);
}
/**
* Shows a warning notice
* @param message - The message to display
* @param options - Additional options
*/
public static warning(message: string, options?: NoticeOptions): Notice {
return this.show('warning', message, options);
}
/**
* Shows a success notice
* @param message - The message to display
* @param options - Additional options
*/
public static success(message: string, options?: NoticeOptions): Notice {
return this.show('success', message, options);
}
/**
* Shows a notice with undo functionality
* @param type - The type of notice
* @param message - The message to display
* @param onUndo - The function to call when undo is clicked
* @param undoText - The text for the undo button
* @param options - Additional options
*/
public static showWithUndo(
type: NotificationType,
message: string,
onUndo: () => void,
undoText?: string,
options?: NoticeOptions
): Notice {
return this.show(type, message, {
...options,
showUndoButton: true,
onUndo,
undoText
});
}
/**
* Shows a warning notice
* @param message - The message to display
* @param options - Additional options
*/
public static warning(message: string, options?: NoticeOptions): Notice {
return this.show('warning', message, options);
}
/**
* Shows a notice with undo functionality
* @param type - The type of notice
* @param message - The message to display
* @param onUndo - The function to call when undo is clicked
* @param undoText - The text for the undo button
* @param options - Additional options
*/
public static showWithUndo(
type: NotificationType,
message: string,
onUndo: () => void,
undoText?: string,
options?: NoticeOptions
): Notice {
return this.show(type, message, {
...options,
showUndoButton: true,
onUndo,
undoText,
});
}
}

View file

@ -5,45 +5,45 @@ import { App } from 'obsidian';
* and ensuring the path is relative to vault root (no leading slash)
*/
export function formatPath(path: string): string {
// Handle empty path
if (!path || path === '/') {
return '';
}
// Handle empty path
if (!path || path === '/') {
return '';
}
// Remove double slashes first
path = path.replace(/\/+/g, '/');
// Remove double slashes first
path = path.replace(/\/+/g, '/');
// Remove trailing slash
if (path.endsWith('/')) {
path = path.slice(0, -1);
}
// Remove trailing slash
if (path.endsWith('/')) {
path = path.slice(0, -1);
}
// Remove leading slash to make path relative to vault root
if (path.startsWith('/')) {
path = path.slice(1);
}
// Remove leading slash to make path relative to vault root
if (path.startsWith('/')) {
path = path.slice(1);
}
return path;
return path;
}
/**
* Combines a folder path with a filename
*/
export function combinePath(folder: string, file: string): string {
// If the folder is empty or root path, return just the filename
if (!folder || folder === '/' || folder === '') {
return file;
}
// If the folder is empty or root path, return just the filename
if (!folder || folder === '/' || folder === '') {
return file;
}
// Format the folder first to ensure no leading/trailing slashes
const formattedFolder = formatPath(folder);
// If folder is empty after formatting, return just the filename
if (!formattedFolder) {
return file;
}
// Format the folder first to ensure no leading/trailing slashes
const formattedFolder = formatPath(folder);
return formattedFolder + '/' + file;
// If folder is empty after formatting, return just the filename
if (!formattedFolder) {
return file;
}
return formattedFolder + '/' + file;
}
/**
@ -52,17 +52,17 @@ export function combinePath(folder: string, file: string): string {
* @returns The parent directory path, or empty string if no parent
*/
export function getParentPath(fullPath: string): string {
if (!fullPath) {
return '';
}
if (!fullPath) {
return '';
}
const lastSlashIndex = fullPath.lastIndexOf('/');
if (lastSlashIndex === -1) {
// No slash found, file is in root directory
return '';
}
const lastSlashIndex = fullPath.lastIndexOf('/');
if (lastSlashIndex === -1) {
// No slash found, file is in root directory
return '';
}
return fullPath.substring(0, lastSlashIndex);
return fullPath.substring(0, lastSlashIndex);
}
/**
@ -71,30 +71,33 @@ export function getParentPath(fullPath: string): string {
* @param folderPath - The path of the folder to ensure exists
* @returns Promise<boolean> - true if folder exists or was created successfully, false on error
*/
export async function ensureFolderExists(app: App, folderPath: string): Promise<boolean> {
// Handle empty or root paths
if (!folderPath || folderPath === '/' || folderPath === '') {
return true; // Root always exists
export async function ensureFolderExists(
app: App,
folderPath: string
): Promise<boolean> {
// Handle empty or root paths
if (!folderPath || folderPath === '/' || folderPath === '') {
return true; // Root always exists
}
// Format the path to ensure consistency
const formattedPath = formatPath(folderPath);
if (!formattedPath) {
return true; // Root path after formatting
}
try {
// Check if folder already exists
const exists = await app.vault.adapter.exists(formattedPath);
if (exists) {
return true;
}
// Format the path to ensure consistency
const formattedPath = formatPath(folderPath);
if (!formattedPath) {
return true; // Root path after formatting
}
try {
// Check if folder already exists
const exists = await app.vault.adapter.exists(formattedPath);
if (exists) {
return true;
}
// Create the folder
await app.vault.createFolder(formattedPath);
return true;
} catch (error) {
console.error(`Failed to create folder ${formattedPath}:`, error);
return false;
}
}
// Create the folder
await app.vault.createFolder(formattedPath);
return true;
} catch (error) {
console.error(`Failed to create folder ${formattedPath}:`, error);
return false;
}
}

View file

@ -2,306 +2,359 @@ import { NoteMoverShortcutSettings } from '../settings/Settings';
import { HistoryEntry, BulkOperation } from '../types/HistoryEntry';
export interface ValidationResult {
isValid: boolean;
errors: string[];
warnings: string[];
isValid: boolean;
errors: string[];
warnings: string[];
}
export interface Rule {
criteria: string;
path: string;
criteria: string;
path: string;
}
export class SettingsValidator {
/**
* Validates a complete settings object
*/
static validateSettings(settings: any): ValidationResult {
const result: ValidationResult = {
isValid: true,
errors: [],
warnings: []
};
/**
* Validates a complete settings object
*/
static validateSettings(settings: any): ValidationResult {
const result: ValidationResult = {
isValid: true,
errors: [],
warnings: [],
};
// Check if settings is an object
if (!settings || typeof settings !== 'object') {
result.errors.push('Settings must be a valid object');
result.isValid = false;
return result;
}
// Validate required string fields
const requiredStringFields = ['destination', 'inboxLocation'];
for (const field of requiredStringFields) {
if (!this.validateStringField(settings[field], field, result)) {
result.isValid = false;
}
}
// Validate boolean fields
const booleanFields = [
'enablePeriodicMovement',
'enableOnEditTrigger',
'enableFilter',
'isFilterWhitelist',
'enableRules',
'onlyMoveNotesWithRules'
];
for (const field of booleanFields) {
if (!this.validateBooleanField(settings[field], field, result)) {
result.isValid = false;
}
}
// Validate periodicMovementInterval
if (!this.validateIntervalField(settings.periodicMovementInterval, result)) {
result.isValid = false;
}
// Validate filter array
if (!this.validateFilterArray(settings.filter, result)) {
result.isValid = false;
}
// Validate rules array
if (!this.validateRulesArray(settings.rules, result)) {
result.isValid = false;
}
// Validate optional history array
if (settings.history !== undefined) {
if (!this.validateHistoryArray(settings.history, result)) {
result.warnings.push('History data is invalid and will be ignored');
}
}
// Validate optional bulkOperations array
if (settings.bulkOperations !== undefined) {
if (!this.validateBulkOperationsArray(settings.bulkOperations, result)) {
result.warnings.push('Bulk operations data is invalid and will be ignored');
}
}
// Validate optional lastSeenVersion
if (settings.lastSeenVersion !== undefined) {
if (!this.validateStringField(settings.lastSeenVersion, 'lastSeenVersion', result, false)) {
result.warnings.push('Last seen version is invalid and will be ignored');
}
}
return result;
// Check if settings is an object
if (!settings || typeof settings !== 'object') {
result.errors.push('Settings must be a valid object');
result.isValid = false;
return result;
}
/**
* Validates a string field
*/
private static validateStringField(value: any, fieldName: string, result: ValidationResult, required: boolean = true): boolean {
if (required && (value === undefined || value === null)) {
result.errors.push(`Field '${fieldName}' is required`);
return false;
}
if (value !== undefined && value !== null && typeof value !== 'string') {
result.errors.push(`Field '${fieldName}' must be a string`);
return false;
}
return true;
// Validate required string fields
const requiredStringFields = ['destination', 'inboxLocation'];
for (const field of requiredStringFields) {
if (!this.validateStringField(settings[field], field, result)) {
result.isValid = false;
}
}
/**
* Validates a boolean field
*/
private static validateBooleanField(value: any, fieldName: string, result: ValidationResult): boolean {
if (value === undefined || value === null) {
result.errors.push(`Field '${fieldName}' is required`);
return false;
}
if (typeof value !== 'boolean') {
result.errors.push(`Field '${fieldName}' must be a boolean`);
return false;
}
return true;
// Validate boolean fields
const booleanFields = [
'enablePeriodicMovement',
'enableOnEditTrigger',
'enableFilter',
'isFilterWhitelist',
'enableRules',
'onlyMoveNotesWithRules',
];
for (const field of booleanFields) {
if (!this.validateBooleanField(settings[field], field, result)) {
result.isValid = false;
}
}
/**
* Validates the periodic movement interval
*/
private static validateIntervalField(value: any, result: ValidationResult): boolean {
if (value === undefined || value === null) {
result.errors.push('Field "periodicMovementInterval" is required');
return false;
}
if (typeof value !== 'number' || !Number.isInteger(value)) {
result.errors.push('Field "periodicMovementInterval" must be an integer');
return false;
}
if (value < 1) {
result.errors.push('Field "periodicMovementInterval" must be greater than 0');
return false;
}
return true;
// Validate periodicMovementInterval
if (
!this.validateIntervalField(settings.periodicMovementInterval, result)
) {
result.isValid = false;
}
/**
* Validates the filter array
*/
private static validateFilterArray(value: any, result: ValidationResult): boolean {
if (value === undefined || value === null) {
result.errors.push('Field "filter" is required');
return false;
}
if (!Array.isArray(value)) {
result.errors.push('Field "filter" must be an array');
return false;
}
for (let i = 0; i < value.length; i++) {
if (typeof value[i] !== 'string') {
result.errors.push(`Filter item at index ${i} must be a string`);
return false;
}
}
return true;
// Validate filter array
if (!this.validateFilterArray(settings.filter, result)) {
result.isValid = false;
}
/**
* Validates the rules array
*/
private static validateRulesArray(value: any, result: ValidationResult): boolean {
if (value === undefined || value === null) {
result.errors.push('Field "rules" is required');
return false;
}
if (!Array.isArray(value)) {
result.errors.push('Field "rules" must be an array');
return false;
}
for (let i = 0; i < value.length; i++) {
if (!this.validateRule(value[i], i, result)) {
return false;
}
}
return true;
// Validate rules array
if (!this.validateRulesArray(settings.rules, result)) {
result.isValid = false;
}
/**
* Validates a single rule
*/
private static validateRule(rule: any, index: number, result: ValidationResult): boolean {
if (!rule || typeof rule !== 'object') {
result.errors.push(`Rule at index ${index} must be an object`);
return false;
}
if (!this.validateStringField(rule.criteria, `rules[${index}].criteria`, result)) {
return false;
}
if (!this.validateStringField(rule.path, `rules[${index}].path`, result)) {
return false;
}
return true;
// Validate optional history array
if (settings.history !== undefined) {
if (!this.validateHistoryArray(settings.history, result)) {
result.warnings.push('History data is invalid and will be ignored');
}
}
/**
* Validates the history array (optional)
*/
private static validateHistoryArray(value: any, result: ValidationResult): boolean {
if (!Array.isArray(value)) {
return false;
}
for (let i = 0; i < value.length; i++) {
const entry = value[i];
if (!entry || typeof entry !== 'object') {
return false;
}
// Check required fields for history entry
if (typeof entry.id !== 'string' ||
typeof entry.timestamp !== 'number' ||
typeof entry.sourcePath !== 'string' ||
typeof entry.destinationPath !== 'string') {
return false;
}
}
return true;
// Validate optional bulkOperations array
if (settings.bulkOperations !== undefined) {
if (!this.validateBulkOperationsArray(settings.bulkOperations, result)) {
result.warnings.push(
'Bulk operations data is invalid and will be ignored'
);
}
}
/**
* Validates the bulk operations array (optional)
*/
private static validateBulkOperationsArray(value: any, result: ValidationResult): boolean {
if (!Array.isArray(value)) {
return false;
}
for (let i = 0; i < value.length; i++) {
const operation = value[i];
if (!operation || typeof operation !== 'object') {
return false;
}
// Check required fields for bulk operation
if (typeof operation.id !== 'string' ||
typeof operation.timestamp !== 'number' ||
!Array.isArray(operation.entries)) {
return false;
}
}
return true;
// Validate optional lastSeenVersion
if (settings.lastSeenVersion !== undefined) {
if (
!this.validateStringField(
settings.lastSeenVersion,
'lastSeenVersion',
result,
false
)
) {
result.warnings.push(
'Last seen version is invalid and will be ignored'
);
}
}
/**
* Creates a clean settings object with only valid fields
*/
static sanitizeSettings(settings: any): Partial<NoteMoverShortcutSettings> {
const sanitized: any = {};
return result;
}
// Copy valid fields
const validFields = [
'destination',
'inboxLocation',
'enablePeriodicMovement',
'periodicMovementInterval',
'enableFilter',
'filter',
'isFilterWhitelist',
'enableRules',
'rules',
'onlyMoveNotesWithRules',
'lastSeenVersion'
];
for (const field of validFields) {
if (settings[field] !== undefined) {
sanitized[field] = settings[field];
}
}
// Only include history and bulkOperations if they are valid arrays
if (Array.isArray(settings.history)) {
sanitized.history = settings.history;
}
if (Array.isArray(settings.bulkOperations)) {
sanitized.bulkOperations = settings.bulkOperations;
}
return sanitized;
/**
* Validates a string field
*/
private static validateStringField(
value: any,
fieldName: string,
result: ValidationResult,
required = true
): boolean {
if (required && (value === undefined || value === null)) {
result.errors.push(`Field '${fieldName}' is required`);
return false;
}
if (value !== undefined && value !== null && typeof value !== 'string') {
result.errors.push(`Field '${fieldName}' must be a string`);
return false;
}
return true;
}
/**
* Validates a boolean field
*/
private static validateBooleanField(
value: any,
fieldName: string,
result: ValidationResult
): boolean {
if (value === undefined || value === null) {
result.errors.push(`Field '${fieldName}' is required`);
return false;
}
if (typeof value !== 'boolean') {
result.errors.push(`Field '${fieldName}' must be a boolean`);
return false;
}
return true;
}
/**
* Validates the periodic movement interval
*/
private static validateIntervalField(
value: any,
result: ValidationResult
): boolean {
if (value === undefined || value === null) {
result.errors.push('Field "periodicMovementInterval" is required');
return false;
}
if (typeof value !== 'number' || !Number.isInteger(value)) {
result.errors.push('Field "periodicMovementInterval" must be an integer');
return false;
}
if (value < 1) {
result.errors.push(
'Field "periodicMovementInterval" must be greater than 0'
);
return false;
}
return true;
}
/**
* Validates the filter array
*/
private static validateFilterArray(
value: any,
result: ValidationResult
): boolean {
if (value === undefined || value === null) {
result.errors.push('Field "filter" is required');
return false;
}
if (!Array.isArray(value)) {
result.errors.push('Field "filter" must be an array');
return false;
}
for (let i = 0; i < value.length; i++) {
if (typeof value[i] !== 'string') {
result.errors.push(`Filter item at index ${i} must be a string`);
return false;
}
}
return true;
}
/**
* Validates the rules array
*/
private static validateRulesArray(
value: any,
result: ValidationResult
): boolean {
if (value === undefined || value === null) {
result.errors.push('Field "rules" is required');
return false;
}
if (!Array.isArray(value)) {
result.errors.push('Field "rules" must be an array');
return false;
}
for (let i = 0; i < value.length; i++) {
if (!this.validateRule(value[i], i, result)) {
return false;
}
}
return true;
}
/**
* Validates a single rule
*/
private static validateRule(
rule: any,
index: number,
result: ValidationResult
): boolean {
if (!rule || typeof rule !== 'object') {
result.errors.push(`Rule at index ${index} must be an object`);
return false;
}
if (
!this.validateStringField(
rule.criteria,
`rules[${index}].criteria`,
result
)
) {
return false;
}
if (!this.validateStringField(rule.path, `rules[${index}].path`, result)) {
return false;
}
return true;
}
/**
* Validates the history array (optional)
*/
private static validateHistoryArray(
value: any,
result: ValidationResult
): boolean {
if (!Array.isArray(value)) {
return false;
}
for (let i = 0; i < value.length; i++) {
const entry = value[i];
if (!entry || typeof entry !== 'object') {
return false;
}
// Check required fields for history entry
if (
typeof entry.id !== 'string' ||
typeof entry.timestamp !== 'number' ||
typeof entry.sourcePath !== 'string' ||
typeof entry.destinationPath !== 'string'
) {
return false;
}
}
return true;
}
/**
* Validates the bulk operations array (optional)
*/
private static validateBulkOperationsArray(
value: any,
result: ValidationResult
): boolean {
if (!Array.isArray(value)) {
return false;
}
for (let i = 0; i < value.length; i++) {
const operation = value[i];
if (!operation || typeof operation !== 'object') {
return false;
}
// Check required fields for bulk operation
if (
typeof operation.id !== 'string' ||
typeof operation.timestamp !== 'number' ||
!Array.isArray(operation.entries)
) {
return false;
}
}
return true;
}
/**
* Creates a clean settings object with only valid fields
*/
static sanitizeSettings(settings: any): Partial<NoteMoverShortcutSettings> {
const sanitized: any = {};
// Copy valid fields
const validFields = [
'destination',
'inboxLocation',
'enablePeriodicMovement',
'periodicMovementInterval',
'enableFilter',
'filter',
'isFilterWhitelist',
'enableRules',
'rules',
'onlyMoveNotesWithRules',
'lastSeenVersion',
];
for (const field of validFields) {
if (settings[field] !== undefined) {
sanitized[field] = settings[field];
}
}
// Only include history and bulkOperations if they are valid arrays
if (Array.isArray(settings.history)) {
sanitized.history = settings.history;
}
if (Array.isArray(settings.bulkOperations)) {
sanitized.bulkOperations = settings.bulkOperations;
}
return sanitized;
}
}

View file

@ -3,77 +3,93 @@ import { NoticeManager } from '../NoticeManager';
// Mock for the NoticeManager function
jest.mock('../NoticeManager', () => ({
NoticeManager: {
error: jest.fn()
}
NoticeManager: {
error: jest.fn(),
},
}));
describe('Error', () => {
beforeEach(() => {
jest.clearAllMocks();
beforeEach(() => {
jest.clearAllMocks();
});
describe('createError', () => {
it('should create an error with message only', () => {
const error = createError('Test Error');
expect(error.message).toBe('Test Error');
expect(error).toBeInstanceOf(Error);
});
describe('createError', () => {
it('should create an error with message only', () => {
const error = createError('Test Error');
expect(error.message).toBe('Test Error');
expect(error).toBeInstanceOf(Error);
});
it('should create an error with message and context', () => {
const error = createError('Test Error', 'Test Context');
expect(error.message).toBe('Test Context: Test Error');
expect(error).toBeInstanceOf(Error);
});
it('should create an error with empty message', () => {
const error = createError('');
expect(error.message).toBe('');
expect(error).toBeInstanceOf(Error);
});
it('should create an error with undefined context', () => {
const error = createError('Test Error', undefined);
expect(error.message).toBe('Test Error');
expect(error).toBeInstanceOf(Error);
});
it('should create an error with message and context', () => {
const error = createError('Test Error', 'Test Context');
expect(error.message).toBe('Test Context: Test Error');
expect(error).toBeInstanceOf(Error);
});
describe('handleError', () => {
it('should log and re-throw error by default', () => {
const error = new Error('Test Error');
expect(() => handleError(error, 'Test Context')).toThrow('Test Error');
expect(NoticeManager.error).toHaveBeenCalledWith('Test Context: Test Error');
});
it('should log but not re-throw when shouldThrow is false', () => {
const error = new Error('Test Error');
expect(() => handleError(error, 'Test Context', false)).not.toThrow();
expect(NoticeManager.error).toHaveBeenCalledWith('Test Context: Test Error');
});
it('should handle non-Error objects', () => {
const error = { custom: 'error' };
expect(() => handleError(error, 'Test Context')).toThrow('{"custom":"error"}');
expect(NoticeManager.error).toHaveBeenCalledWith('Test Context: {"custom":"error"}');
});
it('should handle string errors', () => {
const error = 'String error';
expect(() => handleError(error, 'Test Context')).toThrow('String error');
expect(NoticeManager.error).toHaveBeenCalledWith('Test Context: String error');
});
it('should handle undefined errors', () => {
const error = undefined;
expect(() => handleError(error, 'Test Context')).toThrow('undefined');
expect(NoticeManager.error).toHaveBeenCalledWith('Test Context: undefined');
});
it('should create proper error message with context', () => {
const error = new Error('Original Error');
expect(() => handleError(error, 'Test Context')).toThrow('Original Error');
expect(NoticeManager.error).toHaveBeenCalledWith('Test Context: Original Error');
});
it('should create an error with empty message', () => {
const error = createError('');
expect(error.message).toBe('');
expect(error).toBeInstanceOf(Error);
});
});
it('should create an error with undefined context', () => {
const error = createError('Test Error', undefined);
expect(error.message).toBe('Test Error');
expect(error).toBeInstanceOf(Error);
});
});
describe('handleError', () => {
it('should log and re-throw error by default', () => {
const error = new Error('Test Error');
expect(() => handleError(error, 'Test Context')).toThrow('Test Error');
expect(NoticeManager.error).toHaveBeenCalledWith(
'Test Context: Test Error'
);
});
it('should log but not re-throw when shouldThrow is false', () => {
const error = new Error('Test Error');
expect(() => handleError(error, 'Test Context', false)).not.toThrow();
expect(NoticeManager.error).toHaveBeenCalledWith(
'Test Context: Test Error'
);
});
it('should handle non-Error objects', () => {
const error = { custom: 'error' };
expect(() => handleError(error, 'Test Context')).toThrow(
'{"custom":"error"}'
);
expect(NoticeManager.error).toHaveBeenCalledWith(
'Test Context: {"custom":"error"}'
);
});
it('should handle string errors', () => {
const error = 'String error';
expect(() => handleError(error, 'Test Context')).toThrow('String error');
expect(NoticeManager.error).toHaveBeenCalledWith(
'Test Context: String error'
);
});
it('should handle undefined errors', () => {
const error = undefined;
expect(() => handleError(error, 'Test Context')).toThrow('undefined');
expect(NoticeManager.error).toHaveBeenCalledWith(
'Test Context: undefined'
);
});
it('should create proper error message with context', () => {
const error = new Error('Original Error');
expect(() => handleError(error, 'Test Context')).toThrow(
'Original Error'
);
expect(NoticeManager.error).toHaveBeenCalledWith(
'Test Context: Original Error'
);
});
});
});

View file

@ -3,80 +3,80 @@ import { Notice } from 'obsidian';
// Mock for the Notice class
jest.mock('obsidian', () => ({
Notice: jest.fn().mockImplementation(() => ({
noticeEl: {
appendChild: jest.fn()
}
}))
Notice: jest.fn().mockImplementation(() => ({
noticeEl: {
appendChild: jest.fn(),
},
})),
}));
describe('NoticeManager', () => {
beforeEach(() => {
jest.clearAllMocks();
beforeEach(() => {
jest.clearAllMocks();
});
describe('show', () => {
it('should create a notice with default duration', () => {
NoticeManager.show('info', 'Test message');
expect(Notice).toHaveBeenCalledWith('', 8000);
});
describe('show', () => {
it('should create a notice with default duration', () => {
NoticeManager.show('info', 'Test message');
expect(Notice).toHaveBeenCalledWith('', 8000);
});
it('should create a notice with custom duration', () => {
NoticeManager.show('error', 'Test error', { duration: 10000 });
expect(Notice).toHaveBeenCalledWith('', 10000);
});
it('should create a notice with undo button', () => {
const onUndo = jest.fn();
NoticeManager.show('info', 'Test message', {
showUndoButton: true,
onUndo,
undoText: 'Custom Undo'
});
expect(Notice).toHaveBeenCalledWith('', 8000);
});
it('should create a notice with custom duration', () => {
NoticeManager.show('error', 'Test error', { duration: 10000 });
expect(Notice).toHaveBeenCalledWith('', 10000);
});
describe('info', () => {
it('should create an info notice', () => {
NoticeManager.info('Test info message');
expect(Notice).toHaveBeenCalledWith('', 8000);
});
it('should create a notice with undo button', () => {
const onUndo = jest.fn();
NoticeManager.show('info', 'Test message', {
showUndoButton: true,
onUndo,
undoText: 'Custom Undo',
});
expect(Notice).toHaveBeenCalledWith('', 8000);
});
});
describe('error', () => {
it('should create an error notice', () => {
NoticeManager.error('Test error message');
expect(Notice).toHaveBeenCalledWith('', 15000);
});
describe('info', () => {
it('should create an info notice', () => {
NoticeManager.info('Test info message');
expect(Notice).toHaveBeenCalledWith('', 8000);
});
});
describe('update', () => {
it('should create an update notice', () => {
NoticeManager.update('Test update message');
expect(Notice).toHaveBeenCalledWith('', 15000);
});
describe('error', () => {
it('should create an error notice', () => {
NoticeManager.error('Test error message');
expect(Notice).toHaveBeenCalledWith('', 15000);
});
});
describe('success', () => {
it('should create a success notice', () => {
NoticeManager.success('Test success message');
expect(Notice).toHaveBeenCalledWith('', 5000);
});
describe('update', () => {
it('should create an update notice', () => {
NoticeManager.update('Test update message');
expect(Notice).toHaveBeenCalledWith('', 15000);
});
});
describe('warning', () => {
it('should create a warning notice', () => {
NoticeManager.warning('Test warning message');
expect(Notice).toHaveBeenCalledWith('', 10000);
});
describe('success', () => {
it('should create a success notice', () => {
NoticeManager.success('Test success message');
expect(Notice).toHaveBeenCalledWith('', 5000);
});
});
describe('showWithUndo', () => {
it('should create a notice with undo functionality', () => {
const onUndo = jest.fn();
NoticeManager.showWithUndo('info', 'Test message', onUndo, 'Custom Undo');
expect(Notice).toHaveBeenCalledWith('', 8000);
});
describe('warning', () => {
it('should create a warning notice', () => {
NoticeManager.warning('Test warning message');
expect(Notice).toHaveBeenCalledWith('', 10000);
});
});
describe('showWithUndo', () => {
it('should create a notice with undo functionality', () => {
const onUndo = jest.fn();
NoticeManager.showWithUndo('info', 'Test message', onUndo, 'Custom Undo');
expect(Notice).toHaveBeenCalledWith('', 8000);
});
});
});

View file

@ -1,170 +1,193 @@
import { formatPath, combinePath, getParentPath, ensureFolderExists } from '../PathUtils';
import {
formatPath,
combinePath,
getParentPath,
ensureFolderExists,
} from '../PathUtils';
// Mock Obsidian App
const mockApp = {
vault: {
adapter: {
exists: jest.fn()
},
createFolder: jest.fn()
}
vault: {
adapter: {
exists: jest.fn(),
},
createFolder: jest.fn(),
},
} as any;
describe('PathUtils', () => {
beforeEach(() => {
jest.clearAllMocks();
beforeEach(() => {
jest.clearAllMocks();
});
describe('formatPath', () => {
it('should remove double slashes and leading slash', () => {
expect(formatPath('//test///path//')).toBe('test/path');
});
describe('formatPath', () => {
it('should remove double slashes and leading slash', () => {
expect(formatPath('//test///path//')).toBe('test/path');
});
it('should remove leading slash to make paths relative', () => {
expect(formatPath('/test/path')).toBe('test/path');
});
it('should remove trailing slash', () => {
expect(formatPath('/test/path/')).toBe('test/path');
});
it('should handle root path correctly', () => {
expect(formatPath('/')).toBe('');
expect(formatPath('')).toBe('');
});
it('should handle paths without leading slash', () => {
expect(formatPath('test/path')).toBe('test/path');
});
it('should handle single folder', () => {
expect(formatPath('folder')).toBe('folder');
expect(formatPath('/folder/')).toBe('folder');
});
it('should remove leading slash to make paths relative', () => {
expect(formatPath('/test/path')).toBe('test/path');
});
describe('combinePath', () => {
it('should correctly combine folder path and filename', () => {
expect(combinePath('test/folder', 'file.md')).toBe('test/folder/file.md');
});
it('should handle paths with leading slash', () => {
expect(combinePath('/test/folder', 'file.md')).toBe('test/folder/file.md');
});
it('should handle root path correctly', () => {
expect(combinePath('/', 'file.md')).toBe('file.md');
expect(combinePath('', 'file.md')).toBe('file.md');
});
it('should avoid double slashes', () => {
expect(combinePath('/test//folder/', 'file.md')).toBe('test/folder/file.md');
});
it('should handle empty folder', () => {
expect(combinePath('', 'file.md')).toBe('file.md');
});
it('should remove trailing slash', () => {
expect(formatPath('/test/path/')).toBe('test/path');
});
describe('getParentPath', () => {
it('should extract parent path from full path', () => {
expect(getParentPath('folder/subfolder/file.md')).toBe('folder/subfolder');
});
it('should handle single folder level', () => {
expect(getParentPath('folder/file.md')).toBe('folder');
});
it('should handle file in root directory', () => {
expect(getParentPath('file.md')).toBe('');
});
it('should handle empty path', () => {
expect(getParentPath('')).toBe('');
});
it('should handle path with no filename', () => {
expect(getParentPath('folder/subfolder/')).toBe('folder/subfolder');
});
it('should handle deeply nested paths', () => {
expect(getParentPath('a/b/c/d/e/file.md')).toBe('a/b/c/d/e');
});
it('should handle paths with special characters', () => {
expect(getParentPath('my folder/sub-folder/file name.md')).toBe('my folder/sub-folder');
});
it('should handle root path correctly', () => {
expect(formatPath('/')).toBe('');
expect(formatPath('')).toBe('');
});
describe('ensureFolderExists', () => {
it('should return true if folder already exists', async () => {
mockApp.vault.adapter.exists.mockResolvedValue(true);
const result = await ensureFolderExists(mockApp, 'existing/folder');
expect(result).toBe(true);
expect(mockApp.vault.adapter.exists).toHaveBeenCalledWith('existing/folder');
expect(mockApp.vault.createFolder).not.toHaveBeenCalled();
});
it('should create folder if it does not exist', async () => {
mockApp.vault.adapter.exists.mockResolvedValue(false);
mockApp.vault.createFolder.mockResolvedValue(undefined);
const result = await ensureFolderExists(mockApp, 'new/folder');
expect(result).toBe(true);
expect(mockApp.vault.adapter.exists).toHaveBeenCalledWith('new/folder');
expect(mockApp.vault.createFolder).toHaveBeenCalledWith('new/folder');
});
it('should return false if folder creation fails', async () => {
mockApp.vault.adapter.exists.mockResolvedValue(false);
mockApp.vault.createFolder.mockRejectedValue(new Error('Creation failed'));
const result = await ensureFolderExists(mockApp, 'problematic/folder');
expect(result).toBe(false);
expect(mockApp.vault.createFolder).toHaveBeenCalledWith('problematic/folder');
});
it('should handle root path correctly', async () => {
const result = await ensureFolderExists(mockApp, '');
expect(result).toBe(true);
expect(mockApp.vault.adapter.exists).not.toHaveBeenCalled();
});
it('should handle root slash correctly', async () => {
const result = await ensureFolderExists(mockApp, '/');
expect(result).toBe(true);
expect(mockApp.vault.adapter.exists).not.toHaveBeenCalled();
});
it('should format path before processing', async () => {
mockApp.vault.adapter.exists.mockResolvedValue(true);
await ensureFolderExists(mockApp, '//messy///path//');
expect(mockApp.vault.adapter.exists).toHaveBeenCalledWith('messy/path');
});
it('should handle path formatting edge cases', async () => {
mockApp.vault.adapter.exists.mockResolvedValue(false);
mockApp.vault.createFolder.mockResolvedValue(undefined);
await ensureFolderExists(mockApp, '/leading/slash/');
expect(mockApp.vault.adapter.exists).toHaveBeenCalledWith('leading/slash');
expect(mockApp.vault.createFolder).toHaveBeenCalledWith('leading/slash');
});
it('should return false on adapter.exists error', async () => {
mockApp.vault.adapter.exists.mockRejectedValue(new Error('Access denied'));
const result = await ensureFolderExists(mockApp, 'test/folder');
expect(result).toBe(false);
expect(mockApp.vault.createFolder).not.toHaveBeenCalled();
});
it('should handle paths without leading slash', () => {
expect(formatPath('test/path')).toBe('test/path');
});
});
it('should handle single folder', () => {
expect(formatPath('folder')).toBe('folder');
expect(formatPath('/folder/')).toBe('folder');
});
});
describe('combinePath', () => {
it('should correctly combine folder path and filename', () => {
expect(combinePath('test/folder', 'file.md')).toBe('test/folder/file.md');
});
it('should handle paths with leading slash', () => {
expect(combinePath('/test/folder', 'file.md')).toBe(
'test/folder/file.md'
);
});
it('should handle root path correctly', () => {
expect(combinePath('/', 'file.md')).toBe('file.md');
expect(combinePath('', 'file.md')).toBe('file.md');
});
it('should avoid double slashes', () => {
expect(combinePath('/test//folder/', 'file.md')).toBe(
'test/folder/file.md'
);
});
it('should handle empty folder', () => {
expect(combinePath('', 'file.md')).toBe('file.md');
});
});
describe('getParentPath', () => {
it('should extract parent path from full path', () => {
expect(getParentPath('folder/subfolder/file.md')).toBe(
'folder/subfolder'
);
});
it('should handle single folder level', () => {
expect(getParentPath('folder/file.md')).toBe('folder');
});
it('should handle file in root directory', () => {
expect(getParentPath('file.md')).toBe('');
});
it('should handle empty path', () => {
expect(getParentPath('')).toBe('');
});
it('should handle path with no filename', () => {
expect(getParentPath('folder/subfolder/')).toBe('folder/subfolder');
});
it('should handle deeply nested paths', () => {
expect(getParentPath('a/b/c/d/e/file.md')).toBe('a/b/c/d/e');
});
it('should handle paths with special characters', () => {
expect(getParentPath('my folder/sub-folder/file name.md')).toBe(
'my folder/sub-folder'
);
});
});
describe('ensureFolderExists', () => {
it('should return true if folder already exists', async () => {
mockApp.vault.adapter.exists.mockResolvedValue(true);
const result = await ensureFolderExists(mockApp, 'existing/folder');
expect(result).toBe(true);
expect(mockApp.vault.adapter.exists).toHaveBeenCalledWith(
'existing/folder'
);
expect(mockApp.vault.createFolder).not.toHaveBeenCalled();
});
it('should create folder if it does not exist', async () => {
mockApp.vault.adapter.exists.mockResolvedValue(false);
mockApp.vault.createFolder.mockResolvedValue(undefined);
const result = await ensureFolderExists(mockApp, 'new/folder');
expect(result).toBe(true);
expect(mockApp.vault.adapter.exists).toHaveBeenCalledWith('new/folder');
expect(mockApp.vault.createFolder).toHaveBeenCalledWith('new/folder');
});
it('should return false if folder creation fails', async () => {
mockApp.vault.adapter.exists.mockResolvedValue(false);
mockApp.vault.createFolder.mockRejectedValue(
new Error('Creation failed')
);
const result = await ensureFolderExists(mockApp, 'problematic/folder');
expect(result).toBe(false);
expect(mockApp.vault.createFolder).toHaveBeenCalledWith(
'problematic/folder'
);
});
it('should handle root path correctly', async () => {
const result = await ensureFolderExists(mockApp, '');
expect(result).toBe(true);
expect(mockApp.vault.adapter.exists).not.toHaveBeenCalled();
});
it('should handle root slash correctly', async () => {
const result = await ensureFolderExists(mockApp, '/');
expect(result).toBe(true);
expect(mockApp.vault.adapter.exists).not.toHaveBeenCalled();
});
it('should format path before processing', async () => {
mockApp.vault.adapter.exists.mockResolvedValue(true);
await ensureFolderExists(mockApp, '//messy///path//');
expect(mockApp.vault.adapter.exists).toHaveBeenCalledWith('messy/path');
});
it('should handle path formatting edge cases', async () => {
mockApp.vault.adapter.exists.mockResolvedValue(false);
mockApp.vault.createFolder.mockResolvedValue(undefined);
await ensureFolderExists(mockApp, '/leading/slash/');
expect(mockApp.vault.adapter.exists).toHaveBeenCalledWith(
'leading/slash'
);
expect(mockApp.vault.createFolder).toHaveBeenCalledWith('leading/slash');
});
it('should return false on adapter.exists error', async () => {
mockApp.vault.adapter.exists.mockRejectedValue(
new Error('Access denied')
);
const result = await ensureFolderExists(mockApp, 'test/folder');
expect(result).toBe(false);
expect(mockApp.vault.createFolder).not.toHaveBeenCalled();
});
});
});

View file

@ -2,583 +2,582 @@
/* Search Input Styles */
.note_mover_search {
width: calc(100% - 20px);
width: calc(100% - 20px);
}
/* ===== COMMON MODAL STYLES ===== */
/* Modal Size Classes */
.modal-size-small {
width: 400px !important;
min-width: 350px !important;
width: 400px !important;
min-width: 350px !important;
}
.modal-size-medium {
min-width: 600px !important;
width: 800px !important;
max-width: 95vw !important;
min-width: 600px !important;
width: 800px !important;
max-width: 95vw !important;
}
.modal-size-large {
min-width: 700px !important;
width: 900px !important;
max-width: 95vw !important;
min-width: 700px !important;
width: 900px !important;
max-width: 95vw !important;
}
/* Common Modal Title Styles */
.modal-title-container {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.modal-title-icon {
font-size: 2em;
line-height: 1;
font-size: 2em;
line-height: 1;
}
.modal-title {
margin: 0;
font-size: 1.6em;
font-weight: bold;
color: var(--text-normal);
margin: 0;
font-size: 1.6em;
font-weight: bold;
color: var(--text-normal);
}
.modal-subtitle {
margin: 0 0 24px 0;
color: var(--text-muted);
font-size: 1.1em;
margin: 0 0 24px 0;
color: var(--text-muted);
font-size: 1.1em;
}
/* Common Button Container Styles */
.modal-button-container {
display: flex;
justify-content: center;
gap: 16px;
align-items: center;
display: flex;
justify-content: center;
gap: 16px;
align-items: center;
}
.modal-button-container .setting-item {
border: none !important;
margin: 0 !important;
padding: 0 !important;
border: none !important;
margin: 0 !important;
padding: 0 !important;
}
.modal-button-container .setting-item-control {
margin: 0 !important;
margin: 0 !important;
}
/* Common Section Styles */
.modal-section {
margin-bottom: 24px;
margin-bottom: 24px;
}
.modal-section:last-child {
margin-bottom: 16px;
margin-bottom: 16px;
}
.modal-section-header {
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--background-modifier-border);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--background-modifier-border);
}
.modal-section-header h3 {
margin: 0;
font-size: 1.2em;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 1.2em;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
/* Common Footer Styles */
.modal-footer {
display: flex;
justify-content: center;
border-top: 1px solid var(--background-modifier-border);
padding-top: 16px;
margin-top: 24px;
display: flex;
justify-content: center;
border-top: 1px solid var(--background-modifier-border);
padding-top: 16px;
margin-top: 24px;
}
/* Common List Styles */
.modal-list {
display: flex;
flex-direction: column;
gap: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.modal-list-item {
padding: 16px;
border-radius: 6px;
border: 1px solid var(--background-modifier-border);
background: var(--background-primary);
padding: 16px;
border-radius: 6px;
border: 1px solid var(--background-modifier-border);
background: var(--background-primary);
}
/* Common Empty State Styles */
.modal-empty {
text-align: center;
padding: 48px 24px;
color: var(--text-muted);
font-size: 1.1em;
background: var(--background-secondary);
border-radius: 8px;
margin: 24px 0;
text-align: center;
padding: 48px 24px;
color: var(--text-muted);
font-size: 1.1em;
background: var(--background-secondary);
border-radius: 8px;
margin: 24px 0;
}
/* Common Info Box Styles */
.modal-info-box {
margin-bottom: 20px;
padding: 12px 16px;
background: var(--background-secondary);
border-radius: 6px;
border: 1px solid var(--background-modifier-border);
margin-bottom: 20px;
padding: 12px 16px;
background: var(--background-secondary);
border-radius: 6px;
border: 1px solid var(--background-modifier-border);
}
.modal-info-row {
display: flex;
gap: 24px;
flex-wrap: wrap;
font-size: 0.9em;
color: var(--text-muted);
display: flex;
gap: 24px;
flex-wrap: wrap;
font-size: 0.9em;
color: var(--text-muted);
}
.modal-info-row span {
display: flex;
align-items: center;
gap: 4px;
display: flex;
align-items: center;
gap: 4px;
}
.modal-info-row code {
background: var(--background-primary);
padding: 2px 6px;
border-radius: 3px;
font-size: 0.85em;
background: var(--background-primary);
padding: 2px 6px;
border-radius: 3px;
font-size: 0.85em;
}
/* Verlaufshistorie Modal */
.note-mover-history-modal {
padding: 32px 0 20px 0;
box-sizing: border-box;
padding: 32px 0 20px 0;
box-sizing: border-box;
}
/* Time Filter Container */
.time-filter-container {
margin-bottom: 20px;
padding: 0;
background: none;
border: none;
margin-bottom: 20px;
padding: 0;
background: none;
border: none;
}
.time-filter-container .setting-item {
margin-bottom: 0;
border: none !important;
box-shadow: none !important;
background: none !important;
margin-bottom: 0;
border: none !important;
box-shadow: none !important;
background: none !important;
}
.time-filter-container .setting-item-name {
font-weight: 600;
color: var(--text-normal);
display: flex;
align-items: center;
height: 100%;
font-weight: 600;
color: var(--text-normal);
display: flex;
align-items: center;
height: 100%;
}
.time-filter-container .setting-item-control {
display: flex;
align-items: center;
height: 100%;
margin-right: 16px;
display: flex;
align-items: center;
height: 100%;
margin-right: 16px;
}
/* Retention Policy Settings - Inline layout */
.setting-item-control .retention-value-input {
width: 80px !important;
margin-right: 8px !important;
width: 80px !important;
margin-right: 8px !important;
}
.setting-item-control .retention-unit-dropdown {
width: 100px !important;
width: 100px !important;
}
.setting-item-control {
display: flex !important;
align-items: center !important;
gap: 8px !important;
display: flex !important;
align-items: center !important;
gap: 8px !important;
}
.history-list {
display: flex;
flex-direction: column;
gap: 0;
width: 100%;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0;
width: 100%;
margin: 0;
padding: 0;
}
.history-entry:first-child {
margin-top: 0;
border-top: none;
margin-top: 0;
border-top: none;
}
.history-entry {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 24px;
border-bottom: 1px solid var(--background-modifier-border);
background: none;
box-sizing: border-box;
gap: 16px;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 24px;
border-bottom: 1px solid var(--background-modifier-border);
background: none;
box-sizing: border-box;
gap: 16px;
}
.history-entry:last-child {
border-bottom: none;
border-bottom: none;
}
.history-entry-content {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1 1 0;
min-width: 0;
overflow: hidden;
}
.history-entry-info {
font-weight: 600;
font-size: 1em;
color: var(--text-normal);
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
font-size: 1em;
color: var(--text-normal);
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-entry-paths {
color: var(--text-muted);
font-size: 0.92em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-muted);
font-size: 0.92em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.setting-item-control {
flex-shrink: 0;
margin-left: 16px;
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: 16px;
display: flex;
align-items: center;
}
.setting-item {
border: none !important;
box-shadow: none !important;
border: none !important;
box-shadow: none !important;
}
.note-mover-history-title {
margin: 0 0 16px 0;
font-size: 1.4em;
font-weight: bold;
text-align: left;
color: var(--text-normal);
margin: 0 0 16px 0;
font-size: 1.4em;
font-weight: bold;
text-align: left;
color: var(--text-normal);
}
/* Bulk Operation Styles */
.history-entry.bulk-operation {
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
margin-bottom: 8px;
padding: 16px 24px;
flex-direction: column;
align-items: stretch;
gap: 12px;
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
margin-bottom: 8px;
padding: 16px 24px;
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.history-entry.single-operation {
border-left: 3px solid var(--interactive-accent);
border-left: 3px solid var(--interactive-accent);
}
.bulk-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.bulk-operation-info {
font-weight: 600;
font-size: 1.1em;
color: var(--text-normal);
font-weight: 600;
font-size: 1.1em;
color: var(--text-normal);
}
.bulk-details {
margin-top: 8px;
margin-top: 8px;
}
.bulk-details summary {
cursor: pointer;
color: var(--text-muted);
font-size: 0.9em;
margin-bottom: 8px;
user-select: none;
cursor: pointer;
color: var(--text-muted);
font-size: 0.9em;
margin-bottom: 8px;
user-select: none;
}
.bulk-details summary:hover {
color: var(--text-normal);
color: var(--text-normal);
}
.bulk-files-list {
display: flex;
flex-direction: column;
gap: 0;
margin-left: 0;
padding-top: 0;
display: flex;
flex-direction: column;
gap: 0;
margin-left: 0;
padding-top: 0;
}
/* Bulk file entries use the same styling as regular history entries */
.history-entry.bulk-file-entry {
background: var(--background-primary);
border-left: 2px solid var(--background-modifier-border);
margin-bottom: 0;
border-bottom: 1px solid var(--background-modifier-border-hover);
background: var(--background-primary);
border-left: 2px solid var(--background-modifier-border);
margin-bottom: 0;
border-bottom: 1px solid var(--background-modifier-border-hover);
}
/* Update Modal Styles */
.note-mover-update-modal {
padding: 32px 0 20px 0;
box-sizing: border-box;
padding: 32px 0 20px 0;
box-sizing: border-box;
}
.changelog-container {
max-height: 400px;
overflow-y: auto;
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
padding: 16px;
margin-bottom: 24px;
background: var(--background-secondary);
max-height: 400px;
overflow-y: auto;
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
padding: 16px;
margin-bottom: 24px;
background: var(--background-secondary);
}
.changelog-version {
margin-bottom: 24px;
margin-bottom: 24px;
}
.changelog-version:last-child {
margin-bottom: 0;
margin-bottom: 0;
}
.changelog-version-title {
margin: 0 0 12px 0;
font-size: 1.3em;
font-weight: bold;
color: var(--text-accent);
border-bottom: 1px solid var(--background-modifier-border);
padding-bottom: 6px;
margin: 0 0 12px 0;
font-size: 1.3em;
font-weight: bold;
color: var(--text-accent);
border-bottom: 1px solid var(--background-modifier-border);
padding-bottom: 6px;
}
.changelog-section {
margin-bottom: 16px;
margin-bottom: 16px;
}
.changelog-section:last-child {
margin-bottom: 0;
margin-bottom: 0;
}
.changelog-section-title {
margin: 0 0 8px 0;
font-size: 1em;
font-weight: 600;
color: var(--text-normal);
margin: 0 0 8px 0;
font-size: 1em;
font-weight: 600;
color: var(--text-normal);
}
.changelog-list {
margin: 0;
padding-left: 20px;
color: var(--text-normal);
margin: 0;
padding-left: 20px;
color: var(--text-normal);
}
.changelog-list li {
margin-bottom: 4px;
margin-bottom: 4px;
}
.changelog-list li:last-child {
margin-bottom: 0;
margin-bottom: 0;
}
.update-modal-links {
display: flex;
align-items: center;
justify-content: center;
display: flex;
align-items: center;
justify-content: center;
}
.update-modal-github-link {
color: var(--text-muted);
text-decoration: none;
font-size: 0.9em;
transition: color 0.2s ease;
color: var(--text-muted);
text-decoration: none;
font-size: 0.9em;
transition: color 0.2s ease;
}
.update-modal-github-link:hover {
color: var(--text-accent);
text-decoration: underline;
color: var(--text-accent);
text-decoration: underline;
}
.no-changelog-text {
color: var(--text-muted);
font-style: italic;
text-align: center;
margin: 24px 0;
color: var(--text-muted);
font-style: italic;
text-align: center;
margin: 24px 0;
}
/* Preview Modal Styles */
.note-mover-preview-modal {
padding: 16px 0 20px 0;
box-sizing: border-box;
padding: 16px 0 20px 0;
box-sizing: border-box;
}
.preview-section {
margin-bottom: 24px;
margin-bottom: 24px;
}
.preview-section:last-child {
margin-bottom: 16px;
margin-bottom: 16px;
}
.preview-section-success .modal-section-header h3 {
color: var(--text-success);
color: var(--text-success);
}
.preview-section-blocked .modal-section-header h3 {
color: var(--text-error);
color: var(--text-error);
}
.modal-list-item.preview-item-success {
border-left: 3px solid var(--text-success);
background: var(--background-primary-alt);
border-left: 3px solid var(--text-success);
background: var(--background-primary-alt);
}
.modal-list-item.preview-item-blocked {
border-left: 3px solid var(--text-error);
background: var(--background-primary-alt);
border-left: 3px solid var(--text-error);
background: var(--background-primary-alt);
}
.preview-item-main {
margin-bottom: 8px;
margin-bottom: 8px;
}
.preview-item-filename {
font-weight: 600;
font-size: 1em;
color: var(--text-normal);
margin-bottom: 4px;
font-weight: 600;
font-size: 1em;
color: var(--text-normal);
margin-bottom: 4px;
}
.preview-item-paths {
display: flex;
align-items: center;
gap: 12px;
font-family: var(--font-monospace);
font-size: 0.85em;
display: flex;
align-items: center;
gap: 12px;
font-family: var(--font-monospace);
font-size: 0.85em;
}
.current-path {
color: var(--text-muted);
color: var(--text-muted);
}
.arrow {
color: var(--text-accent);
font-weight: bold;
color: var(--text-accent);
font-weight: bold;
}
.target-path {
color: var(--text-success);
font-weight: 500;
color: var(--text-success);
font-weight: 500;
}
.preview-item-details {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.9em;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.9em;
}
.preview-item-rule,
.preview-item-block-reason,
.preview-item-blocking-filter,
.preview-item-tags {
display: flex;
align-items: center;
gap: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.rule-label,
.block-reason-label,
.filter-label,
.tags-label {
font-weight: 500;
color: var(--text-muted);
min-width: 60px;
font-weight: 500;
color: var(--text-muted);
min-width: 60px;
}
.preview-item-rule code,
.preview-item-blocking-filter code {
background: var(--background-secondary);
padding: 2px 6px;
border-radius: 3px;
font-size: 0.8em;
color: var(--text-normal);
background: var(--background-secondary);
padding: 2px 6px;
border-radius: 3px;
font-size: 0.8em;
color: var(--text-normal);
}
.preview-item-block-reason {
color: var(--text-error);
color: var(--text-error);
}
.tag {
display: inline-block;
background: var(--background-modifier-border);
color: var(--text-muted);
padding: 2px 6px;
border-radius: 12px;
font-size: 0.8em;
margin-right: 4px;
display: inline-block;
background: var(--background-modifier-border);
color: var(--text-muted);
padding: 2px 6px;
border-radius: 12px;
font-size: 0.8em;
margin-right: 4px;
}
/* Confirm Modal Styles */
.confirm-modal {
padding: 24px 0 16px 0;
box-sizing: border-box;
text-align: center;
padding: 24px 0 16px 0;
box-sizing: border-box;
text-align: center;
}
.confirm-modal .modal-title {
text-align: center;
font-size: 1.4em;
text-align: center;
font-size: 1.4em;
}
.confirm-modal-message {
margin: 0 0 24px 0;
color: var(--text-muted);
font-size: 1em;
line-height: 1.5;
text-align: center;
margin: 0 0 24px 0;
color: var(--text-muted);
font-size: 1em;
line-height: 1.5;
text-align: center;
}
.confirm-modal .modal-button-container {
border-top: 1px solid var(--background-modifier-border);
padding-top: 16px;
border-top: 1px solid var(--background-modifier-border);
padding-top: 16px;
}
.confirm-modal .modal-button-container button {
min-width: 80px;
min-width: 80px;
}
/* ===== NOTICE STYLES ===== */
/* Undo Button Styles */
.notice-undo-button {
margin-left: 10px;
}
margin-left: 10px;
}

View file

@ -12,14 +12,7 @@
"isolatedModules": true,
"strictNullChecks": true,
"esModuleInterop": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
]
"lib": ["DOM", "ES5", "ES6", "ES7"]
},
"include": [
"**/*.ts"
]
"include": ["**/*.ts"]
}

View file

@ -1,14 +1,14 @@
import { readFileSync, writeFileSync } from "fs";
import { readFileSync, writeFileSync } from 'fs';
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
let manifest = JSON.parse(readFileSync('manifest.json', 'utf8'));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
writeFileSync('manifest.json', JSON.stringify(manifest, null, '\t'));
// update versions.json with target version and minAppVersion from manifest.json
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
let versions = JSON.parse(readFileSync('versions.json', 'utf8'));
versions[targetVersion] = minAppVersion;
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));