mirror of
https://github.com/bueckerlars/obsidian-note-mover-shortcut.git
synced 2026-07-22 05:46:00 +00:00
Add pre-commit hooks with formatting, linting, type checking and tests
This commit is contained in:
parent
c441bb72d2
commit
f37bb5304b
62 changed files with 19027 additions and 17417 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
30
.github/workflows/test.yml
vendored
30
.github/workflows/test.yml
vendored
|
|
@ -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
10
.husky/pre-commit
Normal 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
7
.prettierignore
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules/
|
||||
coverage/
|
||||
*.min.js
|
||||
main.js
|
||||
manifest.json
|
||||
versions.json
|
||||
data.json
|
||||
11
.prettierrc
Normal file
11
.prettierrc
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
57
CHANGELOG.md
57
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
29
README.md
29
README.md
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||
## Configuration
|
||||
|
||||
### Settings:
|
||||
|
||||
#### Trigger Settings
|
||||
|
||||

|
||||
|
||||
- **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
|
||||
|
||||

|
||||
|
||||
- **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 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
|
||||
|
||||

|
||||
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
|
||||
|
||||

|
||||
|
||||
- **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
|
||||
|
||||

|
||||
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,4 +26,4 @@ module.exports = {
|
|||
'src/types/__tests__/HistoryEntryType.test.ts',
|
||||
],
|
||||
setupFiles: ['<rootDir>/src/__tests__/setup.ts'],
|
||||
};
|
||||
};
|
||||
|
|
|
|||
128
main.ts
128
main.ts
|
|
@ -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
20484
package-lock.json
generated
File diff suppressed because it is too large
Load diff
84
package.json
84
package.json
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 {};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}`
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
// };
|
||||
// };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
569
styles.css
569
styles.css
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,14 +12,7 @@
|
|||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
]
|
||||
"lib": ["DOM", "ES5", "ES6", "ES7"]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
]
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
Loading…
Reference in a new issue