From 9b2571bb9e0b4c183a04e8ec8a6900fcae55dfd9 Mon Sep 17 00:00:00 2001 From: larsbuecker Date: Sun, 19 Apr 2026 12:52:47 +0200 Subject: [PATCH] chore: restructured into new modular architecture --- CHANGELOG.md | 19 + README.md | 33 +- esbuild.config.mjs | 2 +- main.ts | 467 +-- manifest.json | 2 +- package-lock.json | 3196 ++++++++++++++--- package.json | 18 +- scripts/generate-changelog.ts | 4 +- src/application/history-service.ts | 18 + src/application/move-file-service.ts | 33 + .../plugin-application-services.ts | 20 + src/application/preview-service.ts | 14 + src/core/FileMovementService.ts | 388 -- src/core/MetadataExtractor.ts | 59 +- src/core/NoteMoverShortcut.ts | 380 +- src/core/RuleEvaluationCache.ts | 24 +- src/core/RuleManager.ts | 214 -- src/core/RuleManagerV2.ts | 143 +- src/core/RuleMatcher.ts | 307 -- src/core/RuleMatcherV2.ts | 820 +---- src/core/RuleMigrationService.ts | 19 +- src/core/TriggerEventHandler.ts | 2 +- src/core/UpdateManager.ts | 5 - .../filters/blacklist-filter-engine.test.ts | 36 + src/domain/filters/blacklist-filter-engine.ts | 153 + .../filters/filter-needs-content.test.ts | 16 + src/domain/filters/filter-needs-content.ts | 14 + src/domain/property/isListProperty.ts | 16 + src/domain/property/parseListProperty.test.ts | 17 + src/domain/property/parseListProperty.ts | 23 + src/domain/rules/v2/RuleMatcherV2Engine.ts | 839 +++++ .../rules/v2/rule-matcher-v2-engine.test.ts | 59 + .../templates}/DestinationTemplate.ts | 0 .../templates/destination-template.test.ts | 28 + src/handlers/CommandHandler.ts | 20 +- .../cache/plugin-vault-index-cache.test.ts | 39 + .../cache/plugin-vault-index-cache.ts | 363 ++ src/infrastructure/debug/performance-trace.ts | 110 + .../persistence/plugin-settings-controller.ts | 286 ++ .../persistence/plugin-settings-schema.ts | 25 + .../plugin/vault-event-hooks.ts | 74 + src/modals/LegacyMigrationModal.ts | 121 - src/modals/PreviewModal.ts | 58 +- src/modals/RuleEditorModal.ts | 295 +- src/modals/index.ts | 2 - src/modals/mobile/MobileModalButton.ts | 61 - src/modals/mobile/MobileModalCard.ts | 65 - src/modals/mobile/MobileModalInput.ts | 54 - src/modals/mobile/MobileModalSection.ts | 29 - src/modals/mobile/MobileModalToggle.ts | 80 - src/modals/mobile/MobileTriggerCard.ts | 424 --- src/modals/mobile/index.ts | 7 - src/settings/Settings.ts | 90 +- src/settings/mobile/MobileSettingsRenderer.ts | 38 - .../mobile/components/MobileButtonSetting.ts | 61 - .../mobile/components/MobileInputSetting.ts | 54 - .../mobile/components/MobileRuleItem.ts | 330 -- .../mobile/components/MobileSettingItem.ts | 60 - .../mobile/components/MobileToggleSetting.ts | 87 - .../mobile/sections/MobileFiltersSection.ts | 179 - .../mobile/sections/MobileHistorySection.ts | 151 - .../sections/MobileImportExportSection.ts | 173 - .../mobile/sections/MobileLegacySection.ts | 39 - .../mobile/sections/MobileRulesSection.ts | 305 -- .../mobile/sections/MobileTriggersSection.ts | 112 - .../sections/FilterSettingsSection.ts | 6 +- .../sections/ImportExportSettingsSection.ts | 55 +- .../sections/LegacySettingsSection.ts | 38 - .../PerformanceDebugSettingsSection.ts | 88 + .../PeriodicMovementSettingsSection.ts | 4 +- src/settings/sections/RulesSettingsSection.ts | 183 +- src/settings/sections/index.ts | 2 +- src/settings/suggesters/AdvancedSuggest.ts | 25 +- .../suggesters/PropertyValueSuggest.ts | 15 +- src/settings/suggesters/TagSuggest.ts | 10 +- src/test-utils/obsidian-vitest-stub.ts | 14 + src/types/PluginData.ts | 8 +- src/types/Rule.ts | 9 - src/utils/OperatorMapping.ts | 17 +- src/utils/SettingsValidator.ts | 22 +- styles.css | 11 + tsconfig.json | 14 +- versions.json | 4 +- vitest.config.mjs | 24 + 84 files changed, 5654 insertions(+), 6075 deletions(-) create mode 100644 src/application/history-service.ts create mode 100644 src/application/move-file-service.ts create mode 100644 src/application/plugin-application-services.ts create mode 100644 src/application/preview-service.ts delete mode 100644 src/core/FileMovementService.ts delete mode 100644 src/core/RuleManager.ts delete mode 100644 src/core/RuleMatcher.ts create mode 100644 src/domain/filters/blacklist-filter-engine.test.ts create mode 100644 src/domain/filters/blacklist-filter-engine.ts create mode 100644 src/domain/filters/filter-needs-content.test.ts create mode 100644 src/domain/filters/filter-needs-content.ts create mode 100644 src/domain/property/isListProperty.ts create mode 100644 src/domain/property/parseListProperty.test.ts create mode 100644 src/domain/property/parseListProperty.ts create mode 100644 src/domain/rules/v2/RuleMatcherV2Engine.ts create mode 100644 src/domain/rules/v2/rule-matcher-v2-engine.test.ts rename src/{utils => domain/templates}/DestinationTemplate.ts (100%) create mode 100644 src/domain/templates/destination-template.test.ts create mode 100644 src/infrastructure/cache/plugin-vault-index-cache.test.ts create mode 100644 src/infrastructure/cache/plugin-vault-index-cache.ts create mode 100644 src/infrastructure/debug/performance-trace.ts create mode 100644 src/infrastructure/persistence/plugin-settings-controller.ts create mode 100644 src/infrastructure/persistence/plugin-settings-schema.ts create mode 100644 src/infrastructure/plugin/vault-event-hooks.ts delete mode 100644 src/modals/LegacyMigrationModal.ts delete mode 100644 src/modals/mobile/MobileModalButton.ts delete mode 100644 src/modals/mobile/MobileModalCard.ts delete mode 100644 src/modals/mobile/MobileModalInput.ts delete mode 100644 src/modals/mobile/MobileModalSection.ts delete mode 100644 src/modals/mobile/MobileModalToggle.ts delete mode 100644 src/modals/mobile/MobileTriggerCard.ts delete mode 100644 src/modals/mobile/index.ts delete mode 100644 src/settings/mobile/MobileSettingsRenderer.ts delete mode 100644 src/settings/mobile/components/MobileButtonSetting.ts delete mode 100644 src/settings/mobile/components/MobileInputSetting.ts delete mode 100644 src/settings/mobile/components/MobileRuleItem.ts delete mode 100644 src/settings/mobile/components/MobileSettingItem.ts delete mode 100644 src/settings/mobile/components/MobileToggleSetting.ts delete mode 100644 src/settings/mobile/sections/MobileFiltersSection.ts delete mode 100644 src/settings/mobile/sections/MobileHistorySection.ts delete mode 100644 src/settings/mobile/sections/MobileImportExportSection.ts delete mode 100644 src/settings/mobile/sections/MobileLegacySection.ts delete mode 100644 src/settings/mobile/sections/MobileRulesSection.ts delete mode 100644 src/settings/mobile/sections/MobileTriggersSection.ts delete mode 100644 src/settings/sections/LegacySettingsSection.ts create mode 100644 src/settings/sections/PerformanceDebugSettingsSection.ts create mode 100644 src/test-utils/obsidian-vitest-stub.ts delete mode 100644 src/types/Rule.ts create mode 100644 vitest.config.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index b705169..3cdc198 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [1.0.0](https://github.com/bueckerlars/obsidian-note-mover-shortcut/compare/0.7.2...1.0.0) + +### Breaking + +- **Legacy Rule V1 removed**: V1 rule types, legacy settings toggles, and duplicate mobile-only settings/modal implementations were dropped. Existing V1 rules are still migrated once on load (with backup) into **Rule V2**. + +### Features + +- **Preview bulk cancel**: While executing moves from the preview modal, you can **Stop** the remaining renames; completed moves stay in history. + +### Improvements + +- **Rule evaluation cache on by default** for new installs and unset data; existing `false` stays respected. +- **Lazy `vault.read` for filters**: If a blacklist line uses `content:`, file body is read for that evaluation path only. +- **Regex warmup**: Rule V2 regex patterns are precompiled when rules are loaded. +- **Bulk throughput**: Chunked vault moves and preview execution use `requestIdleCallback` when available instead of only `setTimeout(0)`. +- **Application services**: Thin `MoveFileService`, `PreviewService`, and `HistoryService` facades on the plugin (`appServices`) for clearer layering. +- **README**: Documented blacklist-only filters and Rule V2-first configuration (removed outdated whitelist / V1 criteria wording). + ## [0.7.1](https://github.com/bueckerlars/obsidian-note-mover-shortcut/compare/0.7.0...0.7.1) ### Features diff --git a/README.md b/README.md index 89a88fb..fff7799 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,8 @@ Notes: ![Filter Settings](images/noteMover-settings-filter.png) -- **Toggle blacklist/whitelist**: Choose between: - - **Blacklist**: Move all files EXCEPT those matching the specified criteria - - **Whitelist**: Move ONLY files matching the specified criteria -- **Filter criteria**: Add criteria to include/exclude from movement. Supported types: +- **Blacklist only**: Filters always act as a **blacklist** — files matching any filter line are **excluded** from automatic moves (manual commands still respect the same rules + filters). +- **Filter criteria**: Add criteria to exclude from movement. Supported types: - **Tags**: `tag: tagname` - Match files with specific tags (e.g., `tag: #inbox`, `tag: work/project`) - **Properties**: Property-based criteria from frontmatter: - `property: key` - Match files that have the specified property key @@ -47,32 +45,13 @@ Notes: - **Creation Date**: `created_at: date` - Match files based on creation date - **Update Date**: `updated_at: date` - Match files based on last modification date -#### Rules +#### Rules (Rule V2) ![Rules Configuration](images/noteMover-settings-rules.png) -- **Rules description**: Define custom rules for moving files based on various criteria. Rules are always active. -- **Rule configuration**: For each rule, specify: - - **Criteria**: The criteria that triggers the rule. Supported types: - - **Tags**: `tag: tagname` - Trigger for files with specific tags - - Supports subtags (e.g., `tag: work/project` matches both `#work/project` and `#work`) - - Example: `tag: meeting`, `tag: #inbox` - - **Properties**: `property: key` or `property: key:value` - Trigger based on frontmatter properties - - `property: key` - Trigger for files that have the specified property key - - `property: key:value` - Trigger for files where the property key has the exact value - - Example: `property: type:meeting`, `property: urgent` - - **File Names**: `fileName: pattern` - Trigger for files matching filename patterns - - Supports wildcards (e.g., `fileName: *.json`, `fileName: Daily*`) - - Example: `fileName: Meeting`, `fileName: *.md` - - **Content**: `content: text` - Trigger for files containing specific text - - Example: `content: TODO`, `content: urgent` - - **Path**: `path: folder/path` - Trigger for files in specific locations - - Example: `path: Inbox/`, `path: Templates/` - - **Creation Date**: `created_at: date` - Trigger based on when the file was created - - **Update Date**: `updated_at: date` - Trigger based on when the file was last modified - - **Path**: The destination folder for files matching this criteria - - Note: If a file matches multiple rules, the first matching rule will be applied. - - Note: Destination folders will be created automatically if they don't exist. +- **Rules**: Define **Rule V2** entries with named rules, **match aggregation** (`all` / `any` / `none`), typed **triggers** (tag, fileName, folder, properties, dates, links, embeds, headings, extension), and a **destination** (plain path or template). +- **Order**: If multiple rules match, the **first** matching rule in the list wins. +- **Destinations**: Folders are created when needed. Template placeholders such as `{{tag.x}}` / `{{property.y}}` are supported (see below). ##### Template Rules for Dynamic Destinations (Rule V2) diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 77ed707..b9231c1 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -33,7 +33,7 @@ const context = await esbuild.context({ ...builtins, ], format: 'cjs', - target: 'es2018', + target: 'es2022', logLevel: 'info', sourcemap: prod ? false : 'inline', treeShaking: true, diff --git a/main.ts b/main.ts index 5b7a5fb..43ba802 100644 --- a/main.ts +++ b/main.ts @@ -1,4 +1,4 @@ -import { Plugin, TFile, TAbstractFile } from 'obsidian'; +import { Plugin } from 'obsidian'; import { NoteMoverShortcut } from 'src/core/NoteMoverShortcut'; import { CommandHandler } from 'src/handlers/CommandHandler'; import { NoteMoverShortcutSettingsTab } from 'src/settings/Settings'; @@ -6,471 +6,90 @@ import { HistoryManager } from 'src/core/HistoryManager'; import { TriggerEventHandler } from 'src/core/TriggerEventHandler'; import { UpdateManager } from 'src/core/UpdateManager'; import { RuleEvaluationCache } from 'src/core/RuleEvaluationCache'; +import type { PluginData } from 'src/types/PluginData'; import { - PluginData, - SettingsData, - HistoryData, - Filter, -} from 'src/types/PluginData'; -import { SETTINGS_CONSTANTS } from 'src/config/constants'; -import { SettingsValidator } from 'src/utils/SettingsValidator'; -import { RuleMigrationService } from 'src/core/RuleMigrationService'; -import { LegacyMigrationModal } from 'src/modals/LegacyMigrationModal'; + loadPersistedSettings, + savePersistedSettings, +} from 'src/infrastructure/persistence/plugin-settings-controller'; +import { + registerVaultEventHooks, + registerVaultIndexMetadataListeners, +} from 'src/infrastructure/plugin/vault-event-hooks'; +import { PluginVaultIndexCache } from 'src/infrastructure/cache/plugin-vault-index-cache'; +import { PerformanceTraceRecorder } from 'src/infrastructure/debug/performance-trace'; +import { createPluginApplicationServices } from 'src/application/plugin-application-services'; +import type { PluginApplicationServices } from 'src/application/plugin-application-services'; export default class NoteMoverShortcutPlugin extends Plugin { - public settings: PluginData; - public noteMover: NoteMoverShortcut; - public command_handler: CommandHandler; - public historyManager: HistoryManager; - public updateManager: UpdateManager; - public triggerHandler: TriggerEventHandler; - public ruleCache: RuleEvaluationCache; - private settingTab: NoteMoverShortcutSettingsTab; + public settings!: PluginData; + public noteMover!: NoteMoverShortcut; + public command_handler!: CommandHandler; + public historyManager!: HistoryManager; + public updateManager!: UpdateManager; + public triggerHandler!: TriggerEventHandler; + public ruleCache!: RuleEvaluationCache; + public vaultIndexCache!: PluginVaultIndexCache; + public performanceTrace!: PerformanceTraceRecorder; + private settingTab!: NoteMoverShortcutSettingsTab; + /** Application-layer facades (use-cases); core logic remains on `noteMover`. */ + public appServices!: PluginApplicationServices; async onload() { - await this.load_settings(); + await loadPersistedSettings(this); this.ruleCache = new RuleEvaluationCache(); + this.vaultIndexCache = new PluginVaultIndexCache(); + this.performanceTrace = new PerformanceTraceRecorder( + () => this.settings.settings.enablePerformanceDebug === true + ); + this.vaultIndexCache.setCacheEnabledResolver( + () => this.settings.settings.enableVaultIndexCache !== false + ); + this.vaultIndexCache.setPerformanceRecorder(this.performanceTrace); this.historyManager = new HistoryManager(this); this.historyManager.loadHistoryFromSettings(); this.updateManager = new UpdateManager(this); this.noteMover = new NoteMoverShortcut(this); + this.appServices = createPluginApplicationServices(this); this.triggerHandler = new TriggerEventHandler(this); this.command_handler = new CommandHandler(this); this.command_handler.setup(); - this.addRibbonIcon('book-plus', 'NoteMover', (evt: MouseEvent) => { + this.addRibbonIcon('book-plus', 'NoteMover', () => { this.noteMover.moveFocusedNoteToDestination(); }); this.settingTab = new NoteMoverShortcutSettingsTab(this); this.addSettingTab(this.settingTab); - // Initialize triggers this.triggerHandler.togglePeriodic(); this.triggerHandler.toggleOnEditListener(); - // Event listeners for history and cache dirty-tracking - this.setupVaultEventListeners(); + registerVaultEventHooks(this); + registerVaultIndexMetadataListeners(this); - // Seed the rules hash so the cache knows the current config this.syncRuleCacheHash(); - // Show legacy migration modal when user has V1 rules and has not dismissed the prompt - if ( - this.settings.settings.enableLegacyRules === true && - !this.settings.settings.legacyMigrationDismissed && - this.settings.settings.rules.length > 0 - ) { - setTimeout(() => this.showLegacyMigrationModal(), 500); - } - - // Check for updates after complete loading this.app.workspace.onLayoutReady(() => { this.updateManager.checkForUpdates(); }); } - /** - * Opens the legacy migration modal with options to migrate to V2, ask later, or don't ask again. - */ - private showLegacyMigrationModal(): void { - const modal = new LegacyMigrationModal(this.app, { - onMigrate: async () => { - this.settings.settings.rulesV2 = RuleMigrationService.migrateRules( - this.settings.settings.rules - ); - this.settings.settings.enableLegacyRules = false; - await this.save_settings(); - this.noteMover.updateRuleManager(); - this.syncRuleCacheHash(); - }, - onDismiss: () => {}, - onDismissPermanently: async () => { - this.settings.settings.legacyMigrationDismissed = true; - await this.save_settings(); - }, - }); - modal.open(); - } - onunload() { - // Cleanup settings tab debounce manager - if (this.settingTab) { - this.settingTab.cleanup(); - } - // Cleanup trigger handler debounce manager - if (this.triggerHandler) { - this.triggerHandler.cleanup(); - } - // Event listeners are automatically removed when the plugin is unloaded + this.settingTab?.cleanup(); + this.triggerHandler?.cleanup(); } - /** Sync the rule-evaluation cache hash with the current settings. */ public syncRuleCacheHash(): void { const s = this.settings.settings; - this.ruleCache.updateRulesHash( - s.rules, - s.rulesV2 ?? [], - s.filters.filter, - !!s.enableLegacyRules - ); - } - - /** - * Setup of event listeners for automatic history creation and cache - * dirty-tracking. - */ - private setupVaultEventListeners(): void { - // Monitor file renaming/moving (history + cache) - this.registerEvent( - this.app.vault.on('rename', (file, oldPath) => { - if (file instanceof TFile && file.extension === 'md') { - this.historyManager.addEntryFromVaultEvent( - oldPath, - file.path, - file.name - ); - this.ruleCache.handleRename(oldPath, file.path); - } - }) - ); - - // Mark modified files as dirty so the next periodic run re-evaluates them - this.registerEvent( - this.app.vault.on('modify', (file: TAbstractFile) => { - if (file instanceof TFile && file.extension === 'md') { - this.ruleCache.markDirty(file.path); - } - }) - ); - - // Newly created files need evaluation - this.registerEvent( - this.app.vault.on('create', (file: TAbstractFile) => { - if (file instanceof TFile && file.extension === 'md') { - this.ruleCache.markDirty(file.path); - } - }) - ); - - // Remove deleted files from the cache - this.registerEvent( - this.app.vault.on('delete', (file: TAbstractFile) => { - if (file instanceof TFile && file.extension === 'md') { - this.ruleCache.handleDelete(file.path); - } - }) - ); + this.ruleCache.updateRulesHash(s.rulesV2 ?? [], s.filters.filter); } async save_settings(): Promise { - // Ensure history in settings is kept in sync and robust against corruption - const settingsAny = this.settings as any; - const currentHistory = settingsAny.history; - - if ( - !currentHistory || - typeof currentHistory !== 'object' || - Array.isArray(currentHistory) - ) { - settingsAny.history = { history: [], bulkOperations: [] } as HistoryData; - } - if (!Array.isArray(settingsAny.history.history)) { - settingsAny.history.history = []; - } - if (!Array.isArray(settingsAny.history.bulkOperations)) { - settingsAny.history.bulkOperations = []; - } - - // Ensure rule flags are always defined so they are serialized (JSON.stringify omits undefined) - const s = this.settings.settings; - if (s.enableLegacyRules === undefined) { - s.enableLegacyRules = false; - } - if (s.legacyMigrationDismissed === undefined) { - s.legacyMigrationDismissed = false; - } - - await this.saveData(this.settings); + await savePersistedSettings(this); } async load_settings(): Promise { - const savedData: any = await this.loadData(); - - if (this.isPluginData(savedData)) { - // Already in new format - this.settings = savedData as PluginData; - } else { - // Migrate from old NoteMoverShortcutSettings format - await this.backupLegacyDataJson(); - this.settings = this.migrateFromLegacy(savedData || {}); - await this.save_settings(); - } - - // Validate settings after loading to clean up any invalid data - await this.validateSettings(); - } - - /** - * Validate settings to prevent data loss - */ - private async validateSettings(): Promise { - // Ensure nested structures exist - if (!this.settings.settings) { - this.settings.settings = this.buildDefaultSettingsData(); - } - if (!this.settings.history) { - this.settings.history = { - history: [], - bulkOperations: [], - } as HistoryData; - } - - // Ensure rules array exists - if (!Array.isArray(this.settings.settings.rules)) { - this.settings.settings.rules = []; - } - // Ensure filters array exists - if (!this.settings.settings.filters) { - this.settings.settings.filters = { filter: [] }; - } - if (!Array.isArray(this.settings.settings.filters.filter)) { - this.settings.settings.filters.filter = []; - } - - // Migrate from old enableRuleV2 to enableLegacyRules (inverted meaning) - const settingsAny = this.settings.settings as any; - if (settingsAny.enableRuleV2 !== undefined) { - this.settings.settings.enableLegacyRules = !settingsAny.enableRuleV2; - delete settingsAny.enableRuleV2; - // Persist new format so reload keeps the value - await this.save_settings(); - } - if (this.settings.settings.enableLegacyRules === undefined) { - this.settings.settings.enableLegacyRules = false; - } - if (this.settings.settings.legacyMigrationDismissed === undefined) { - this.settings.settings.legacyMigrationDismissed = false; - } - - // Ensure rule evaluation cache feature flag exists - if (this.settings.settings.enableRuleEvaluationCache === undefined) { - this.settings.settings.enableRuleEvaluationCache = false; - } - - // Ensure RuleV2 array exists - if (!Array.isArray(this.settings.settings.rulesV2)) { - this.settings.settings.rulesV2 = []; - } - - // Ensure schemaVersion exists - if (this.settings.schemaVersion === undefined) { - this.settings.schemaVersion = 1; - } - - // Remove any invalid rules (empty criteria or path) - this.settings.settings.rules = this.settings.settings.rules.filter( - rule => - rule && - (rule as any).criteria && - (rule as any).path && - (rule as any).criteria.trim() !== '' && - (rule as any).path.trim() !== '' - ); - - // Remove any invalid filters (empty strings) - this.settings.settings.filters.filter = - this.settings.settings.filters.filter.filter( - f => f && typeof f.value === 'string' && f.value.trim() !== '' - ); - - // Migrate V1 rules to V2 when using V2 (enableLegacyRules false) and migration is needed - if (!this.settings.settings.enableLegacyRules) { - if ( - RuleMigrationService.shouldMigrate( - this.settings.settings.rules, - this.settings.settings.rulesV2 - ) - ) { - console.log('Migrating Rule V1 to Rule V2...'); - this.settings.settings.rulesV2 = RuleMigrationService.migrateRules( - this.settings.settings.rules - ); - console.log( - `Migrated ${this.settings.settings.rulesV2.length} rules to V2 format` - ); - await this.save_settings(); - } - - // Migrate RuleV2 triggers from ruleType to operator format (legacy data) - if ( - this.settings.settings.rulesV2 && - this.settings.settings.rulesV2.length > 0 - ) { - const migrated = RuleMigrationService.migrateRuleV2Triggers( - this.settings.settings.rulesV2 - ); - if (migrated) { - console.log( - 'Migrated RuleV2 triggers from ruleType to operator format' - ); - await this.save_settings(); - } - - const repaired = RuleMigrationService.repairRuleV2Properties( - this.settings.settings.rulesV2 - ); - if (repaired) { - console.log( - 'Repaired RuleV2 properties triggers with missing propertyName' - ); - await this.save_settings(); - } - } - } - - // Repair RuleV2 properties when in legacy mode but rulesV2 exist (e.g. after re-enabling legacy) - if ( - this.settings.settings.enableLegacyRules && - this.settings.settings.rulesV2 && - this.settings.settings.rulesV2.length > 0 - ) { - const repaired = RuleMigrationService.repairRuleV2Properties( - this.settings.settings.rulesV2 - ); - if (repaired) { - console.log( - 'Repaired RuleV2 properties triggers with missing propertyName' - ); - await this.save_settings(); - } - } - - // Validate RuleV2 with regex pre-compilation - if ( - this.settings.settings.rulesV2 && - this.settings.settings.rulesV2.length > 0 - ) { - const validationResult = { - isValid: true, - errors: [], - warnings: [], - }; - - SettingsValidator.validateRulesV2Array( - this.settings.settings.rulesV2, - validationResult - ); - - // Log any validation errors or warnings - if (validationResult.errors.length > 0) { - console.error('RuleV2 validation errors:', validationResult.errors); - } - if (validationResult.warnings.length > 0) { - console.warn('RuleV2 validation warnings:', validationResult.warnings); - } - } - } - - private isPluginData(data: any): data is PluginData { - return ( - data && - typeof data === 'object' && - 'settings' in data && - data.settings && - 'history' in data && - data.history - ); - } - - private buildDefaultSettingsData(): SettingsData { - const defaults = SETTINGS_CONSTANTS.DEFAULT_SETTINGS as any; - return { - triggers: { - enablePeriodicMovement: !!defaults.enablePeriodicMovement, - periodicMovementInterval: defaults.periodicMovementInterval ?? 5, - enableOnEditTrigger: !!defaults.enableOnEditTrigger, - }, - filters: { - filter: Array.isArray(defaults.filter) - ? (defaults.filter as string[]).map(v => ({ value: v }) as Filter) - : [], - }, - rules: Array.isArray(defaults.rules) ? defaults.rules : [], - retentionPolicy: defaults.retentionPolicy, - enableLegacyRules: false, - legacyMigrationDismissed: false, - rulesV2: [], - enableRuleEvaluationCache: false, - } as SettingsData; - } - - private migrateFromLegacy(legacy: any): PluginData { - const defaults = SETTINGS_CONSTANTS.DEFAULT_SETTINGS as any; - - const enablePeriodicMovement = - legacy?.enablePeriodicMovement ?? - defaults.enablePeriodicMovement ?? - false; - const periodicMovementInterval = - legacy?.periodicMovementInterval ?? - defaults.periodicMovementInterval ?? - 5; - const enableOnEditTrigger = - legacy?.enableOnEditTrigger ?? defaults.enableOnEditTrigger ?? false; - const retentionPolicy = legacy?.retentionPolicy ?? defaults.retentionPolicy; - - const rules = Array.isArray(legacy?.rules) ? legacy.rules : []; - const filterValues: string[] = Array.isArray(legacy?.filter) - ? legacy.filter - : []; - const filters: Filter[] = filterValues - .filter(v => typeof v === 'string') - .map(v => ({ value: v })); - - const historyArray = Array.isArray(legacy?.history) ? legacy.history : []; - const bulkOps = Array.isArray(legacy?.bulkOperations) - ? legacy.bulkOperations - : []; - - const data: PluginData = { - settings: { - triggers: { - enablePeriodicMovement, - periodicMovementInterval, - enableOnEditTrigger, - }, - filters: { filter: filters }, - rules, - retentionPolicy, - enableLegacyRules: false, - legacyMigrationDismissed: false, - }, - history: { - history: historyArray, - bulkOperations: bulkOps, - }, - lastSeenVersion: legacy?.lastSeenVersion, - schemaVersion: 1, - }; - - return data; - } - - private async backupLegacyDataJson(): Promise { - try { - const configDir = (this.app.vault as any).configDir || '.obsidian'; - const adapter: any = this.app.vault.adapter as any; - const dataPath = `${configDir}/plugins/${this.manifest.id}/data.json`; - const exists = await adapter.exists?.(dataPath); - if (!exists) return; - const iso = new Date().toISOString().replace(/[:.]/g, '-'); - const backupPath = `${configDir}/plugins/${this.manifest.id}/data.backup.${iso}.json`; - const content = await adapter.read(dataPath); - await adapter.write(backupPath, content); - } catch (e) { - // Best-effort backup; ignore errors - } + await loadPersistedSettings(this); } } diff --git a/manifest.json b/manifest.json index 4329932..cf3513d 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "note-mover-shortcut", "name": "NoteMover shortcut", - "version": "0.7.2", + "version": "1.0.0", "minAppVersion": "1.5.0", "description": "Quickly and easily move notes to a predefined folder. Perfect for organizing your notes.", "author": "bueckerlars", diff --git a/package-lock.json b/package-lock.json index 0efbacf..56870ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,29 +1,123 @@ { "name": "obsidian-note-mover-shortcut", - "version": "0.7.2", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-note-mover-shortcut", - "version": "0.7.2", + "version": "1.0.0", "license": "MIT", "dependencies": { "@popperjs/core": "^2.11.8" }, "devDependencies": { - "@types/node": "^16.11.6", - "@typescript-eslint/eslint-plugin": "5.29.0", - "@typescript-eslint/parser": "5.29.0", + "@types/node": "^20.19.39", + "@typescript-eslint/eslint-plugin": "^8.58.2", + "@typescript-eslint/parser": "^8.58.2", + "@vitest/coverage-v8": "^3.2.4", "builtin-modules": "3.3.0", "esbuild": "^0.25.9", + "eslint": "^8.57.1", "husky": "^9.1.7", + "jsdom": "^25.0.1", "lint-staged": "^16.2.1", "obsidian": "latest", "prettier": "^3.6.2", "tslib": "2.4.0", "tsx": "^4.20.6", - "typescript": "4.7.4" + "typescript": "~5.6.3", + "valibot": "^1.3.1", + "vite": "^6.4.2", + "vitest": "^3.2.4" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" } }, "node_modules/@codemirror/state": { @@ -51,6 +145,121 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -499,7 +708,6 @@ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "eslint-visitor-keys": "^3.4.3" }, @@ -519,7 +727,6 @@ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } @@ -530,7 +737,6 @@ "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -555,7 +761,6 @@ "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } @@ -567,7 +772,6 @@ "deprecated": "Use @eslint/config-array instead", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", @@ -583,7 +787,6 @@ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=12.22" }, @@ -598,8 +801,159 @@ "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "deprecated": "Use @eslint/object-schema instead", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } }, "node_modules/@marijn/find-cluster-break": { "version": "1.0.2", @@ -647,6 +1001,17 @@ "node": ">= 8" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -657,6 +1022,406 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/codemirror": { "version": "5.60.8", "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", @@ -667,6 +1432,13 @@ "@types/tern": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -674,19 +1446,15 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { - "version": "16.18.126", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", - "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==", + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } }, "node_modules/@types/tern": { "version": "0.23.9", @@ -699,120 +1467,159 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.29.0.tgz", - "integrity": "sha512-kgTsISt9pM53yRFQmLZ4npj99yGl3x3Pl7z4eA66OuTzAGC4bQB5H5fuLwPnqTKU3yyrrg4MIhjF17UYnL4c0w==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", + "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "5.29.0", - "@typescript-eslint/type-utils": "5.29.0", - "@typescript-eslint/utils": "5.29.0", - "debug": "^4.3.4", - "functional-red-black-tree": "^1.0.1", - "ignore": "^5.2.0", - "regexpp": "^3.2.0", - "semver": "^7.3.7", - "tsutils": "^3.21.0" + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/type-utils": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^5.0.0", - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "@typescript-eslint/parser": "^8.58.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" } }, "node_modules/@typescript-eslint/parser": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.29.0.tgz", - "integrity": "sha512-ruKWTv+x0OOxbzIw9nW5oWlUopvP/IQDjB5ZqmTglLIoDTctLlAJpAQFpNPJP/ZI7hTT9sARBosEfaKbcFuECw==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", + "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "5.29.0", - "@typescript-eslint/types": "5.29.0", - "@typescript-eslint/typescript-estree": "5.29.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", + "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.2", + "@typescript-eslint/types": "^8.58.2", + "debug": "^4.4.3" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.29.0.tgz", - "integrity": "sha512-etbXUT0FygFi2ihcxDZjz21LtC+Eps9V2xVx09zFoN44RRHPrkMflidGMI+2dUs821zR1tDS6Oc9IXxIjOUZwA==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", + "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "5.29.0", - "@typescript-eslint/visitor-keys": "5.29.0" + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.29.0.tgz", - "integrity": "sha512-JK6bAaaiJozbox3K220VRfCzLa9n0ib/J+FHIwnaV3Enw/TO267qe0pM1b1QrrEuy6xun374XEAsRlA86JJnyg==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", + "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==", "dev": true, "license": "MIT", - "dependencies": { - "@typescript-eslint/utils": "5.29.0", - "debug": "^4.3.4", - "tsutils": "^3.21.0" - }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "*" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", + "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.29.0.tgz", - "integrity": "sha512-X99VbqvAXOMdVyfFmksMy3u8p8yoRGITgU1joBJPzeYa0rhdf5ok9S56/itRoUSh99fiDoMtarSIJXo7H/SnOg==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", + "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", "dev": true, "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -820,74 +1627,125 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.29.0.tgz", - "integrity": "sha512-mQvSUJ/JjGBdvo+1LwC+GY2XmSYjK1nAaVw2emp/E61wEVYEyibRHCqm1I1vEKbXCpUKuW4G7u9ZCaZhJbLoNQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@typescript-eslint/types": "5.29.0", - "@typescript-eslint/visitor-keys": "5.29.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "semver": "^7.3.7", - "tsutils": "^3.21.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.29.0.tgz", - "integrity": "sha512-3Eos6uP1nyLOBayc/VUdKZikV90HahXE5Dx9L5YlSd/7ylQPXhLk1BYb29SDgnBnTp+jmSZUU0QxUiyHgW4p7A==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", + "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==", "dev": true, "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.9", - "@typescript-eslint/scope-manager": "5.29.0", - "@typescript-eslint/types": "5.29.0", - "@typescript-eslint/typescript-estree": "5.29.0", - "eslint-scope": "^5.1.1", - "eslint-utils": "^3.0.0" + "@typescript-eslint/project-service": "8.58.2", + "@typescript-eslint/tsconfig-utils": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.29.0.tgz", - "integrity": "sha512-Hpb/mCWsjILvikMQoZIE3voc9wtQcS0A9FUw3h8bhr9UxBdtI/tw1ZDZUOXHXLOVMedKCH5NxyzATwnU78bWCQ==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "5.29.0", - "eslint-visitor-keys": "^3.3.0" + "balanced-match": "^4.0.2" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz", + "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", + "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/@ungap/structured-clone": { @@ -895,8 +1753,156 @@ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" + }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } }, "node_modules/acorn": { "version": "8.16.0", @@ -904,7 +1910,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -918,18 +1923,26 @@ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -963,7 +1976,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -974,7 +1986,6 @@ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -990,52 +2001,62 @@ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, - "license": "Python-2.0", - "peer": true + "license": "Python-2.0" }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/builtin-modules": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", @@ -1049,24 +2070,63 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -1078,6 +2138,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -1117,7 +2187,6 @@ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "color-name": "~1.1.4" }, @@ -1130,8 +2199,7 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/colorette": { "version": "2.0.20", @@ -1140,6 +2208,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", @@ -1155,8 +2236,7 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/crelt": { "version": "1.0.6", @@ -1172,7 +2252,6 @@ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -1182,6 +2261,41 @@ "node": ">= 8" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1200,25 +2314,38 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "dev": true, "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, "engines": { - "node": ">=8" + "node": ">=0.4.0" } }, "node_modules/doctrine": { @@ -1227,7 +2354,6 @@ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "esutils": "^2.0.2" }, @@ -1235,6 +2361,28 @@ "node": ">=6.0.0" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -1242,6 +2390,19 @@ "dev": true, "license": "MIT" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -1255,6 +2416,62 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1303,7 +2520,6 @@ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -1318,7 +2534,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -1369,49 +2584,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/eslint-utils": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", - "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^2.0.0" - }, - "engines": { - "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - }, - "peerDependencies": { - "eslint": ">=5" - } - }, - "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-visitor-keys": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", @@ -1431,7 +2603,6 @@ "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -1449,7 +2620,6 @@ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -1460,7 +2630,6 @@ "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", @@ -1479,7 +2648,6 @@ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "estraverse": "^5.1.0" }, @@ -1493,7 +2661,6 @@ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -1521,14 +2688,14 @@ "node": ">=4.0" } }, - "node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" } }, "node_modules/esutils": { @@ -1537,7 +2704,6 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -1549,59 +2715,36 @@ "dev": true, "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } + "license": "MIT" }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fastq": { "version": "1.20.1", @@ -1613,13 +2756,30 @@ "reusify": "^1.0.4" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "flat-cache": "^3.0.4" }, @@ -1627,26 +2787,12 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -1664,7 +2810,6 @@ "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", @@ -1675,20 +2820,52 @@ } }, "node_modules/flatted": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", - "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, "license": "ISC", - "peer": true + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/fsevents": { "version": "2.3.3", @@ -1705,12 +2882,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/functional-red-black-tree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, - "license": "MIT" + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/get-east-asian-width": { "version": "1.5.0", @@ -1725,6 +2905,45 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "4.13.6", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", @@ -1745,7 +2964,6 @@ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -1767,7 +2985,6 @@ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -1781,7 +2998,6 @@ "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "type-fest": "^0.20.2" }, @@ -1792,25 +3008,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/graphemer": { @@ -1818,8 +3026,7 @@ "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/has-flag": { "version": "4.0.0", @@ -1827,11 +3034,100 @@ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -1848,6 +3144,19 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1864,7 +3173,6 @@ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -1882,7 +3190,6 @@ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.19" } @@ -1894,7 +3201,6 @@ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -1905,8 +3211,7 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/is-extglob": { "version": "2.1.1", @@ -1947,34 +3252,99 @@ "node": ">=0.10.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } }, "node_modules/js-yaml": { "version": "4.1.1", @@ -1982,7 +3352,6 @@ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "argparse": "^2.0.1" }, @@ -1990,29 +3359,67 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/keyv": { "version": "4.5.4", @@ -2020,7 +3427,6 @@ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "json-buffer": "3.0.1" } @@ -2031,7 +3437,6 @@ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -2088,7 +3493,6 @@ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -2104,8 +3508,7 @@ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/log-update": { "version": "6.1.0", @@ -2186,41 +3589,89 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } + "license": "MIT" }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "engines": { + "node": ">= 0.6" } }, "node_modules/mimic-function": { @@ -2242,7 +3693,6 @@ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -2250,6 +3700,16 @@ "node": "*" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/moment": { "version": "2.29.4", "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", @@ -2267,13 +3727,38 @@ "dev": true, "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" }, "node_modules/obsidian": { "version": "1.12.3", @@ -2296,7 +3781,6 @@ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "wrappy": "1" } @@ -2323,7 +3807,6 @@ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -2342,7 +3825,6 @@ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -2359,7 +3841,6 @@ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -2370,13 +3851,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "callsites": "^3.0.0" }, @@ -2384,13 +3871,25 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -2401,7 +3900,6 @@ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2412,25 +3910,55 @@ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 14.16" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -2440,13 +3968,41 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8.0" } @@ -2473,7 +4029,6 @@ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -2499,26 +4054,12 @@ ], "license": "MIT" }, - "node_modules/regexpp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", - "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4" } @@ -2575,7 +4116,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -2586,6 +4126,58 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -2610,6 +4202,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -2629,7 +4241,6 @@ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -2643,11 +4254,17 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -2661,16 +4278,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/slice-ansi": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", @@ -2701,6 +4308,30 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -2728,6 +4359,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/string-width/node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -2763,7 +4427,20 @@ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -2777,7 +4454,6 @@ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" }, @@ -2785,6 +4461,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/style-mod": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", @@ -2799,7 +4495,6 @@ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -2807,13 +4502,135 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/test-exclude/node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" }, "node_modules/tinyexec": { "version": "1.0.4", @@ -2825,17 +4642,110 @@ "node": ">=18" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { - "is-number": "^7.0.0" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { - "node": ">=8.0" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" } }, "node_modules/tslib": { @@ -2845,29 +4755,6 @@ "dev": true, "license": "0BSD" }, - "node_modules/tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^1.8.1" - }, - "engines": { - "node": ">= 6" - }, - "peerDependencies": { - "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" - } - }, - "node_modules/tsutils/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true, - "license": "0BSD" - }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -3378,7 +5265,6 @@ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "prelude-ls": "^1.2.1" }, @@ -3392,7 +5278,6 @@ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, "license": "(MIT OR CC0-1.0)", - "peer": true, "engines": { "node": ">=10" }, @@ -3401,9 +5286,9 @@ } }, "node_modules/typescript": { - "version": "4.7.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", - "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3411,20 +5296,219 @@ "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "punycode": "^2.1.0" } }, + "node_modules/valibot": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.3.1.tgz", + "integrity": "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -3433,13 +5517,73 @@ "license": "MIT", "peer": true }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "isexe": "^2.0.0" }, @@ -3450,13 +5594,29 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3479,6 +5639,57 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi/node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -3544,13 +5755,51 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "dev": true, "license": "ISC", "bin": { @@ -3569,7 +5818,6 @@ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, diff --git a/package.json b/package.json index 450efcd..874eded 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-note-mover-shortcut", - "version": "0.7.2", + "version": "1.0.0", "description": "Quickly and easily move notes to a predefined folder. Perfect for organizing your notes.", "main": "main.js", "scripts": { @@ -13,24 +13,32 @@ "format:check": "prettier --check .", "lint": "eslint . --ext .ts", "lint:fix": "eslint . --ext .ts --fix", + "test": "vitest run --config vitest.config.mjs", + "test:watch": "vitest --config vitest.config.mjs", "prepare": "husky" }, "keywords": [], "author": "bueckerlars", "license": "MIT", "devDependencies": { - "@types/node": "^16.11.6", - "@typescript-eslint/eslint-plugin": "5.29.0", - "@typescript-eslint/parser": "5.29.0", + "@types/node": "^20.19.39", + "@typescript-eslint/eslint-plugin": "^8.58.2", + "@typescript-eslint/parser": "^8.58.2", + "@vitest/coverage-v8": "^3.2.4", "builtin-modules": "3.3.0", "esbuild": "^0.25.9", + "eslint": "^8.57.1", "husky": "^9.1.7", + "jsdom": "^25.0.1", "lint-staged": "^16.2.1", "obsidian": "latest", "prettier": "^3.6.2", "tslib": "2.4.0", "tsx": "^4.20.6", - "typescript": "4.7.4" + "typescript": "~5.6.3", + "valibot": "^1.3.1", + "vite": "^6.4.2", + "vitest": "^3.2.4" }, "dependencies": { "@popperjs/core": "^2.11.8" diff --git a/scripts/generate-changelog.ts b/scripts/generate-changelog.ts index 9131519..2542aa4 100644 --- a/scripts/generate-changelog.ts +++ b/scripts/generate-changelog.ts @@ -32,6 +32,8 @@ function parseChangelog(content: string): ChangelogEntry[] { // Recognize version header (e.g. "## [0.2.1]" or "## [0.1.6]") const versionMatch = line.match(/^##\s*\[?(\d+\.\d+\.\d+)\]?/); if (versionMatch) { + const nextVersion = versionMatch[1]; + if (!nextVersion) continue; // Save previous version if exists if (currentVersion) { entries.push({ @@ -41,7 +43,7 @@ function parseChangelog(content: string): ChangelogEntry[] { } // Start new version - currentVersion = versionMatch[1]; + currentVersion = nextVersion; currentChanges = {}; currentSection = null; continue; diff --git a/src/application/history-service.ts b/src/application/history-service.ts new file mode 100644 index 0000000..44a412f --- /dev/null +++ b/src/application/history-service.ts @@ -0,0 +1,18 @@ +import type { + HistoryEntry, + BulkOperation, + TimeFilter, +} from 'src/types/HistoryEntry'; +import type NoteMoverShortcutPlugin from 'main'; + +export class HistoryService { + constructor(private readonly plugin: NoteMoverShortcutPlugin) {} + + getFilteredHistory(filter: TimeFilter): HistoryEntry[] { + return this.plugin.historyManager.getFilteredHistory(filter); + } + + getFilteredBulkOperations(filter: TimeFilter): BulkOperation[] { + return this.plugin.historyManager.getFilteredBulkOperations(filter); + } +} diff --git a/src/application/move-file-service.ts b/src/application/move-file-service.ts new file mode 100644 index 0000000..ca9897d --- /dev/null +++ b/src/application/move-file-service.ts @@ -0,0 +1,33 @@ +import type { TFile } from 'obsidian'; +import type NoteMoverShortcutPlugin from 'main'; + +/** + * Application use-case: move files using configured rules (delegates to core). + */ +export class MoveFileService { + constructor(private readonly plugin: NoteMoverShortcutPlugin) {} + + async moveFocusedNoteToDestination(): Promise { + await this.plugin.noteMover.moveFocusedNoteToDestination(); + } + + async moveFileBasedOnTags( + file: TFile, + defaultFolder: string, + skipFilter = false + ): Promise { + return this.plugin.noteMover.moveFileBasedOnTags( + file, + defaultFolder, + skipFilter + ); + } + + async moveAllFilesInVault(opts?: { signal?: AbortSignal }): Promise { + await this.plugin.noteMover.moveAllFilesInVault(opts); + } + + updateRuleManager(): void { + this.plugin.noteMover.updateRuleManager(); + } +} diff --git a/src/application/plugin-application-services.ts b/src/application/plugin-application-services.ts new file mode 100644 index 0000000..d71de86 --- /dev/null +++ b/src/application/plugin-application-services.ts @@ -0,0 +1,20 @@ +import type NoteMoverShortcutPlugin from 'main'; +import { MoveFileService } from './move-file-service'; +import { PreviewService } from './preview-service'; +import { HistoryService } from './history-service'; + +export type PluginApplicationServices = { + moveFile: MoveFileService; + preview: PreviewService; + history: HistoryService; +}; + +export function createPluginApplicationServices( + plugin: NoteMoverShortcutPlugin +): PluginApplicationServices { + return { + moveFile: new MoveFileService(plugin), + preview: new PreviewService(plugin), + history: new HistoryService(plugin), + }; +} diff --git a/src/application/preview-service.ts b/src/application/preview-service.ts new file mode 100644 index 0000000..6fbf54e --- /dev/null +++ b/src/application/preview-service.ts @@ -0,0 +1,14 @@ +import type { MovePreview } from 'src/types/MovePreview'; +import type NoteMoverShortcutPlugin from 'main'; + +export class PreviewService { + constructor(private readonly plugin: NoteMoverShortcutPlugin) {} + + generateVaultMovePreview(): Promise { + return this.plugin.noteMover.generateVaultMovePreview(); + } + + generateActiveNotePreview(): Promise { + return this.plugin.noteMover.generateActiveNotePreview(); + } +} diff --git a/src/core/FileMovementService.ts b/src/core/FileMovementService.ts deleted file mode 100644 index e021699..0000000 --- a/src/core/FileMovementService.ts +++ /dev/null @@ -1,388 +0,0 @@ -import { App, TFile } from 'obsidian'; -import { createError, handleError } from '../utils/Error'; -import { NoticeManager } from '../utils/NoticeManager'; -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 - - constructor( - private app: App, - private plugin: NoteMoverShortcutPlugin - ) {} - - /** - * 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; - } - // 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 a target path already has a file (conflict check) - * - * @param file - The file to be moved - * @param targetPath - The target path to check - * @returns True if a conflict exists (different file at target location) - */ - public checkTargetConflict(file: TFile, targetPath: string): boolean { - // Skip if file is already at target location - if (file.path === targetPath) { - return false; - } - - // Check if target file already exists - const existingFile = this.app.vault.getAbstractFileByPath(targetPath); - if (existingFile && existingFile.path !== file.path) { - return true; - } - - return false; - } - - /** - * 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 { - const { - trackInHistory = true, - showNotifications = false, - originalPath = file.path, - } = options; - - try { - // Skip if file is already in the correct location - if (file.path === targetPath) { - return true; // File is already in the correct location, no need to move - } - - // Check if target file already exists (conflict) - if (this.checkTargetConflict(file, targetPath)) { - if (showNotifications) { - NoticeManager.warning( - `Skipped '${file.name}': File already exists at target location` - ); - } - return false; - } - - // 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 { - 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 { - 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 { - 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; - skipped: number; - errors: string[]; - }> { - const { - trackInHistory = true, - showNotifications = false, - onProgress, - } = options; - - const results = { - successful: 0, - failed: 0, - skipped: 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); - } - - // Check for conflicts before attempting move - const hasConflict = this.checkTargetConflict( - operation.file, - operation.targetPath - ); - - if (hasConflict) { - results.skipped++; - if (showNotifications) { - NoticeManager.warning( - `Skipped '${operation.file.name}': File already exists at target location` - ); - } - continue; - } - - 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 && results.skipped === 0) { - NoticeManager.success( - `Successfully moved ${results.successful} files!` - ); - } else { - const parts: string[] = []; - if (results.successful > 0) { - parts.push(`moved ${results.successful}`); - } - if (results.skipped > 0) { - parts.push(`skipped ${results.skipped}`); - } - if (results.failed > 0) { - parts.push(`${results.failed} errors`); - } - NoticeManager.warning(`Batch operation: ${parts.join(', ')} file(s).`); - } - } - - return results; - } -} diff --git a/src/core/MetadataExtractor.ts b/src/core/MetadataExtractor.ts index 2886c4e..8d79a61 100644 --- a/src/core/MetadataExtractor.ts +++ b/src/core/MetadataExtractor.ts @@ -2,6 +2,8 @@ import { App, TFile } from 'obsidian'; import { getAllTags } from 'obsidian'; import { FileMetadata } from '../types/Common'; import { handleError } from '../utils/Error'; +import { parseListProperty as parseListPropertyDomain } from '../domain/property/parseListProperty'; +import { isListProperty as isListPropertyDomain } from '../domain/property/isListProperty'; /** * Extracts and provides standardized access to file metadata @@ -144,30 +146,7 @@ export class MetadataExtractor { * @returns Array of individual values */ public parseListProperty(value: any): string[] { - if (value === null || value === undefined) { - return []; - } - - // If it's already an array, return it as strings - if (Array.isArray(value)) { - return value - .map(item => String(item).trim()) - .filter(item => item.length > 0); - } - - // If it's a string, try to parse it as comma-separated values - if (typeof value === 'string') { - // Handle both comma-separated and newline-separated values - const values = value - .split(/[,\n]/) - .map(item => item.trim()) - .filter(item => item.length > 0); - - return values; - } - - // For other types, convert to string - return [String(value).trim()].filter(item => item.length > 0); + return parseListPropertyDomain(value); } /** @@ -177,20 +156,7 @@ export class MetadataExtractor { * @returns True if the value represents multiple items */ public isListProperty(value: any): boolean { - if (value === null || value === undefined) { - return false; - } - - if (Array.isArray(value)) { - return value.length > 1; - } - - if (typeof value === 'string') { - // Check if it contains separators (comma or newline) - return /[,\n]/.test(value) && value.split(/[,\n]/).length > 1; - } - - return false; + return isListPropertyDomain(value); } /** @@ -198,9 +164,13 @@ export class MetadataExtractor { * Includes additional fields: extension, links, embeds, headings * * @param file - TFile to extract metadata from + * @param needsContent - When true, reads file body (for blacklist `content:` filters). * @returns Complete FileMetadata with V2 fields */ - public async extractFileMetadataV2(file: TFile): Promise { + public async extractFileMetadataV2( + file: TFile, + needsContent = false + ): Promise { try { const fileName = file.name; const filePath = file.path; @@ -241,12 +211,21 @@ export class MetadataExtractor { } } + let fileContent = ''; + if (needsContent) { + try { + fileContent = await this.app.vault.read(file); + } catch { + // optional + } + } + return { fileName, filePath, tags, properties, - fileContent: '', + fileContent, createdAt, updatedAt, extension, diff --git a/src/core/NoteMoverShortcut.ts b/src/core/NoteMoverShortcut.ts index 317709c..92d50fc 100644 --- a/src/core/NoteMoverShortcut.ts +++ b/src/core/NoteMoverShortcut.ts @@ -1,6 +1,5 @@ import { TFile } from 'obsidian'; import { NoticeManager } from '../utils/NoticeManager'; -import { RuleManager } from './RuleManager'; import { RuleManagerV2 } from './RuleManagerV2'; import { createError, handleError } from '../utils/Error'; import { combinePath, ensureFolderExists } from '../utils/PathUtils'; @@ -15,33 +14,26 @@ import { const BULK_CHUNK_SIZE = 50; export class NoteMoverShortcut { - private ruleManager: RuleManager; private ruleManagerV2: RuleManagerV2; constructor(private plugin: NoteMoverShortcutPlugin) { - this.ruleManager = new RuleManager(plugin.app, '/'); - this.ruleManagerV2 = new RuleManagerV2(plugin.app, '/'); + this.ruleManagerV2 = new RuleManagerV2( + plugin.app, + '/', + plugin.performanceTrace + ); this.updateRuleManager(); } public updateRuleManager(): void { - // Use V1 (legacy) only when legacy mode is enabled; otherwise V2 is default - if (this.plugin.settings.settings.enableLegacyRules === true) { - this.ruleManager.setRules(this.plugin.settings.settings.rules); - this.ruleManager.setFilter( - this.plugin.settings.settings.filters.filter.map(f => f.value) - ); - } else { - const rulesV2 = Array.isArray(this.plugin.settings.settings.rulesV2) - ? this.plugin.settings.settings.rulesV2 - : []; - this.ruleManagerV2.setRules(rulesV2); - this.ruleManagerV2.setFilter( - this.plugin.settings.settings.filters.filter.map(f => f.value) - ); - } + const rulesV2 = Array.isArray(this.plugin.settings.settings.rulesV2) + ? this.plugin.settings.settings.rulesV2 + : []; + this.ruleManagerV2.setRules(rulesV2); + this.ruleManagerV2.setFilter( + this.plugin.settings.settings.filters.filter.map(f => f.value) + ); - // Re-hash the rules config; invalidates the cache when anything changed this.plugin.syncRuleCacheHash(); } @@ -96,169 +88,201 @@ export class NoteMoverShortcut { defaultFolder: string, skipFilter = false ): Promise { - const { app } = this.plugin; - const cache = this.plugin.ruleCache; - const cacheEnabled = - this.plugin.settings.settings.enableRuleEvaluationCache === true; - const originalPath = file.path; - const mtime = file.stat?.mtime ?? 0; + return this.plugin.performanceTrace.recordAsync( + 'NoteMoverShortcut.moveFileBasedOnTags', + async () => { + const { app } = this.plugin; + const cache = this.plugin.ruleCache; + const cacheEnabled = + this.plugin.settings.settings.enableRuleEvaluationCache !== false; + const originalPath = file.path; + const mtime = file.stat?.mtime ?? 0; - // Fast path: if the cache is enabled and already knows the result for - // this file, skip the expensive rule evaluation entirely. - if (cacheEnabled && !cache.needsEvaluation(originalPath, mtime)) { - const cachedDest = cache.getCachedDestination(originalPath); - if (cachedDest === null || cachedDest === undefined) { - return false; // No rule matched last time and nothing changed - } - const cachedPath = combinePath(cachedDest, file.name); - if (originalPath === cachedPath) { - return false; // Already in correct folder - } - } + // Fast path: if the cache is enabled and already knows the result for + // this file, skip the expensive rule evaluation entirely. + if (cacheEnabled && !cache.needsEvaluation(originalPath, mtime)) { + const cachedDest = cache.getCachedDestination(originalPath); + if (cachedDest === null || cachedDest === undefined) { + return false; // No rule matched last time and nothing changed + } + const cachedPath = combinePath(cachedDest, file.name); + if (originalPath === cachedPath) { + return false; // Already in correct folder + } + } - try { - let targetFolder = defaultFolder; + try { + let targetFolder = defaultFolder; - let result: string | null = null; + let result: string | null = null; - if (this.plugin.settings.settings.enableLegacyRules === true) { - result = await this.ruleManager.moveFileBasedOnTags(file, skipFilter); - } else { - result = await this.ruleManagerV2.moveFileBasedOnTags(file, skipFilter); - } + result = await this.ruleManagerV2.moveFileBasedOnTags( + file, + skipFilter + ); - if (cacheEnabled) { - cache.store(originalPath, mtime, result); - } + if (cacheEnabled) { + cache.store(originalPath, mtime, result); + } - if (result === null) { - return false; - } - targetFolder = result; + if (result === null) { + return false; + } + targetFolder = result; - const newPath = combinePath(targetFolder, file.name); + const newPath = combinePath(targetFolder, file.name); - if (originalPath === newPath) { - return false; - } + if (originalPath === newPath) { + return false; + } - if (!(await ensureFolderExists(app, targetFolder))) { - throw createError(`Failed to create target folder: ${targetFolder}`); - } + if (!(await ensureFolderExists(app, targetFolder))) { + throw createError( + `Failed to create target folder: ${targetFolder}` + ); + } - this.plugin.historyManager.markPluginMoveStart(); + this.plugin.historyManager.markPluginMoveStart(); - try { - await app.fileManager.renameFile(file, newPath); + try { + await app.fileManager.renameFile(file, newPath); - this.plugin.historyManager.addEntry({ - sourcePath: originalPath, - destinationPath: newPath, - fileName: file.name, - }); - } finally { - this.plugin.historyManager.markPluginMoveEnd(); - } + this.plugin.historyManager.addEntry({ + sourcePath: originalPath, + destinationPath: newPath, + fileName: file.name, + }); + } finally { + this.plugin.historyManager.markPluginMoveEnd(); + } - return true; - } catch (error) { - handleError(error, `Error moving file '${file.path}'`); - return false; - } + return true; + } catch (error) { + handleError(error, `Error moving file '${file.path}'`); + return false; + } + }, + { path: file.path, skipFilter } + ); } private async moveAllFiles(options: { createFolders: boolean; showNotifications: boolean; operationType: OperationType; + signal?: AbortSignal; }): Promise { - const { app } = this.plugin; + await this.plugin.performanceTrace.recordAsync( + 'NoteMoverShortcut.moveAllFiles', + async () => { + const { app } = this.plugin; - const files = app.vault.getFiles(); + const files = this.plugin.vaultIndexCache.getMarkdownFilesCached(app); - if (files.length === 0) { - if (options.showNotifications && options.operationType === 'bulk') { - NoticeManager.info('No files found in vault'); - } - return; - } - - // Start bulk operation - const bulkOperationId = this.plugin.historyManager.startBulkOperation( - options.operationType - ); - let successCount = 0; - let errorCount = 0; - - try { - for (let i = 0; i < files.length; i++) { - try { - const wasMoved = await this.moveFileBasedOnTags(files[i], '/'); - if (wasMoved) { - successCount++; + if (files.length === 0) { + if (options.showNotifications && options.operationType === 'bulk') { + NoticeManager.info('No markdown files found in vault'); } - } catch (error) { - errorCount++; - const errorMessage = - options.operationType === 'periodic' - ? `Error moving file '${files[i].path}' during periodic movement` - : `Error moving file '${files[i].path}'`; - handleError(error, errorMessage, false); + return; } - // Yield to the UI thread periodically to prevent freezing - if ((i + 1) % BULK_CHUNK_SIZE === 0 && i + 1 < files.length) { - await new Promise(resolve => setTimeout(resolve, 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`; + // Start bulk operation + const bulkOperationId = this.plugin.historyManager.startBulkOperation( + options.operationType + ); + let successCount = 0; + let errorCount = 0; - 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 } - ); + try { + for (let i = 0; i < files.length; i++) { + if (options.signal?.aborted) { + break; + } + try { + const wasMoved = await this.moveFileBasedOnTags(files[i], '/'); + if (wasMoved) { + successCount++; } - }, - SETTINGS_CONSTANTS.UI_TEXTS.UNDO_ALL - ); - } else { - NoticeManager.info( - `Periodic movement: Successfully moved ${successCount} files` - ); + } catch (error) { + errorCount++; + const errorMessage = + options.operationType === 'periodic' + ? `Error moving file '${files[i].path}' during periodic movement` + : `Error moving file '${files[i].path}'`; + handleError(error, errorMessage, false); + } + // Yield to the UI thread periodically to prevent freezing + if ((i + 1) % BULK_CHUNK_SIZE === 0 && i + 1 < files.length) { + await new Promise(resolve => { + const ric = ( + globalThis as typeof globalThis & { + requestIdleCallback?: ( + cb: IdleRequestCallback, + opts?: IdleRequestOptions + ) => number; + } + ).requestIdleCallback; + if (typeof ric === 'function') { + ric(() => resolve(), { timeout: 250 }); + } else { + setTimeout(resolve, 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` + ); + } + } + } finally { + // End bulk operation + this.plugin.historyManager.endBulkOperation(); } - } - } finally { - // End bulk operation - this.plugin.historyManager.endBulkOperation(); - } + }, + { operationType: options.operationType } + ); } - async moveAllFilesInVault() { + async moveAllFilesInVault(opts?: { signal?: AbortSignal }) { await this.moveAllFiles({ createFolders: true, showNotifications: true, operationType: 'bulk', + signal: opts?.signal, }); } @@ -327,48 +351,40 @@ export class NoteMoverShortcut { * Generates a preview of moves for all files in the vault */ async generateVaultMovePreview(): Promise { - const { app } = this.plugin; + return this.plugin.performanceTrace.recordAsync( + 'NoteMoverShortcut.generateVaultMovePreview', + async () => { + const { app } = this.plugin; - const files = app.vault.getFiles(); + const files = this.plugin.vaultIndexCache.getMarkdownFilesCached(app); - if (this.plugin.settings.settings.enableLegacyRules === true) { - return await this.ruleManager.generateMovePreview( - files, - true, // Rules are always enabled - true // Filter is always enabled - ); - } else { - return await this.ruleManagerV2.generateMovePreview( - files, - true, // Rules are always enabled - true // Filter is always enabled - ); - } + return await this.ruleManagerV2.generateMovePreview(files, true, true); + }, + {} + ); } /** * Generates a preview for the currently active note */ async generateActiveNotePreview(): Promise { - const { app } = this.plugin; - const activeFile = app.workspace.getActiveFile(); + return this.plugin.performanceTrace.recordAsync( + 'NoteMoverShortcut.generateActiveNotePreview', + async () => { + const { app } = this.plugin; + const activeFile = app.workspace.getActiveFile(); - if (!activeFile) { - return null; - } + if (!activeFile) { + return null; + } - if (this.plugin.settings.settings.enableLegacyRules === true) { - return await this.ruleManager.generateMovePreview( - [activeFile], - true, - true - ); - } else { - return await this.ruleManagerV2.generateMovePreview( - [activeFile], - true, - true - ); - } + return await this.ruleManagerV2.generateMovePreview( + [activeFile], + true, + true + ); + }, + {} + ); } } diff --git a/src/core/RuleEvaluationCache.ts b/src/core/RuleEvaluationCache.ts index 6ec2ba4..d450599 100644 --- a/src/core/RuleEvaluationCache.ts +++ b/src/core/RuleEvaluationCache.ts @@ -1,4 +1,3 @@ -import { Rule } from '../types/Rule'; import { RuleV2 } from '../types/RuleV2'; import { Filter } from '../types/PluginData'; @@ -24,18 +23,8 @@ export class RuleEvaluationCache { * Recompute the rules hash from the current configuration. * Must be called whenever rules or filters change. */ - public updateRulesHash( - rules: Rule[], - rulesV2: RuleV2[], - filters: Filter[], - enableLegacyRules: boolean - ): void { - const newHash = this.computeRulesHash( - rules, - rulesV2, - filters, - enableLegacyRules - ); + public updateRulesHash(rulesV2: RuleV2[], filters: Filter[]): void { + const newHash = this.computeRulesHash(rulesV2, filters); if (newHash !== this.currentRulesHash) { this.currentRulesHash = newHash; this.invalidateAll(); @@ -103,14 +92,9 @@ export class RuleEvaluationCache { this.dirtyFiles.clear(); } - private computeRulesHash( - rules: Rule[], - rulesV2: RuleV2[], - filters: Filter[], - enableLegacyRules: boolean - ): string { + private computeRulesHash(rulesV2: RuleV2[], filters: Filter[]): string { try { - return JSON.stringify({ enableLegacyRules, rules, rulesV2, filters }); + return JSON.stringify({ rulesV2, filters }); } catch { return ''; } diff --git a/src/core/RuleManager.ts b/src/core/RuleManager.ts deleted file mode 100644 index 5937817..0000000 --- a/src/core/RuleManager.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { App, TFile } from 'obsidian'; -import { NoticeManager } from '../utils/NoticeManager'; -import { Rule } from '../types/Rule'; -import { PreviewEntry, MovePreview } from '../types/MovePreview'; -import { createError, handleError } from '../utils/Error'; -import { combinePath } from '../utils/PathUtils'; -import { MetadataExtractor } from './MetadataExtractor'; -import { RuleMatcher } from './RuleMatcher'; - -/** - * RuleManager for Rule V1 system (legacy). - * Rules V1 is no longer in active development. Used only when Legacy Mode is enabled. - * @deprecated Legacy code. RuleV2 (RuleManagerV2) is the default. Kept for backward compatibility. - */ -export class RuleManager { - private rules: Rule[] = []; - private filter: string[] = []; - private needsContent = false; - private metadataExtractor: MetadataExtractor; - private ruleMatcher: RuleMatcher; - - constructor( - private app: App, - private defaultFolder: string - ) { - this.metadataExtractor = new MetadataExtractor(app); - this.ruleMatcher = new RuleMatcher(this.metadataExtractor); - } - - public setRules(rules: Rule[]): void { - this.rules = rules; - this.updateNeedsContent(); - } - - public setFilter(filter: string[]): void { - this.filter = filter; - this.updateNeedsContent(); - } - - private updateNeedsContent(): void { - const hasContentRule = this.rules.some(r => - r.criteria.trimStart().startsWith('content:') - ); - const hasContentFilter = this.filter.some(f => - f.trimStart().startsWith('content:') - ); - this.needsContent = hasContentRule || hasContentFilter; - } - - /** - * Processes a single file against rules and filters - * - * This method evaluates ONLY the specified file against the configured - * rules and filters. It does NOT scan the entire vault. - * - * @param file - The specific TFile to evaluate - * @param skipFilter - Whether to skip filter evaluation - * @returns Target folder path if rule matches, null otherwise - */ - public async moveFileBasedOnTags( - file: TFile, - skipFilter = false - ): Promise { - try { - const metadata = await this.metadataExtractor.extractFileMetadata( - file, - this.needsContent - ); - - if ( - !skipFilter && - !this.ruleMatcher.evaluateFilter(metadata, this.filter) - ) { - return null; - } - - const matchingRule = this.ruleMatcher.findMatchingRule( - metadata, - this.rules - ); - if (matchingRule) { - return matchingRule.path; - } - - return null; - } 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 = false - ): Promise { - try { - const metadata = await this.metadataExtractor.extractFileMetadata( - file, - this.needsContent - ); - const { tags, fileName, filePath } = metadata; - - // Check filters first - if (!skipFilter) { - const filterDetails = this.ruleMatcher.getFilterMatchDetails( - metadata, - this.filter - ); - 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) { - // Calculate the full target path - const fullTargetPath = combinePath(matchingRule.path, fileName); - - // Check if file is already in the correct location - if (filePath === fullTargetPath) { - return { - fileName, - currentPath: filePath, - targetPath: matchingRule.path, - willBeMoved: false, - blockReason: 'File is already in the correct folder', - matchedRule: matchingRule.criteria, - tags, - }; - } - - return { - fileName, - currentPath: filePath, - targetPath: matchingRule.path, - willBeMoved: true, - matchedRule: matchingRule.criteria, - tags, - }; - } - - // No rule matched - skip the file since only notes with rules should be moved - return { - fileName, - currentPath: filePath, - targetPath: null, - willBeMoved: false, - blockReason: 'No matching rule found', - 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 - ): Promise { - 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: false, // Always blacklist mode - }, - }; - } -} diff --git a/src/core/RuleManagerV2.ts b/src/core/RuleManagerV2.ts index 8dac265..07ccebf 100644 --- a/src/core/RuleManagerV2.ts +++ b/src/core/RuleManagerV2.ts @@ -6,11 +6,13 @@ import { createError, handleError } from '../utils/Error'; import { combinePath } from '../utils/PathUtils'; import { MetadataExtractor } from './MetadataExtractor'; import { RuleMatcherV2 } from './RuleMatcherV2'; -import { RuleMatcher } from './RuleMatcher'; +import { BlacklistFilterEngine } from '../domain/filters/blacklist-filter-engine'; +import { filtersNeedContent } from '../domain/filters/filter-needs-content'; import { DestinationTemplateContext, renderDestinationTemplate, -} from '../utils/DestinationTemplate'; +} from '../domain/templates/DestinationTemplate'; +import type { PerformanceTraceRecorder } from '../infrastructure/debug/performance-trace'; /** * RuleManager for Rule V2 system @@ -25,15 +27,15 @@ export class RuleManagerV2 { private filter: string[] = []; // Filter remain V1-compatible private metadataExtractor: MetadataExtractor; private ruleMatcherV2: RuleMatcherV2; - private ruleMatcherV1: RuleMatcher; // For filter evaluation + private readonly filterEngine = new BlacklistFilterEngine(); constructor( private app: App, - private defaultFolder: string + private defaultFolder: string, + private readonly perf: PerformanceTraceRecorder ) { this.metadataExtractor = new MetadataExtractor(app); this.ruleMatcherV2 = new RuleMatcherV2(this.metadataExtractor); - this.ruleMatcherV1 = new RuleMatcher(this.metadataExtractor); } /** @@ -43,6 +45,7 @@ export class RuleManagerV2 { */ public setRules(rules: RuleV2[]): void { this.rules = rules; + this.ruleMatcherV2.warmRegexCacheFromRules(rules); } /** @@ -55,6 +58,10 @@ export class RuleManagerV2 { this.filter = filter; } + private filterNeedsContent(): boolean { + return filtersNeedContent(this.filter); + } + /** * Processes a single file against rules and filters * @@ -69,49 +76,56 @@ export class RuleManagerV2 { file: TFile, skipFilter = false ): Promise { - try { - // Extract V2 metadata - const metadata = await this.metadataExtractor.extractFileMetadataV2(file); + return this.perf.recordAsync( + 'RuleManagerV2.moveFileBasedOnTags', + async () => { + try { + const metadata = await this.metadataExtractor.extractFileMetadataV2( + file, + this.filterNeedsContent() + ); - // Check if file should be skipped based on filter (using V1 logic) - if ( - !skipFilter && - !this.ruleMatcherV1.evaluateFilter(metadata, this.filter) - ) { - return null; // File is blocked by filter - } + if ( + !skipFilter && + !this.filterEngine.evaluateFilter(metadata, this.filter) + ) { + return null; // File is blocked by filter + } - // Find matching rule using V2 logic - const matchingRule = this.ruleMatcherV2.findMatchingRule( - metadata, - this.rules - ); + // Find matching rule using V2 logic + const matchingRule = this.ruleMatcherV2.findMatchingRule( + metadata, + this.rules + ); - if (matchingRule) { - const targetFolder = this.resolveDestinationTemplate( - matchingRule.destination, - metadata - ); + if (matchingRule) { + const targetFolder = this.resolveDestinationTemplate( + matchingRule.destination, + metadata + ); - // If the destination template resolves to an empty value, treat it as - // "no valid move target" so the file is not moved to the vault root. - if (!targetFolder || targetFolder.trim() === '') { + // If the destination template resolves to an empty value, treat it as + // "no valid move target" so the file is not moved to the vault root. + if (!targetFolder || targetFolder.trim() === '') { + return null; + } + + return targetFolder; + } + + // No rule matched - skip the file since only notes with rules should be moved + return null; + } catch (error) { + handleError( + error, + `Error processing V2 rules for file '${file.path}'`, + false + ); return null; } - - return targetFolder; - } - - // No rule matched - skip the file since only notes with rules should be moved - return null; - } catch (error) { - handleError( - error, - `Error processing V2 rules for file '${file.path}'`, - false - ); - return null; - } + }, + { path: file.path, skipFilter } + ); } /** @@ -126,13 +140,15 @@ export class RuleManagerV2 { skipFilter = false ): Promise { try { - // Extract V2 metadata - const metadata = await this.metadataExtractor.extractFileMetadataV2(file); + const metadata = await this.metadataExtractor.extractFileMetadataV2( + file, + this.filterNeedsContent() + ); const { tags, fileName, filePath } = metadata; // Check filters first (using V1 logic) if (!skipFilter) { - const filterDetails = this.ruleMatcherV1.getFilterMatchDetails( + const filterDetails = this.filterEngine.getFilterMatchDetails( metadata, this.filter ); @@ -241,24 +257,33 @@ export class RuleManagerV2 { enableRules: boolean, enableFilter: boolean ): Promise { - const successfulMoves: PreviewEntry[] = []; + return this.perf.recordAsync( + 'RuleManagerV2.generateMovePreview', + async () => { + const successfulMoves: PreviewEntry[] = []; - for (const file of files) { - const preview = await this.generatePreviewForFile(file, !enableFilter); + 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 - } + if (preview.willBeMoved) { + successfulMoves.push(preview); + } + // Only include files that will be moved - blocked files are ignored + } - return { - successfulMoves, - totalFiles: files.length, - settings: { - isFilterWhitelist: false, // Always blacklist mode + return { + successfulMoves, + totalFiles: files.length, + settings: { + isFilterWhitelist: false, // Always blacklist mode + }, + }; }, - }; + { fileCount: files.length, enableRules, enableFilter } + ); } /** diff --git a/src/core/RuleMatcher.ts b/src/core/RuleMatcher.ts deleted file mode 100644 index 374330c..0000000 --- a/src/core/RuleMatcher.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { FileMetadata } from '../types/Common'; -import { Rule } from '../types/Rule'; -import { MetadataExtractor } from './MetadataExtractor'; - -/** - * Handles all rule and filter matching logic for file processing (Rule V1). - * Extracted from RuleManager to eliminate code duplication and improve reusability. - * Provides centralized matching for tags, properties, and rule evaluation. - * Rules V1 is no longer in active development. Used only when Legacy Mode is enabled. - * - * @since 0.4.0 - * @deprecated Legacy code. RuleMatcherV2 is used by default. Kept for backward compatibility. - */ -export class RuleMatcher { - private metadataExtractor: MetadataExtractor; - private regexCache: Map = new Map(); - - constructor(metadataExtractor?: MetadataExtractor) { - this.metadataExtractor = - metadataExtractor || new MetadataExtractor({} as any); - } - - private getCachedRegex(pattern: string, flags: string): RegExp { - const key = `${pattern}|||${flags}`; - let regex = this.regexCache.get(key); - if (!regex) { - regex = new RegExp(pattern, flags); - this.regexCache.set(key, regex); - } - return regex; - } - /** - * 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 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('?')) { - let regexPattern = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&'); - regexPattern = regexPattern.replace(/\*/g, '.*').replace(/\?/g, '.'); - - const regex = this.getCachedRegex(`^${regexPattern}$`, 'i'); - return regex.test(fileName); - } - - // 3. Case-insensitive exact match (fallback) - return fileName.toLowerCase() === pattern.toLowerCase(); - } - - /** - * Matches frontmatter properties with flexible criteria - * Now supports list properties by checking individual values - * - * @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, - 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; - } - - // Check if this is a list property and handle accordingly - if (this.metadataExtractor.isListProperty(actualValue)) { - return this.matchListProperty(actualValue, expectedValue); - } - - // Convert both to strings for comparison to handle different types - const actualValueStr = String(actualValue).toLowerCase(); - const expectedValueStr = expectedValue.toLowerCase(); - - return actualValueStr === expectedValueStr; - } - } - - /** - * Matches individual values within a list property - * - * @param listValue - The list property value (array or comma-separated string) - * @param expectedValue - The value to match against - * @returns True if any item in the list matches the expected value - */ - private matchListProperty(listValue: any, expectedValue: string): boolean { - const listItems = this.metadataExtractor.parseListProperty(listValue); - const expectedValueLower = expectedValue.toLowerCase(); - - return listItems.some(item => item.toLowerCase() === expectedValueLower); - } - - /** - * 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': - // For path filters, check if the file path starts with the filter value - // This allows folder blacklisting to work (e.g., "Templates" matches "Templates/Meeting Notes.md") - return filePath === value || filePath.startsWith(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 - * Filters now always work as blacklist (exclude matching files) - * - * @param metadata - File metadata object - * @param filter - Array of filter criteria - * @returns True if file should be processed - */ - public evaluateFilter(metadata: FileMetadata, filter: string[]): boolean { - if (filter.length === 0) { - return true; // No filter means all files pass - } - - for (const filterStr of filter) { - const filterMatch = this.evaluateCriteria(metadata, filterStr); - - if (filterMatch) { - return false; // Blacklist: file matches filter, so skip it - } - } - - return true; // File passes filter - } - - /** - * Finds the matching rule with user-defined order priority. - * - * Primary precedence: original user order (first match wins). - * Tiebreaker: if multiple candidates share the same user-order position, - * prefer the more specific tag (more subtag levels). - * - * @param metadata - File metadata object - * @param rules - Array of rules to evaluate - * @returns First matching rule by user order, with specificity as tiebreaker - */ - public findMatchingRule(metadata: FileMetadata, rules: Rule[]): Rule | null { - for (const rule of rules) { - if (this.evaluateCriteria(metadata, rule.criteria)) { - return rule; - } - } - 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 aCriteria = a.criteria.trim(); - const bCriteria = b.criteria.trim(); - const aMatch = aCriteria.match(/^tag:\s*(.*)$/); - const bMatch = bCriteria.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 - * Filters now always work as blacklist (exclude matching files) - * - * @param metadata - File metadata object - * @param filter - Array of filter criteria - * @returns Object with pass/fail status and blocking details - */ - public getFilterMatchDetails( - metadata: FileMetadata, - filter: string[] - ): { - passes: boolean; - blockingFilter?: string; - blockReason?: string; - } { - if (filter.length === 0) { - return { passes: true }; - } - - for (const filterStr of filter) { - const filterMatch = this.evaluateCriteria(metadata, filterStr); - - if (filterMatch) { - return { - passes: false, - blockingFilter: filterStr, - blockReason: 'Blocked by blacklist filter', - }; - } - } - - return { passes: true }; - } -} diff --git a/src/core/RuleMatcherV2.ts b/src/core/RuleMatcherV2.ts index cbe8f76..0160403 100644 --- a/src/core/RuleMatcherV2.ts +++ b/src/core/RuleMatcherV2.ts @@ -1,825 +1,29 @@ -import { FileMetadata } from '../types/Common'; -import { - RuleV2, - Trigger, - AggregationType, - TextOperator, - ListOperator, - DateOperator, - BasePropertyOperator, - TextPropertyOperator, - NumberPropertyOperator, - ListPropertyOperator, - DatePropertyOperator, - CheckboxPropertyOperator, -} from '../types/RuleV2'; -import { MetadataExtractor } from './MetadataExtractor'; +import type { FileMetadata } from '../types/Common'; +import type { RuleV2, Trigger } from '../types/RuleV2'; +import type { MetadataExtractor } from './MetadataExtractor'; +import { RuleMatcherV2Engine } from '../domain/rules/v2/RuleMatcherV2Engine'; /** - * Handles all rule and filter matching logic for RuleV2 system - * - * Provides centralized matching for all CriteriaTypes and Operators with - * type-safe evaluation and aggregation support. - * - * @since 0.5.0 + * Rule V2 matcher — thin adapter over {@link RuleMatcherV2Engine} (domain). */ export class RuleMatcherV2 { - private metadataExtractor: MetadataExtractor; - private regexCache: Map; + private readonly engine = new RuleMatcherV2Engine(); - constructor(metadataExtractor: MetadataExtractor) { - this.metadataExtractor = metadataExtractor; - this.regexCache = new Map(); - } + /** @param _metadataExtractor unused; kept for call-site compatibility. */ + constructor(_metadataExtractor?: MetadataExtractor) {} - /** - * Finds the first matching rule based on user-defined order priority - * - * @param metadata - File metadata object - * @param rules - Array of RuleV2 rules to evaluate - * @returns First matching rule by user order, null if no match - */ public findMatchingRule( metadata: FileMetadata, rules: RuleV2[] ): RuleV2 | null { - for (const rule of rules) { - if (!rule.active) { - continue; // Skip inactive rules - } - - if (rule.triggers.length === 0) { - continue; // Skip rules with no triggers - } - - // Evaluate all triggers for this rule - const triggerResults: boolean[] = []; - for (const trigger of rule.triggers) { - const result = this.evaluateTrigger(metadata, trigger); - triggerResults.push(result); - } - - // Apply aggregation logic - if (this.evaluateAggregation(triggerResults, rule.aggregation)) { - return rule; // First match wins - } - } - - return null; // No rule matched + return this.engine.findMatchingRule(metadata, rules); } - /** - * Evaluates a single trigger against file metadata - * - * @param metadata - File metadata object - * @param trigger - Trigger to evaluate - * @returns True if trigger matches metadata - */ public evaluateTrigger(metadata: FileMetadata, trigger: Trigger): boolean { - switch (trigger.criteriaType) { - case 'fileName': - return this.evaluateTextOperator( - metadata.fileName, - trigger.operator as TextOperator, - trigger.value - ); - - case 'folder': - // Extract folder from filePath - const folder = - metadata.filePath.split('/').slice(0, -1).join('/') || ''; - return this.evaluateTextOperator( - folder, - trigger.operator as TextOperator, - trigger.value - ); - - case 'extension': - const extension = metadata.extension || ''; - return this.evaluateTextOperator( - extension, - trigger.operator as TextOperator, - trigger.value - ); - - case 'headings': - const headings = metadata.headings || []; - return this.evaluateListOperator( - headings, - trigger.operator as ListOperator, - trigger.value - ); - - case 'tag': - return this.evaluateListOperator( - metadata.tags, - trigger.operator as ListOperator, - trigger.value - ); - - case 'links': - const links = metadata.links || []; - return this.evaluateListOperator( - links, - trigger.operator as ListOperator, - trigger.value - ); - - case 'embeds': - const embeds = metadata.embeds || []; - return this.evaluateListOperator( - embeds, - trigger.operator as ListOperator, - trigger.value - ); - - case 'created_at': - return this.evaluateDateOperator( - metadata.createdAt, - trigger.operator as DateOperator, - trigger.value - ); - - case 'modified_at': - return this.evaluateDateOperator( - metadata.updatedAt, - trigger.operator as DateOperator, - trigger.value - ); - - case 'properties': - return this.evaluatePropertyOperator(metadata.properties, trigger); - - default: - return false; // Unknown criteria type - } + return this.engine.evaluateTrigger(metadata, trigger); } - /** - * Evaluates aggregation logic for trigger results - * - * @param results - Array of boolean results from triggers - * @param aggregation - Aggregation type (all, any, none) - * @returns True if aggregation condition is met - */ - private evaluateAggregation( - results: boolean[], - aggregation: AggregationType - ): boolean { - if (results.length === 0) { - return false; // No triggers to evaluate - } - - switch (aggregation) { - case 'all': - return results.every(result => result === true); - - case 'any': - return results.some(result => result === true); - - case 'none': - return results.every(result => result === false); - - default: - return false; // Unknown aggregation type - } - } - - /** - * Evaluates text-based operators (fileName, folder, extension, headings) - * - * @param value - Actual value to test - * @param operator - Text operator to apply - * @param expected - Expected value/pattern - * @returns True if operator condition is met - */ - private evaluateTextOperator( - value: string, - operator: TextOperator, - expected: string - ): boolean { - const valueLower = value.toLowerCase(); - const expectedLower = expected.toLowerCase(); - - switch (operator) { - case 'is': - return valueLower === expectedLower; - - case 'is not': - return valueLower !== expectedLower; - - case 'contains': - return valueLower.includes(expectedLower); - - case 'does not contain': - return !valueLower.includes(expectedLower); - - case 'starts with': - return valueLower.startsWith(expectedLower); - - case 'does not starts with': - return !valueLower.startsWith(expectedLower); - - case 'ends with': - return valueLower.endsWith(expectedLower); - - case 'does not ends with': - return !valueLower.endsWith(expectedLower); - - case 'match regex': - const regex = this.getRegex(expected); - return regex !== null ? regex.test(value) : false; - - case 'does not match regex': - const regexNot = this.getRegex(expected); - return regexNot !== null ? !regexNot.test(value) : true; - - default: - return false; // Unknown operator - } - } - - /** - * Evaluates list-based operators (tag, links, embeds) - * - * @param items - Array of items to test - * @param operator - List operator to apply - * @param expected - Expected value/pattern - * @returns True if operator condition is met - */ - private evaluateListOperator( - items: string[], - operator: ListOperator, - expected: string - ): boolean { - const expectedLower = expected.toLowerCase(); - - switch (operator) { - case 'includes item': - return items.some(item => item.toLowerCase() === expectedLower); - - case 'does not include item': - return !items.some(item => item.toLowerCase() === expectedLower); - - case 'all are': - return ( - items.length > 0 && - items.every(item => item.toLowerCase() === expectedLower) - ); - - case 'all start with': - return ( - items.length > 0 && - items.every(item => item.toLowerCase().startsWith(expectedLower)) - ); - - case 'all end with': - return ( - items.length > 0 && - items.every(item => item.toLowerCase().endsWith(expectedLower)) - ); - - case 'all match regex': - const regexAll = this.getRegex(expected); - return ( - regexAll !== null && - items.length > 0 && - items.every(item => regexAll.test(item)) - ); - - case 'any contain': - return items.some(item => item.toLowerCase().includes(expectedLower)); - - case 'any end with': - return items.some(item => item.toLowerCase().endsWith(expectedLower)); - - case 'any match regex': - const regexAny = this.getRegex(expected); - return regexAny !== null - ? items.some(item => regexAny.test(item)) - : false; - - case 'none contain': - return !items.some(item => item.toLowerCase().includes(expectedLower)); - - case 'none start with': - return !items.some(item => - item.toLowerCase().startsWith(expectedLower) - ); - - case 'none end with': - return !items.some(item => item.toLowerCase().endsWith(expectedLower)); - - case 'count is': - const count = this.parseNumericValue(expected); - return count !== null && items.length === count; - - case 'count is not': - const countNot = this.parseNumericValue(expected); - return countNot !== null && items.length !== countNot; - - case 'count is less than': - const countLess = this.parseNumericValue(expected); - return countLess !== null && items.length < countLess; - - case 'count is more than': - const countMore = this.parseNumericValue(expected); - return countMore !== null && items.length > countMore; - - default: - return false; // Unknown operator - } - } - - /** - * Evaluates date-based operators (created_at, modified_at) - * - * @param date - Date to test (can be null) - * @param operator - Date operator to apply - * @param expected - Expected value/pattern - * @returns True if operator condition is met - */ - private evaluateDateOperator( - date: Date | null, - operator: DateOperator, - expected: string - ): boolean { - if (!date) { - return false; // No date available - } - - const now = new Date(); - const expectedDate = this.parseDate(expected); - - switch (operator) { - case 'is': - return expectedDate ? date.getTime() === expectedDate.getTime() : false; - - case 'is before': - return expectedDate ? date < expectedDate : false; - - case 'is after': - return expectedDate ? date > expectedDate : false; - - case 'time is before': - return expectedDate ? date.getTime() < expectedDate.getTime() : false; - - case 'time is after': - return expectedDate ? date.getTime() > expectedDate.getTime() : false; - - case 'time is before now': - return date < now; - - case 'time is after now': - return date > now; - - case 'date is': - return expectedDate ? this.isSameDate(date, expectedDate) : false; - - case 'date is not': - return expectedDate ? !this.isSameDate(date, expectedDate) : true; - - case 'date is before': - return expectedDate ? this.isDateBefore(date, expectedDate) : false; - - case 'date is after': - return expectedDate ? this.isDateAfter(date, expectedDate) : false; - - case 'date is today': - return this.isSameDate(date, now); - - case 'date is not today': - return !this.isSameDate(date, now); - - case 'is under X days ago': - const daysUnder = this.parseNumericValue(expected); - return daysUnder !== null - ? this.isDaysAgo(date, daysUnder, 'under') - : false; - - case 'is over X days ago': - const daysOver = this.parseNumericValue(expected); - return daysOver !== null - ? this.isDaysAgo(date, daysOver, 'over') - : false; - - case 'day of week is': - return this.isDayOfWeek(date, expected); - - case 'day of week is not': - return !this.isDayOfWeek(date, expected); - - case 'day of week is before': - return this.isDayOfWeekBefore(date, expected); - - case 'day of week is after': - return this.isDayOfWeekAfter(date, expected); - - case 'day of month is': - const dayOfMonth = this.parseNumericValue(expected); - return dayOfMonth !== null && date.getDate() === dayOfMonth; - - case 'day of month is not': - const dayOfMonthNot = this.parseNumericValue(expected); - return dayOfMonthNot !== null && date.getDate() !== dayOfMonthNot; - - case 'day of month is before': - const dayOfMonthBefore = this.parseNumericValue(expected); - return dayOfMonthBefore !== null && date.getDate() < dayOfMonthBefore; - - case 'day of month is after': - const dayOfMonthAfter = this.parseNumericValue(expected); - return dayOfMonthAfter !== null && date.getDate() > dayOfMonthAfter; - - case 'month is': - const month = this.parseNumericValue(expected); - return month !== null && date.getMonth() + 1 === month; // getMonth() is 0-based - - case 'month is not': - const monthNot = this.parseNumericValue(expected); - return monthNot !== null && date.getMonth() + 1 !== monthNot; - - case 'month is before': - const monthBefore = this.parseNumericValue(expected); - return monthBefore !== null && date.getMonth() + 1 < monthBefore; - - case 'month is after': - const monthAfter = this.parseNumericValue(expected); - return monthAfter !== null && date.getMonth() + 1 > monthAfter; - - case 'year is': - const year = this.parseNumericValue(expected); - return year !== null && date.getFullYear() === year; - - case 'year is not': - const yearNot = this.parseNumericValue(expected); - return yearNot !== null && date.getFullYear() !== yearNot; - - case 'year is before': - const yearBefore = this.parseNumericValue(expected); - return yearBefore !== null && date.getFullYear() < yearBefore; - - case 'year is after': - const yearAfter = this.parseNumericValue(expected); - return yearAfter !== null && date.getFullYear() > yearAfter; - - default: - return false; // Unknown operator - } - } - - /** - * Evaluates property-based operators with runtime type checking - * - * @param properties - Properties object from metadata - * @param trigger - Trigger with property information - * @returns True if property condition is met - */ - private evaluatePropertyOperator( - properties: Record, - trigger: Trigger - ): boolean { - if (!trigger.propertyName) { - return false; // No property name specified - } - - const propertyValue = properties[trigger.propertyName]; - const propertyType = trigger.propertyType || 'text'; - - // Handle base property operators first - switch (trigger.operator as BasePropertyOperator) { - case 'has any value': - return ( - propertyValue !== null && - propertyValue !== undefined && - propertyValue !== '' - ); - - case 'has no value': - return ( - propertyValue === null || - propertyValue === undefined || - propertyValue === '' - ); - - case 'property is present': - return properties.hasOwnProperty(trigger.propertyName); - - case 'property is missing': - return !properties.hasOwnProperty(trigger.propertyName); - } - - // Handle type-specific operators - switch (propertyType) { - case 'text': - return this.evaluateTextPropertyOperator( - propertyValue, - trigger.operator as TextPropertyOperator, - trigger.value - ); - - case 'number': - return this.evaluateNumberPropertyOperator( - propertyValue, - trigger.operator as NumberPropertyOperator, - trigger.value - ); - - case 'list': - return this.evaluateListPropertyOperator( - propertyValue, - trigger.operator as ListPropertyOperator, - trigger.value - ); - - case 'date': - return this.evaluateDatePropertyOperator( - propertyValue, - trigger.operator as DatePropertyOperator, - trigger.value - ); - - case 'checkbox': - return this.evaluateCheckboxPropertyOperator( - propertyValue, - trigger.operator as CheckboxPropertyOperator, - trigger.value - ); - - default: - return false; // Unknown property type - } - } - - /** - * Evaluates text property operators - */ - private evaluateTextPropertyOperator( - value: any, - operator: TextPropertyOperator, - expected: string - ): boolean { - if (value === null || value === undefined) { - return false; - } - - const valueStr = String(value); - return this.evaluateTextOperator( - valueStr, - operator as TextOperator, - expected - ); - } - - /** - * Evaluates number property operators - */ - private evaluateNumberPropertyOperator( - value: any, - operator: NumberPropertyOperator, - expected: string - ): boolean { - if (value === null || value === undefined) { - return false; - } - - const numValue = Number(value); - const expectedNum = this.parseNumericValue(expected); - - if (isNaN(numValue) || expectedNum === null) { - return false; - } - - switch (operator) { - case 'equals': - return numValue === expectedNum; - - case 'does not equal': - return numValue !== expectedNum; - - case 'is less than': - return numValue < expectedNum; - - case 'is more than': - return numValue > expectedNum; - - case 'is divisible by': - return expectedNum !== 0 && numValue % expectedNum === 0; - - case 'is not divisible by': - return expectedNum !== 0 && numValue % expectedNum !== 0; - - default: - return false; - } - } - - /** - * Evaluates list property operators - */ - private evaluateListPropertyOperator( - value: any, - operator: ListPropertyOperator, - expected: string - ): boolean { - if (value === null || value === undefined) { - return false; - } - - const listItems = this.metadataExtractor.parseListProperty(value); - return this.evaluateListOperator( - listItems, - operator as ListOperator, - expected - ); - } - - /** - * Evaluates date property operators - */ - private evaluateDatePropertyOperator( - value: any, - operator: DatePropertyOperator, - expected: string - ): boolean { - if (value === null || value === undefined) { - return false; - } - - let date: Date | null = null; - - if (value instanceof Date) { - date = value; - } else if (typeof value === 'string') { - date = new Date(value); - } else if (typeof value === 'number') { - date = new Date(value); - } - - if (!date || isNaN(date.getTime())) { - return false; - } - - return this.evaluateDateOperator(date, operator as DateOperator, expected); - } - - /** - * Evaluates checkbox property operators - */ - private evaluateCheckboxPropertyOperator( - value: any, - operator: CheckboxPropertyOperator, - expected: string - ): boolean { - if (value === null || value === undefined) { - return false; - } - - const boolValue = Boolean(value); - - switch (operator) { - case 'is true': - return boolValue === true; - - case 'is false': - return boolValue === false; - - default: - return false; - } - } - - /** - * Gets compiled regex from cache or compiles new one - * - * @param pattern - Regex pattern string - * @returns Compiled RegExp or null if invalid - */ - private getRegex(pattern: string): RegExp | null { - if (this.regexCache.has(pattern)) { - return this.regexCache.get(pattern)!; - } - - try { - const regex = new RegExp(pattern, 'i'); // Case insensitive - this.regexCache.set(pattern, regex); - return regex; - } catch (error) { - // Invalid regex pattern - don't cache and return null - return null; - } - } - - /** - * Parses numeric value from string - * - * @param value - String to parse - * @returns Parsed number or null if invalid - */ - private parseNumericValue(value: string): number | null { - const parsed = Number(value); - return isNaN(parsed) ? null : parsed; - } - - /** - * Parses date from string - * - * @param value - Date string to parse - * @returns Parsed Date or null if invalid - */ - private parseDate(value: string): Date | null { - const date = new Date(value); - return isNaN(date.getTime()) ? null : date; - } - - /** - * Checks if two dates are the same day (ignoring time) - */ - private isSameDate(date1: Date, date2: Date): boolean { - return ( - date1.getFullYear() === date2.getFullYear() && - date1.getMonth() === date2.getMonth() && - date1.getDate() === date2.getDate() - ); - } - - /** - * Checks if date1 is before date2 (ignoring time) - */ - private isDateBefore(date1: Date, date2: Date): boolean { - const d1 = new Date(date1.getFullYear(), date1.getMonth(), date1.getDate()); - const d2 = new Date(date2.getFullYear(), date2.getMonth(), date2.getDate()); - return d1 < d2; - } - - /** - * Checks if date1 is after date2 (ignoring time) - */ - private isDateAfter(date1: Date, date2: Date): boolean { - const d1 = new Date(date1.getFullYear(), date1.getMonth(), date1.getDate()); - const d2 = new Date(date2.getFullYear(), date2.getMonth(), date2.getDate()); - return d1 > d2; - } - - /** - * Checks if date is under/over X days ago - */ - private isDaysAgo(date: Date, days: number, type: 'under' | 'over'): boolean { - const now = new Date(); - const diffTime = now.getTime() - date.getTime(); - const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); - - return type === 'under' ? diffDays < days : diffDays > days; - } - - /** - * Checks if date matches day of week - */ - private isDayOfWeek(date: Date, expectedDay: string): boolean { - const dayNames = [ - 'sunday', - 'monday', - 'tuesday', - 'wednesday', - 'thursday', - 'friday', - 'saturday', - ]; - const dayIndex = dayNames.indexOf(expectedDay.toLowerCase()); - return dayIndex !== -1 && date.getDay() === dayIndex; - } - - /** - * Checks if date is before expected day of week - */ - private isDayOfWeekBefore(date: Date, expectedDay: string): boolean { - const dayNames = [ - 'sunday', - 'monday', - 'tuesday', - 'wednesday', - 'thursday', - 'friday', - 'saturday', - ]; - const dayIndex = dayNames.indexOf(expectedDay.toLowerCase()); - return dayIndex !== -1 && date.getDay() < dayIndex; - } - - /** - * Checks if date is after expected day of week - */ - private isDayOfWeekAfter(date: Date, expectedDay: string): boolean { - const dayNames = [ - 'sunday', - 'monday', - 'tuesday', - 'wednesday', - 'thursday', - 'friday', - 'saturday', - ]; - const dayIndex = dayNames.indexOf(expectedDay.toLowerCase()); - return dayIndex !== -1 && date.getDay() > dayIndex; + public warmRegexCacheFromRules(rules: RuleV2[]): void { + this.engine.warmRegexCacheFromRules(rules); } } diff --git a/src/core/RuleMigrationService.ts b/src/core/RuleMigrationService.ts index 5b17831..c3f551b 100644 --- a/src/core/RuleMigrationService.ts +++ b/src/core/RuleMigrationService.ts @@ -1,4 +1,3 @@ -import { Rule } from '../types/Rule'; import { RuleV2, Trigger, @@ -6,6 +5,12 @@ import { Operator, TextOperator, } from '../types/RuleV2'; + +/** Persisted Rule V1 row (migration input only; never part of the public settings model after load). */ +export interface LegacyRuleV1 { + criteria: string; + path: string; +} import { getDefaultOperatorForCriteriaType, getDefaultOperatorForPropertyType, @@ -23,7 +28,7 @@ export class RuleMigrationService { * @param rulesV1 - Array of V1 rules to migrate * @returns Array of RuleV2 objects */ - public static migrateRules(rulesV1: Rule[]): RuleV2[] { + public static migrateRules(rulesV1: LegacyRuleV1[]): RuleV2[] { if (!Array.isArray(rulesV1) || rulesV1.length === 0) { return []; } @@ -40,7 +45,10 @@ export class RuleMigrationService { * @param index - Rule index for naming * @returns RuleV2 object or null if migration fails */ - private static migrateRule(ruleV1: Rule, index: number): RuleV2 | null { + private static migrateRule( + ruleV1: LegacyRuleV1, + index: number + ): RuleV2 | null { if (!ruleV1 || !ruleV1.criteria || !ruleV1.path) { console.warn( `[RuleMigration] Rule ${index + 1}: Skipping invalid rule (missing criteria or path)` @@ -316,7 +324,10 @@ export class RuleMigrationService { * @param rulesV2 - V2 rules array * @returns true if migration should be performed */ - public static shouldMigrate(rulesV1: Rule[], rulesV2?: RuleV2[]): boolean { + public static shouldMigrate( + rulesV1: LegacyRuleV1[], + rulesV2?: RuleV2[] + ): boolean { // Migrate if V1 has rules and V2 is empty or undefined return ( Array.isArray(rulesV1) && diff --git a/src/core/TriggerEventHandler.ts b/src/core/TriggerEventHandler.ts index d4c17ad..992a13f 100644 --- a/src/core/TriggerEventHandler.ts +++ b/src/core/TriggerEventHandler.ts @@ -76,7 +76,7 @@ export class TriggerEventHandler { * @param file - The specific TFile that was modified */ private async handleOnEdit(file: TFile): Promise { - if (this.plugin.settings.settings.enableRuleEvaluationCache) { + if (this.plugin.settings.settings.enableRuleEvaluationCache !== false) { // Ensure the file is marked dirty so the cache check re-evaluates it. // The vault event listener in main.ts does the same, but listener // ordering is not guaranteed. diff --git a/src/core/UpdateManager.ts b/src/core/UpdateManager.ts index 2fd490f..62b21aa 100644 --- a/src/core/UpdateManager.ts +++ b/src/core/UpdateManager.ts @@ -1,11 +1,6 @@ -import { TFile } from 'obsidian'; import NoteMoverShortcutPlugin from 'main'; import { UpdateModal } from '../modals/UpdateModal'; import { NoticeManager } from '../utils/NoticeManager'; -import { RuleMatcher } from './RuleMatcher'; -import { FileMovementService } from './FileMovementService'; -import { BaseModal } from 'src/modals/BaseModal'; -import { MetadataExtractor } from './MetadataExtractor'; import { ChangelogEntry, CHANGELOG_ENTRIES } from '../generated/changelog'; export class UpdateManager { diff --git a/src/domain/filters/blacklist-filter-engine.test.ts b/src/domain/filters/blacklist-filter-engine.test.ts new file mode 100644 index 0000000..828ec48 --- /dev/null +++ b/src/domain/filters/blacklist-filter-engine.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import type { FileMetadata } from '../../types/Common'; +import { BlacklistFilterEngine } from './blacklist-filter-engine'; + +function meta(partial: Partial): FileMetadata { + return { + fileName: 'n.md', + filePath: 'Inbox/n.md', + tags: ['#work'], + properties: {}, + fileContent: '', + createdAt: null, + updatedAt: null, + ...partial, + }; +} + +describe('BlacklistFilterEngine', () => { + const engine = new BlacklistFilterEngine(); + + it('passes when filter list is empty', () => { + expect(engine.evaluateFilter(meta({}), [])).toBe(true); + }); + + it('blocks when tag filter matches', () => { + expect( + engine.evaluateFilter(meta({ tags: ['#inbox'] }), ['tag: #inbox']) + ).toBe(false); + }); + + it('getFilterMatchDetails returns blocking filter', () => { + const d = engine.getFilterMatchDetails(meta({ tags: ['#x'] }), ['tag: #x']); + expect(d.passes).toBe(false); + expect(d.blockingFilter).toBe('tag: #x'); + }); +}); diff --git a/src/domain/filters/blacklist-filter-engine.ts b/src/domain/filters/blacklist-filter-engine.ts new file mode 100644 index 0000000..661b9d3 --- /dev/null +++ b/src/domain/filters/blacklist-filter-engine.ts @@ -0,0 +1,153 @@ +import type { FileMetadata } from '../../types/Common'; +import { parseListProperty } from '../property/parseListProperty'; +import { isListProperty } from '../property/isListProperty'; + +/** + * Blacklist filter strings (tag:, fileName:, path:, …) and V1-style criteria evaluation. + * No Obsidian APIs. + */ +export class BlacklistFilterEngine { + private readonly regexCache = new Map(); + + private getCachedRegex(pattern: string, flags: string): RegExp { + const key = `${pattern}|||${flags}`; + let regex = this.regexCache.get(key); + if (!regex) { + regex = new RegExp(pattern, flags); + this.regexCache.set(key, regex); + } + return regex; + } + + matchTag(fileTags: string[], ruleTag: string): boolean { + if (fileTags.includes(ruleTag)) { + return true; + } + return fileTags.some(fileTag => fileTag.startsWith(ruleTag + '/')); + } + + matchFileName(fileName: string, pattern: string): boolean { + if (fileName === pattern) { + return true; + } + if (pattern.includes('*') || pattern.includes('?')) { + let regexPattern = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + regexPattern = regexPattern.replace(/\*/g, '.*').replace(/\?/g, '.'); + const regex = this.getCachedRegex(`^${regexPattern}$`, 'i'); + return regex.test(fileName); + } + return fileName.toLowerCase() === pattern.toLowerCase(); + } + + matchProperty( + properties: Record, + criteria: string + ): boolean { + const colonIndex = criteria.indexOf(':'); + + if (colonIndex === -1) { + const key = criteria.trim(); + return ( + Object.prototype.hasOwnProperty.call(properties, key) && + properties[key] !== undefined && + properties[key] !== null + ); + } + + const key = criteria.substring(0, colonIndex).trim(); + const expectedValue = criteria.substring(colonIndex + 1).trim(); + + if (!Object.prototype.hasOwnProperty.call(properties, key)) { + return false; + } + + const actualValue = properties[key]; + + if (actualValue === null || actualValue === undefined) { + return false; + } + + if (isListProperty(actualValue)) { + const listItems = parseListProperty(actualValue); + const expectedValueLower = expectedValue.toLowerCase(); + return listItems.some(item => item.toLowerCase() === expectedValueLower); + } + + const actualValueStr = String(actualValue).toLowerCase(); + const expectedValueStr = expectedValue.toLowerCase(); + return actualValueStr === expectedValueStr; + } + + evaluateCriteria(metadata: FileMetadata, criteria: string): boolean { + const match = criteria.match(/^([a-zA-Z_]+):\s*(.*)$/); + if (!match || !match[1]) 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 || filePath.startsWith(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; + } + } + + evaluateFilter(metadata: FileMetadata, filter: string[]): boolean { + if (filter.length === 0) { + return true; + } + for (const filterStr of filter) { + if (this.evaluateCriteria(metadata, filterStr)) { + return false; + } + } + return true; + } + + getFilterMatchDetails( + metadata: FileMetadata, + filter: string[] + ): { + passes: boolean; + blockingFilter?: string; + blockReason?: string; + } { + if (filter.length === 0) { + return { passes: true }; + } + for (const filterStr of filter) { + if (this.evaluateCriteria(metadata, filterStr)) { + return { + passes: false, + blockingFilter: filterStr, + blockReason: 'Blocked by blacklist filter', + }; + } + } + return { passes: true }; + } +} diff --git a/src/domain/filters/filter-needs-content.test.ts b/src/domain/filters/filter-needs-content.test.ts new file mode 100644 index 0000000..cf39cd0 --- /dev/null +++ b/src/domain/filters/filter-needs-content.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest'; +import { filtersNeedContent } from './filter-needs-content'; + +describe('filtersNeedContent', () => { + it('returns false for empty or non-content filters', () => { + expect(filtersNeedContent([])).toBe(false); + expect(filtersNeedContent(['tag: x'])).toBe(false); + expect(filtersNeedContent([' path: foo'])).toBe(false); + }); + + it('detects content: filters (case-insensitive)', () => { + expect(filtersNeedContent(['content: hello'])).toBe(true); + expect(filtersNeedContent([' Content: hello'])).toBe(true); + expect(filtersNeedContent(['\tCONTENT:foo'])).toBe(true); + }); +}); diff --git a/src/domain/filters/filter-needs-content.ts b/src/domain/filters/filter-needs-content.ts new file mode 100644 index 0000000..0502b6f --- /dev/null +++ b/src/domain/filters/filter-needs-content.ts @@ -0,0 +1,14 @@ +/** + * Returns true if any blacklist filter may require full file body (vault.read). + * Matches criteria type `content:` (case-insensitive, leading whitespace ignored). + */ +export function filtersNeedContent(filters: string[]): boolean { + for (const raw of filters) { + if (typeof raw !== 'string') continue; + const s = raw.trimStart(); + if (/^content\s*:/i.test(s)) { + return true; + } + } + return false; +} diff --git a/src/domain/property/isListProperty.ts b/src/domain/property/isListProperty.ts new file mode 100644 index 0000000..f3c1772 --- /dev/null +++ b/src/domain/property/isListProperty.ts @@ -0,0 +1,16 @@ +/** Whether a frontmatter value behaves like a multi-value list. */ +export function isListProperty(value: unknown): boolean { + if (value === null || value === undefined) { + return false; + } + + if (Array.isArray(value)) { + return value.length > 1; + } + + if (typeof value === 'string') { + return /[,\n]/.test(value) && value.split(/[,\n]/).length > 1; + } + + return false; +} diff --git a/src/domain/property/parseListProperty.test.ts b/src/domain/property/parseListProperty.test.ts new file mode 100644 index 0000000..48234c4 --- /dev/null +++ b/src/domain/property/parseListProperty.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { parseListProperty } from './parseListProperty'; + +describe('parseListProperty', () => { + it('returns empty for null/undefined', () => { + expect(parseListProperty(null)).toEqual([]); + expect(parseListProperty(undefined)).toEqual([]); + }); + + it('maps array of mixed types', () => { + expect(parseListProperty([' a ', 2, 'b'])).toEqual(['a', '2', 'b']); + }); + + it('splits comma and newline strings', () => { + expect(parseListProperty('a,b\nc')).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/src/domain/property/parseListProperty.ts b/src/domain/property/parseListProperty.ts new file mode 100644 index 0000000..cb8dacc --- /dev/null +++ b/src/domain/property/parseListProperty.ts @@ -0,0 +1,23 @@ +/** + * Normalizes frontmatter list values to string[] (pure domain helper). + */ +export function parseListProperty(value: unknown): string[] { + if (value === null || value === undefined) { + return []; + } + + if (Array.isArray(value)) { + return value + .map(item => String(item).trim()) + .filter(item => item.length > 0); + } + + if (typeof value === 'string') { + return value + .split(/[,\n]/) + .map(item => item.trim()) + .filter(item => item.length > 0); + } + + return [String(value).trim()].filter(item => item.length > 0); +} diff --git a/src/domain/rules/v2/RuleMatcherV2Engine.ts b/src/domain/rules/v2/RuleMatcherV2Engine.ts new file mode 100644 index 0000000..40b01dc --- /dev/null +++ b/src/domain/rules/v2/RuleMatcherV2Engine.ts @@ -0,0 +1,839 @@ +import { FileMetadata } from '../../../types/Common'; +import { + RuleV2, + Trigger, + AggregationType, + TextOperator, + ListOperator, + DateOperator, + BasePropertyOperator, + TextPropertyOperator, + NumberPropertyOperator, + ListPropertyOperator, + DatePropertyOperator, + CheckboxPropertyOperator, +} from '../../../types/RuleV2'; +import { parseListProperty } from '../../property/parseListProperty'; + +/** + * Pure Rule V2 matching engine (no Obsidian dependencies). + */ +export class RuleMatcherV2Engine { + private regexCache: Map; + + constructor() { + this.regexCache = new Map(); + } + + /** + * Finds the first matching rule based on user-defined order priority + * + * @param metadata - File metadata object + * @param rules - Array of RuleV2 rules to evaluate + * @returns First matching rule by user order, null if no match + */ + /** + * Pre-compile regex patterns used by active rules so first match does not pay compile cost. + */ + public warmRegexCacheFromRules(rules: RuleV2[]): void { + const regexOperators = new Set([ + 'match regex', + 'does not match regex', + 'all match regex', + 'any match regex', + ]); + for (const rule of rules) { + if (!rule.active) continue; + for (const t of rule.triggers) { + const op = String(t.operator); + if (regexOperators.has(op) && t.value && t.value.trim() !== '') { + this.getRegex(t.value); + } + } + } + } + + public findMatchingRule( + metadata: FileMetadata, + rules: RuleV2[] + ): RuleV2 | null { + for (const rule of rules) { + if (!rule.active) { + continue; // Skip inactive rules + } + + if (rule.triggers.length === 0) { + continue; // Skip rules with no triggers + } + + // Evaluate all triggers for this rule + const triggerResults: boolean[] = []; + for (const trigger of rule.triggers) { + const result = this.evaluateTrigger(metadata, trigger); + triggerResults.push(result); + } + + // Apply aggregation logic + if (this.evaluateAggregation(triggerResults, rule.aggregation)) { + return rule; // First match wins + } + } + + return null; // No rule matched + } + + /** + * Evaluates a single trigger against file metadata + * + * @param metadata - File metadata object + * @param trigger - Trigger to evaluate + * @returns True if trigger matches metadata + */ + public evaluateTrigger(metadata: FileMetadata, trigger: Trigger): boolean { + switch (trigger.criteriaType) { + case 'fileName': + return this.evaluateTextOperator( + metadata.fileName, + trigger.operator as TextOperator, + trigger.value + ); + + case 'folder': + // Extract folder from filePath + const folder = + metadata.filePath.split('/').slice(0, -1).join('/') || ''; + return this.evaluateTextOperator( + folder, + trigger.operator as TextOperator, + trigger.value + ); + + case 'extension': + const extension = metadata.extension || ''; + return this.evaluateTextOperator( + extension, + trigger.operator as TextOperator, + trigger.value + ); + + case 'headings': + const headings = metadata.headings || []; + return this.evaluateListOperator( + headings, + trigger.operator as ListOperator, + trigger.value + ); + + case 'tag': + return this.evaluateListOperator( + metadata.tags, + trigger.operator as ListOperator, + trigger.value + ); + + case 'links': + const links = metadata.links || []; + return this.evaluateListOperator( + links, + trigger.operator as ListOperator, + trigger.value + ); + + case 'embeds': + const embeds = metadata.embeds || []; + return this.evaluateListOperator( + embeds, + trigger.operator as ListOperator, + trigger.value + ); + + case 'created_at': + return this.evaluateDateOperator( + metadata.createdAt, + trigger.operator as DateOperator, + trigger.value + ); + + case 'modified_at': + return this.evaluateDateOperator( + metadata.updatedAt, + trigger.operator as DateOperator, + trigger.value + ); + + case 'properties': + return this.evaluatePropertyOperator(metadata.properties, trigger); + + default: + return false; // Unknown criteria type + } + } + + /** + * Evaluates aggregation logic for trigger results + * + * @param results - Array of boolean results from triggers + * @param aggregation - Aggregation type (all, any, none) + * @returns True if aggregation condition is met + */ + private evaluateAggregation( + results: boolean[], + aggregation: AggregationType + ): boolean { + if (results.length === 0) { + return false; // No triggers to evaluate + } + + switch (aggregation) { + case 'all': + return results.every(result => result === true); + + case 'any': + return results.some(result => result === true); + + case 'none': + return results.every(result => result === false); + + default: + return false; // Unknown aggregation type + } + } + + /** + * Evaluates text-based operators (fileName, folder, extension, headings) + * + * @param value - Actual value to test + * @param operator - Text operator to apply + * @param expected - Expected value/pattern + * @returns True if operator condition is met + */ + private evaluateTextOperator( + value: string, + operator: TextOperator, + expected: string + ): boolean { + const valueLower = value.toLowerCase(); + const expectedLower = expected.toLowerCase(); + + switch (operator) { + case 'is': + return valueLower === expectedLower; + + case 'is not': + return valueLower !== expectedLower; + + case 'contains': + return valueLower.includes(expectedLower); + + case 'does not contain': + return !valueLower.includes(expectedLower); + + case 'starts with': + return valueLower.startsWith(expectedLower); + + case 'does not starts with': + return !valueLower.startsWith(expectedLower); + + case 'ends with': + return valueLower.endsWith(expectedLower); + + case 'does not ends with': + return !valueLower.endsWith(expectedLower); + + case 'match regex': + const regex = this.getRegex(expected); + return regex !== null ? regex.test(value) : false; + + case 'does not match regex': + const regexNot = this.getRegex(expected); + return regexNot !== null ? !regexNot.test(value) : true; + + default: + return false; // Unknown operator + } + } + + /** + * Evaluates list-based operators (tag, links, embeds) + * + * @param items - Array of items to test + * @param operator - List operator to apply + * @param expected - Expected value/pattern + * @returns True if operator condition is met + */ + private evaluateListOperator( + items: string[], + operator: ListOperator, + expected: string + ): boolean { + const expectedLower = expected.toLowerCase(); + + switch (operator) { + case 'includes item': + return items.some(item => item.toLowerCase() === expectedLower); + + case 'does not include item': + return !items.some(item => item.toLowerCase() === expectedLower); + + case 'all are': + return ( + items.length > 0 && + items.every(item => item.toLowerCase() === expectedLower) + ); + + case 'all start with': + return ( + items.length > 0 && + items.every(item => item.toLowerCase().startsWith(expectedLower)) + ); + + case 'all end with': + return ( + items.length > 0 && + items.every(item => item.toLowerCase().endsWith(expectedLower)) + ); + + case 'all match regex': + const regexAll = this.getRegex(expected); + return ( + regexAll !== null && + items.length > 0 && + items.every(item => regexAll.test(item)) + ); + + case 'any contain': + return items.some(item => item.toLowerCase().includes(expectedLower)); + + case 'any end with': + return items.some(item => item.toLowerCase().endsWith(expectedLower)); + + case 'any match regex': + const regexAny = this.getRegex(expected); + return regexAny !== null + ? items.some(item => regexAny.test(item)) + : false; + + case 'none contain': + return !items.some(item => item.toLowerCase().includes(expectedLower)); + + case 'none start with': + return !items.some(item => + item.toLowerCase().startsWith(expectedLower) + ); + + case 'none end with': + return !items.some(item => item.toLowerCase().endsWith(expectedLower)); + + case 'count is': + const count = this.parseNumericValue(expected); + return count !== null && items.length === count; + + case 'count is not': + const countNot = this.parseNumericValue(expected); + return countNot !== null && items.length !== countNot; + + case 'count is less than': + const countLess = this.parseNumericValue(expected); + return countLess !== null && items.length < countLess; + + case 'count is more than': + const countMore = this.parseNumericValue(expected); + return countMore !== null && items.length > countMore; + + default: + return false; // Unknown operator + } + } + + /** + * Evaluates date-based operators (created_at, modified_at) + * + * @param date - Date to test (can be null) + * @param operator - Date operator to apply + * @param expected - Expected value/pattern + * @returns True if operator condition is met + */ + private evaluateDateOperator( + date: Date | null, + operator: DateOperator, + expected: string + ): boolean { + if (!date) { + return false; // No date available + } + + const now = new Date(); + const expectedDate = this.parseDate(expected); + + switch (operator) { + case 'is': + return expectedDate ? date.getTime() === expectedDate.getTime() : false; + + case 'is before': + return expectedDate ? date < expectedDate : false; + + case 'is after': + return expectedDate ? date > expectedDate : false; + + case 'time is before': + return expectedDate ? date.getTime() < expectedDate.getTime() : false; + + case 'time is after': + return expectedDate ? date.getTime() > expectedDate.getTime() : false; + + case 'time is before now': + return date < now; + + case 'time is after now': + return date > now; + + case 'date is': + return expectedDate ? this.isSameDate(date, expectedDate) : false; + + case 'date is not': + return expectedDate ? !this.isSameDate(date, expectedDate) : true; + + case 'date is before': + return expectedDate ? this.isDateBefore(date, expectedDate) : false; + + case 'date is after': + return expectedDate ? this.isDateAfter(date, expectedDate) : false; + + case 'date is today': + return this.isSameDate(date, now); + + case 'date is not today': + return !this.isSameDate(date, now); + + case 'is under X days ago': + const daysUnder = this.parseNumericValue(expected); + return daysUnder !== null + ? this.isDaysAgo(date, daysUnder, 'under') + : false; + + case 'is over X days ago': + const daysOver = this.parseNumericValue(expected); + return daysOver !== null + ? this.isDaysAgo(date, daysOver, 'over') + : false; + + case 'day of week is': + return this.isDayOfWeek(date, expected); + + case 'day of week is not': + return !this.isDayOfWeek(date, expected); + + case 'day of week is before': + return this.isDayOfWeekBefore(date, expected); + + case 'day of week is after': + return this.isDayOfWeekAfter(date, expected); + + case 'day of month is': + const dayOfMonth = this.parseNumericValue(expected); + return dayOfMonth !== null && date.getDate() === dayOfMonth; + + case 'day of month is not': + const dayOfMonthNot = this.parseNumericValue(expected); + return dayOfMonthNot !== null && date.getDate() !== dayOfMonthNot; + + case 'day of month is before': + const dayOfMonthBefore = this.parseNumericValue(expected); + return dayOfMonthBefore !== null && date.getDate() < dayOfMonthBefore; + + case 'day of month is after': + const dayOfMonthAfter = this.parseNumericValue(expected); + return dayOfMonthAfter !== null && date.getDate() > dayOfMonthAfter; + + case 'month is': + const month = this.parseNumericValue(expected); + return month !== null && date.getMonth() + 1 === month; // getMonth() is 0-based + + case 'month is not': + const monthNot = this.parseNumericValue(expected); + return monthNot !== null && date.getMonth() + 1 !== monthNot; + + case 'month is before': + const monthBefore = this.parseNumericValue(expected); + return monthBefore !== null && date.getMonth() + 1 < monthBefore; + + case 'month is after': + const monthAfter = this.parseNumericValue(expected); + return monthAfter !== null && date.getMonth() + 1 > monthAfter; + + case 'year is': + const year = this.parseNumericValue(expected); + return year !== null && date.getFullYear() === year; + + case 'year is not': + const yearNot = this.parseNumericValue(expected); + return yearNot !== null && date.getFullYear() !== yearNot; + + case 'year is before': + const yearBefore = this.parseNumericValue(expected); + return yearBefore !== null && date.getFullYear() < yearBefore; + + case 'year is after': + const yearAfter = this.parseNumericValue(expected); + return yearAfter !== null && date.getFullYear() > yearAfter; + + default: + return false; // Unknown operator + } + } + + /** + * Evaluates property-based operators with runtime type checking + * + * @param properties - Properties object from metadata + * @param trigger - Trigger with property information + * @returns True if property condition is met + */ + private evaluatePropertyOperator( + properties: Record, + trigger: Trigger + ): boolean { + if (!trigger.propertyName) { + return false; // No property name specified + } + + const propertyValue = properties[trigger.propertyName]; + const propertyType = trigger.propertyType || 'text'; + + // Handle base property operators first + switch (trigger.operator as BasePropertyOperator) { + case 'has any value': + return ( + propertyValue !== null && + propertyValue !== undefined && + propertyValue !== '' + ); + + case 'has no value': + return ( + propertyValue === null || + propertyValue === undefined || + propertyValue === '' + ); + + case 'property is present': + return properties.hasOwnProperty(trigger.propertyName); + + case 'property is missing': + return !properties.hasOwnProperty(trigger.propertyName); + } + + // Handle type-specific operators + switch (propertyType) { + case 'text': + return this.evaluateTextPropertyOperator( + propertyValue, + trigger.operator as TextPropertyOperator, + trigger.value + ); + + case 'number': + return this.evaluateNumberPropertyOperator( + propertyValue, + trigger.operator as NumberPropertyOperator, + trigger.value + ); + + case 'list': + return this.evaluateListPropertyOperator( + propertyValue, + trigger.operator as ListPropertyOperator, + trigger.value + ); + + case 'date': + return this.evaluateDatePropertyOperator( + propertyValue, + trigger.operator as DatePropertyOperator, + trigger.value + ); + + case 'checkbox': + return this.evaluateCheckboxPropertyOperator( + propertyValue, + trigger.operator as CheckboxPropertyOperator, + trigger.value + ); + + default: + return false; // Unknown property type + } + } + + /** + * Evaluates text property operators + */ + private evaluateTextPropertyOperator( + value: any, + operator: TextPropertyOperator, + expected: string + ): boolean { + if (value === null || value === undefined) { + return false; + } + + const valueStr = String(value); + return this.evaluateTextOperator( + valueStr, + operator as TextOperator, + expected + ); + } + + /** + * Evaluates number property operators + */ + private evaluateNumberPropertyOperator( + value: any, + operator: NumberPropertyOperator, + expected: string + ): boolean { + if (value === null || value === undefined) { + return false; + } + + const numValue = Number(value); + const expectedNum = this.parseNumericValue(expected); + + if (isNaN(numValue) || expectedNum === null) { + return false; + } + + switch (operator) { + case 'equals': + return numValue === expectedNum; + + case 'does not equal': + return numValue !== expectedNum; + + case 'is less than': + return numValue < expectedNum; + + case 'is more than': + return numValue > expectedNum; + + case 'is divisible by': + return expectedNum !== 0 && numValue % expectedNum === 0; + + case 'is not divisible by': + return expectedNum !== 0 && numValue % expectedNum !== 0; + + default: + return false; + } + } + + /** + * Evaluates list property operators + */ + private evaluateListPropertyOperator( + value: any, + operator: ListPropertyOperator, + expected: string + ): boolean { + if (value === null || value === undefined) { + return false; + } + + const listItems = parseListProperty(value); + return this.evaluateListOperator( + listItems, + operator as ListOperator, + expected + ); + } + + /** + * Evaluates date property operators + */ + private evaluateDatePropertyOperator( + value: any, + operator: DatePropertyOperator, + expected: string + ): boolean { + if (value === null || value === undefined) { + return false; + } + + let date: Date | null = null; + + if (value instanceof Date) { + date = value; + } else if (typeof value === 'string') { + date = new Date(value); + } else if (typeof value === 'number') { + date = new Date(value); + } + + if (!date || isNaN(date.getTime())) { + return false; + } + + return this.evaluateDateOperator(date, operator as DateOperator, expected); + } + + /** + * Evaluates checkbox property operators + */ + private evaluateCheckboxPropertyOperator( + value: any, + operator: CheckboxPropertyOperator, + expected: string + ): boolean { + if (value === null || value === undefined) { + return false; + } + + const boolValue = Boolean(value); + + switch (operator) { + case 'is true': + return boolValue === true; + + case 'is false': + return boolValue === false; + + default: + return false; + } + } + + /** + * Gets compiled regex from cache or compiles new one + * + * @param pattern - Regex pattern string + * @returns Compiled RegExp or null if invalid + */ + private getRegex(pattern: string): RegExp | null { + if (this.regexCache.has(pattern)) { + return this.regexCache.get(pattern)!; + } + + try { + const regex = new RegExp(pattern, 'i'); // Case insensitive + this.regexCache.set(pattern, regex); + return regex; + } catch (error) { + // Invalid regex pattern - don't cache and return null + return null; + } + } + + /** + * Parses numeric value from string + * + * @param value - String to parse + * @returns Parsed number or null if invalid + */ + private parseNumericValue(value: string): number | null { + const parsed = Number(value); + return isNaN(parsed) ? null : parsed; + } + + /** + * Parses date from string + * + * @param value - Date string to parse + * @returns Parsed Date or null if invalid + */ + private parseDate(value: string): Date | null { + const date = new Date(value); + return isNaN(date.getTime()) ? null : date; + } + + /** + * Checks if two dates are the same day (ignoring time) + */ + private isSameDate(date1: Date, date2: Date): boolean { + return ( + date1.getFullYear() === date2.getFullYear() && + date1.getMonth() === date2.getMonth() && + date1.getDate() === date2.getDate() + ); + } + + /** + * Checks if date1 is before date2 (ignoring time) + */ + private isDateBefore(date1: Date, date2: Date): boolean { + const d1 = new Date(date1.getFullYear(), date1.getMonth(), date1.getDate()); + const d2 = new Date(date2.getFullYear(), date2.getMonth(), date2.getDate()); + return d1 < d2; + } + + /** + * Checks if date1 is after date2 (ignoring time) + */ + private isDateAfter(date1: Date, date2: Date): boolean { + const d1 = new Date(date1.getFullYear(), date1.getMonth(), date1.getDate()); + const d2 = new Date(date2.getFullYear(), date2.getMonth(), date2.getDate()); + return d1 > d2; + } + + /** + * Checks if date is under/over X days ago + */ + private isDaysAgo(date: Date, days: number, type: 'under' | 'over'): boolean { + const now = new Date(); + const diffTime = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); + + return type === 'under' ? diffDays < days : diffDays > days; + } + + /** + * Checks if date matches day of week + */ + private isDayOfWeek(date: Date, expectedDay: string): boolean { + const dayNames = [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + ]; + const dayIndex = dayNames.indexOf(expectedDay.toLowerCase()); + return dayIndex !== -1 && date.getDay() === dayIndex; + } + + /** + * Checks if date is before expected day of week + */ + private isDayOfWeekBefore(date: Date, expectedDay: string): boolean { + const dayNames = [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + ]; + const dayIndex = dayNames.indexOf(expectedDay.toLowerCase()); + return dayIndex !== -1 && date.getDay() < dayIndex; + } + + /** + * Checks if date is after expected day of week + */ + private isDayOfWeekAfter(date: Date, expectedDay: string): boolean { + const dayNames = [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + ]; + const dayIndex = dayNames.indexOf(expectedDay.toLowerCase()); + return dayIndex !== -1 && date.getDay() > dayIndex; + } +} diff --git a/src/domain/rules/v2/rule-matcher-v2-engine.test.ts b/src/domain/rules/v2/rule-matcher-v2-engine.test.ts new file mode 100644 index 0000000..baeea8e --- /dev/null +++ b/src/domain/rules/v2/rule-matcher-v2-engine.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import type { FileMetadata } from '../../../types/Common'; +import type { RuleV2 } from '../../../types/RuleV2'; +import { RuleMatcherV2Engine } from './RuleMatcherV2Engine'; + +const baseMeta: FileMetadata = { + fileName: 'note.md', + filePath: 'Inbox/note.md', + tags: [], + properties: {}, + fileContent: '', + createdAt: null, + updatedAt: null, +}; + +describe('RuleMatcherV2Engine', () => { + it('finds first active rule by fileName', () => { + const engine = new RuleMatcherV2Engine(); + const rules: RuleV2[] = [ + { + name: 'to-proj', + destination: 'Projects', + aggregation: 'any', + active: true, + triggers: [ + { + criteriaType: 'fileName', + operator: 'contains', + value: 'note', + }, + ], + }, + ]; + const hit = engine.findMatchingRule(baseMeta, rules); + expect(hit?.destination).toBe('Projects'); + }); + + it('warmRegexCacheFromRules pre-compiles regex triggers', () => { + const engine = new RuleMatcherV2Engine(); + const rules: RuleV2[] = [ + { + name: 'regex-rule', + destination: 'Out', + aggregation: 'any', + active: true, + triggers: [ + { + criteriaType: 'fileName', + operator: 'match regex', + value: '^note', + }, + ], + }, + ]; + engine.warmRegexCacheFromRules(rules); + const meta: FileMetadata = { ...baseMeta, fileName: 'note.md' }; + expect(engine.findMatchingRule(meta, rules)?.destination).toBe('Out'); + }); +}); diff --git a/src/utils/DestinationTemplate.ts b/src/domain/templates/DestinationTemplate.ts similarity index 100% rename from src/utils/DestinationTemplate.ts rename to src/domain/templates/DestinationTemplate.ts diff --git a/src/domain/templates/destination-template.test.ts b/src/domain/templates/destination-template.test.ts new file mode 100644 index 0000000..f4a9b8b --- /dev/null +++ b/src/domain/templates/destination-template.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { + validateDestinationTemplate, + renderDestinationTemplate, +} from './DestinationTemplate'; + +describe('DestinationTemplate', () => { + it('validates plain path', () => { + expect(validateDestinationTemplate('Projects/A').isValid).toBe(true); + }); + + it('rejects unclosed placeholder', () => { + const r = validateDestinationTemplate('a/{{tag.foo'); + expect(r.isValid).toBe(false); + }); + + it('renders tag and property placeholders', () => { + const out = renderDestinationTemplate( + 'P/{{property.status}}/{{tag.tasks}}', + { + tags: ['#tasks/personal'], + properties: { status: 'Done' }, + } + ); + expect(out).toContain('Done'); + expect(out).toContain('tasks/personal'); + }); +}); diff --git a/src/handlers/CommandHandler.ts b/src/handlers/CommandHandler.ts index 33cb206..cc91cf4 100644 --- a/src/handlers/CommandHandler.ts +++ b/src/handlers/CommandHandler.ts @@ -1,5 +1,6 @@ import NoteMoverShortcutPlugin from 'main'; -import { Editor, MarkdownView, Notice } from 'obsidian'; +import type { MarkdownFileInfo } from 'obsidian'; +import { Editor, MarkdownView } from 'obsidian'; import { HistoryModal } from '../modals/HistoryModal'; import { UpdateModal } from '../modals/UpdateModal'; import { PreviewModal } from '../modals/PreviewModal'; @@ -14,7 +15,10 @@ export class CommandHandler { this.plugin.addCommand({ id: 'trigger-note-movement', name: 'Move active note to note folder', - editorCallback: (editor: Editor, view: MarkdownView) => { + editorCallback: ( + _editor: Editor, + _ctx: MarkdownView | MarkdownFileInfo + ) => { this.plugin.noteMover.moveFocusedNoteToDestination(); }, }); @@ -72,7 +76,10 @@ export class CommandHandler { this.plugin.addCommand({ id: 'preview-note-movement', name: 'Preview active note movement', - editorCallback: async (editor: Editor, view: MarkdownView) => { + editorCallback: async ( + _editor: Editor, + _ctx: MarkdownView | MarkdownFileInfo + ) => { try { const preview = await this.plugin.noteMover.generateActiveNotePreview(); @@ -94,9 +101,12 @@ export class CommandHandler { this.plugin.addCommand({ id: 'add-current-file-to-blacklist', name: 'Add current file to blacklist', - editorCallback: async (editor: Editor, view: MarkdownView) => { + editorCallback: async ( + _editor: Editor, + ctx: MarkdownView | MarkdownFileInfo + ) => { try { - const fileName = view.file?.name; + const fileName = ctx.file?.name; if (!fileName) { NoticeManager.warning('No active file to add to blacklist.'); return; diff --git a/src/infrastructure/cache/plugin-vault-index-cache.test.ts b/src/infrastructure/cache/plugin-vault-index-cache.test.ts new file mode 100644 index 0000000..b8f50c9 --- /dev/null +++ b/src/infrastructure/cache/plugin-vault-index-cache.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import type { App } from 'obsidian'; +import { PluginVaultIndexCache } from './plugin-vault-index-cache'; + +function createMockApp(getMarkdownFiles: () => unknown[]): App { + return { + vault: { getMarkdownFiles }, + metadataCache: { + getFileCache: () => ({ frontmatter: {} }), + }, + } as unknown as App; +} + +describe('PluginVaultIndexCache', () => { + it('caches getMarkdownFiles until invalidateMarkdownList', () => { + let calls = 0; + const app = createMockApp(() => { + calls++; + return []; + }); + const cache = new PluginVaultIndexCache(); + cache.getMarkdownFilesCached(app); + cache.getMarkdownFilesCached(app); + expect(calls).toBe(1); + cache.invalidateMarkdownList(); + cache.getMarkdownFilesCached(app); + expect(calls).toBe(2); + }); + + it('clears tag index when markdown list is invalidated', () => { + const app = createMockApp(() => []); + const cache = new PluginVaultIndexCache(); + cache.getAllTagsCached(app); + cache.invalidateMarkdownList(); + cache.getAllTagsCached(app); + // No throw; second call rebuilds after generation bump + expect(cache.getMarkdownFilesCached(app)).toEqual([]); + }); +}); diff --git a/src/infrastructure/cache/plugin-vault-index-cache.ts b/src/infrastructure/cache/plugin-vault-index-cache.ts new file mode 100644 index 0000000..75df09e --- /dev/null +++ b/src/infrastructure/cache/plugin-vault-index-cache.ts @@ -0,0 +1,363 @@ +import type { App, Plugin } from 'obsidian'; +import { TAbstractFile, TFile, getAllTags } from 'obsidian'; +import { parseListProperty } from '../../domain/property/parseListProperty'; +import { isListProperty } from '../../domain/property/isListProperty'; +import type { PerformanceTraceRecorder } from '../debug/performance-trace'; + +const METADATA_INDEX_INVALIDATE_MS = 400; + +/** + * Central vault index cache: markdown file list + derived tag/property indices. + * Invalidated via vault events (see registerVaultEventHooks) and debounced metadata updates. + */ +export class PluginVaultIndexCache { + private cacheEnabledResolver: () => boolean = () => true; + private perf: PerformanceTraceRecorder | null = null; + + private markdownListGeneration = 0; + private cachedMarkdownFiles: TFile[] | null = null; + private listFilledAtGeneration = -1; + + private indicesGeneration = 0; + private tagSet: Set | null = null; + private tagsBuiltForGeneration = -1; + + private readonly propertyValuesByName = new Map< + string, + { generation: number; values: Set } + >(); + + private propertySuggestBundle: { + generation: number; + keys: Set; + valuesByKey: Map>; + } | null = null; + + private metadataDebounceTimer: ReturnType | null = null; + + /** When resolver returns false, all reads bypass in-memory indices. */ + setCacheEnabledResolver(resolver: () => boolean): void { + this.cacheEnabledResolver = resolver; + } + + setPerformanceRecorder(recorder: PerformanceTraceRecorder | null): void { + this.perf = recorder; + } + + private traceSync( + name: string, + fn: () => T, + meta?: Record + ): T { + return this.perf ? this.perf.recordSync(name, fn, meta) : fn(); + } + + /** + * Markdown paths changed (create / delete / rename). Refreshes file list and derived indices. + */ + invalidateMarkdownList(): void { + this.markdownListGeneration++; + this.cachedMarkdownFiles = null; + this.listFilledAtGeneration = -1; + this.bumpIndicesGenerationImmediate(); + } + + /** + * Frontmatter/tags may have changed without structural vault change. Debounced. + */ + scheduleMetadataDerivedInvalidate(): void { + if (this.metadataDebounceTimer !== null) { + clearTimeout(this.metadataDebounceTimer); + } + this.metadataDebounceTimer = setTimeout(() => { + this.metadataDebounceTimer = null; + this.bumpIndicesGenerationImmediate(); + }, METADATA_INDEX_INVALIDATE_MS); + } + + private bumpIndicesGenerationImmediate(): void { + this.indicesGeneration++; + this.tagSet = null; + this.tagsBuiltForGeneration = -1; + this.propertyValuesByName.clear(); + this.propertySuggestBundle = null; + } + + getMarkdownFilesCached(app: App): TFile[] { + return this.traceSync( + 'PluginVaultIndexCache.getMarkdownFilesCached', + () => { + if (!this.cacheEnabledResolver()) { + return app.vault.getMarkdownFiles(); + } + if ( + this.cachedMarkdownFiles !== null && + this.listFilledAtGeneration === this.markdownListGeneration + ) { + return this.cachedMarkdownFiles; + } + const files = app.vault.getMarkdownFiles(); + this.cachedMarkdownFiles = files; + this.listFilledAtGeneration = this.markdownListGeneration; + return files; + }, + { cacheEnabled: this.cacheEnabledResolver() } + ); + } + + getAllTagsCached(app: App): Set { + return this.traceSync('PluginVaultIndexCache.getAllTagsCached', () => { + if (!this.cacheEnabledResolver()) { + const tags = new Set(); + const files = app.vault.getMarkdownFiles(); + for (const file of files) { + const fileCache = app.metadataCache.getFileCache(file); + const fileTags = getAllTags(fileCache || {}) || []; + for (const t of fileTags) { + tags.add(t); + } + } + return tags; + } + if ( + this.tagSet !== null && + this.tagsBuiltForGeneration === this.indicesGeneration + ) { + return this.tagSet; + } + const tags = new Set(); + const files = this.getMarkdownFilesCached(app); + for (const file of files) { + const fileCache = app.metadataCache.getFileCache(file); + const fileTags = getAllTags(fileCache || {}) || []; + for (const t of fileTags) { + tags.add(t); + } + } + this.tagSet = tags; + this.tagsBuiltForGeneration = this.indicesGeneration; + return tags; + }); + } + + /** + * Collect distinct values for a frontmatter property (same semantics as PropertyValueSuggest). + */ + getPropertyValueSetCached( + app: App, + propertyName: string, + propertyType: 'text' | 'number' | 'checkbox' | 'date' | 'list' + ): Set { + return this.traceSync( + 'PluginVaultIndexCache.getPropertyValueSetCached', + () => { + if (!this.cacheEnabledResolver()) { + const values = new Set(); + if (propertyType === 'date') { + return values; + } + const files = app.vault.getMarkdownFiles(); + for (const file of files) { + const cache = app.metadataCache.getFileCache(file); + if (cache?.frontmatter?.[propertyName] !== undefined) { + const raw = cache.frontmatter[propertyName]; + this.addPropertySampleToSet(values, raw, propertyType); + } + } + return values; + } + + const existing = this.propertyValuesByName.get(propertyName); + if (existing && existing.generation === this.indicesGeneration) { + return existing.values; + } + const values = new Set(); + if (propertyType === 'date') { + this.propertyValuesByName.set(propertyName, { + generation: this.indicesGeneration, + values, + }); + return values; + } + + const files = this.getMarkdownFilesCached(app); + for (const file of files) { + const cache = app.metadataCache.getFileCache(file); + if (cache?.frontmatter?.[propertyName] !== undefined) { + const raw = cache.frontmatter[propertyName]; + this.addPropertySampleToSet(values, raw, propertyType); + } + } + + this.propertyValuesByName.set(propertyName, { + generation: this.indicesGeneration, + values, + }); + return values; + }, + { propertyName, propertyType } + ); + } + + private addPropertySampleToSet( + target: Set, + value: unknown, + propertyType: 'text' | 'number' | 'checkbox' | 'list' + ): void { + switch (propertyType) { + case 'text': + if (typeof value === 'string' && value.trim()) { + target.add(value.trim()); + } + break; + case 'number': + if (typeof value === 'number') { + target.add(value.toString()); + } else if (typeof value === 'string' && this.isNumberString(value)) { + target.add(value.trim()); + } + break; + case 'checkbox': + if (typeof value === 'boolean') { + target.add(value.toString()); + } else if (typeof value === 'string' && this.isBooleanString(value)) { + target.add(value.trim().toLowerCase()); + } + break; + case 'list': + if (Array.isArray(value)) { + value.forEach(item => { + if (typeof item === 'string' && item.trim()) { + target.add(item.trim()); + } + }); + } else if (typeof value === 'string' && isListProperty(value)) { + parseListProperty(value).forEach(item => { + if (item.trim()) { + target.add(item.trim()); + } + }); + } + break; + default: + break; + } + } + + private isNumberString(value: string): boolean { + const trimmed = value.trim(); + if (/^-?\d+(\.\d+)?$/.test(trimmed)) { + const num = parseFloat(trimmed); + return !isNaN(num) && isFinite(num); + } + return false; + } + + private isBooleanString(value: string): boolean { + const trimmed = value.trim().toLowerCase(); + return ( + trimmed === 'true' || + trimmed === 'false' || + trimmed === 'yes' || + trimmed === 'no' || + trimmed === 'on' || + trimmed === 'off' || + trimmed === '1' || + trimmed === '0' + ); + } + + /** + * Collect property keys and per-key value sets (AdvancedSuggest blacklist UI). + */ + getPropertyKeysAndValuesCached(app: App): { + keys: Set; + valuesByKey: Map>; + } { + return this.traceSync( + 'PluginVaultIndexCache.getPropertyKeysAndValuesCached', + () => { + const build = (): { + keys: Set; + valuesByKey: Map>; + } => { + const keys = new Set(); + const valuesByKey = new Map>(); + const files = this.getMarkdownFilesCached(app); + + for (const file of files) { + const cachedMetadata = app.metadataCache.getFileCache(file); + if (!cachedMetadata?.frontmatter) { + continue; + } + for (const [key, value] of Object.entries( + cachedMetadata.frontmatter + )) { + if (key.startsWith('position') || key === 'tags') { + continue; + } + keys.add(key); + if (!valuesByKey.has(key)) { + valuesByKey.set(key, new Set()); + } + const setForKey = valuesByKey.get(key)!; + if (value !== null && value !== undefined) { + if (isListProperty(value)) { + parseListProperty(value).forEach(item => { + if (item) { + setForKey.add(item); + } + }); + } else { + setForKey.add(String(value)); + } + } + } + } + return { keys, valuesByKey }; + }; + + if (!this.cacheEnabledResolver()) { + return build(); + } + + if ( + this.propertySuggestBundle !== null && + this.propertySuggestBundle.generation === this.indicesGeneration + ) { + return { + keys: this.propertySuggestBundle.keys, + valuesByKey: this.propertySuggestBundle.valuesByKey, + }; + } + + const { keys, valuesByKey } = build(); + + this.propertySuggestBundle = { + generation: this.indicesGeneration, + keys, + valuesByKey, + }; + return { keys, valuesByKey }; + } + ); + } + + /** + * Register debounced invalidation when note metadata changes (frontmatter/tags). + */ + attachMetadataInvalidationListeners(plugin: Plugin & { app: App }): void { + plugin.registerEvent( + plugin.app.metadataCache.on('changed', (file: TAbstractFile) => { + if (file instanceof TFile && file.extension === 'md') { + this.scheduleMetadataDerivedInvalidate(); + } + }) + ); + plugin.registerEvent( + plugin.app.metadataCache.on('resolved', () => { + this.scheduleMetadataDerivedInvalidate(); + }) + ); + } +} diff --git a/src/infrastructure/debug/performance-trace.ts b/src/infrastructure/debug/performance-trace.ts new file mode 100644 index 0000000..dbd4df3 --- /dev/null +++ b/src/infrastructure/debug/performance-trace.ts @@ -0,0 +1,110 @@ +import type { App } from 'obsidian'; +import { normalizePath } from 'obsidian'; + +const LOG_PREFIX = '[NoteMover perf]'; + +export type PerformanceTraceEntry = { + /** Monotonic time origin (ms) */ + t0: number; + /** Wall clock ISO */ + wallTime: string; + name: string; + durationMs: number; + meta?: Record; +}; + +/** + * Collects timing spans when {@link isEnabled} is true; logs to console for quick inspection. + */ +export class PerformanceTraceRecorder { + private readonly entries: PerformanceTraceEntry[] = []; + private seq = 0; + + constructor(private readonly isEnabled: () => boolean) {} + + recordSync(name: string, fn: () => T, meta?: Record): T { + if (!this.isEnabled()) { + return fn(); + } + const start = performance.now(); + const wallTime = new Date().toISOString(); + try { + return fn(); + } finally { + const durationMs = performance.now() - start; + this.pushEntry(name, durationMs, wallTime, start, meta); + } + } + + async recordAsync( + name: string, + fn: () => Promise, + meta?: Record + ): Promise { + if (!this.isEnabled()) { + return fn(); + } + const start = performance.now(); + const wallTime = new Date().toISOString(); + try { + return await fn(); + } finally { + const durationMs = performance.now() - start; + this.pushEntry(name, durationMs, wallTime, start, meta); + } + } + + private pushEntry( + name: string, + durationMs: number, + wallTime: string, + t0: number, + meta?: Record + ): void { + const entry: PerformanceTraceEntry = { + t0, + wallTime, + name, + durationMs, + meta: meta && Object.keys(meta).length > 0 ? meta : undefined, + }; + this.seq += 1; + this.entries.push(entry); + console.debug( + `${LOG_PREFIX} #${this.seq} ${name} ${durationMs.toFixed(2)}ms`, + meta ?? '' + ); + } + + /** JSON for export / diff. */ + exportJson(): string { + const payload = { + plugin: 'obsidian-note-mover-shortcut', + exportedAt: new Date().toISOString(), + entryCount: this.entries.length, + entries: this.entries, + }; + return JSON.stringify(payload, null, 2); + } + + clear(): void { + this.entries.length = 0; + this.seq = 0; + } + + /** + * Writes trace JSON under `_note-mover-traces/` in the vault. + * @returns Normalized path of the written file + */ + async writeExportToVault(app: App): Promise { + const dir = normalizePath('_note-mover-traces'); + const folder = app.vault.getAbstractFileByPath(dir); + if (!folder) { + await app.vault.createFolder(dir); + } + const fileName = `perf-trace-${Date.now()}.json`; + const path = normalizePath(`${dir}/${fileName}`); + await app.vault.adapter.write(path, this.exportJson()); + return path; + } +} diff --git a/src/infrastructure/persistence/plugin-settings-controller.ts b/src/infrastructure/persistence/plugin-settings-controller.ts new file mode 100644 index 0000000..6f81a19 --- /dev/null +++ b/src/infrastructure/persistence/plugin-settings-controller.ts @@ -0,0 +1,286 @@ +import type { Plugin } from 'obsidian'; +import type { + PluginData, + SettingsData, + HistoryData, + Filter, +} from 'src/types/PluginData'; +import { SETTINGS_CONSTANTS } from 'src/config/constants'; +import { SettingsValidator } from 'src/utils/SettingsValidator'; +import { + RuleMigrationService, + type LegacyRuleV1, +} from 'src/core/RuleMigrationService'; +import { looksLikePluginDataRoot } from './plugin-settings-schema'; + +/** Plugin instance with persisted data fields used by this module. */ +export type PluginWithPersistedSettings = Plugin & { + settings: PluginData; +}; + +export async function loadPersistedSettings( + plugin: PluginWithPersistedSettings +): Promise { + const savedData: unknown = await plugin.loadData(); + + if (looksLikePluginDataRoot(savedData)) { + plugin.settings = savedData as PluginData; + } else { + await backupLegacyDataJson(plugin); + plugin.settings = migrateFromLegacy(savedData || {}); + await savePersistedSettings(plugin); + } + + await validateAndRepairPluginData(plugin); +} + +export async function savePersistedSettings( + plugin: PluginWithPersistedSettings +): Promise { + const settingsAny = plugin.settings as any; + const currentHistory = settingsAny.history; + + if ( + !currentHistory || + typeof currentHistory !== 'object' || + Array.isArray(currentHistory) + ) { + settingsAny.history = { history: [], bulkOperations: [] } as HistoryData; + } + if (!Array.isArray(settingsAny.history.history)) { + settingsAny.history.history = []; + } + if (!Array.isArray(settingsAny.history.bulkOperations)) { + settingsAny.history.bulkOperations = []; + } + + const s = plugin.settings.settings as any; + delete s.rules; + delete s.enableLegacyRules; + delete s.legacyMigrationDismissed; + + await plugin.saveData(plugin.settings); +} + +export async function validateAndRepairPluginData( + plugin: PluginWithPersistedSettings +): Promise { + if (!plugin.settings.settings) { + plugin.settings.settings = buildDefaultSettingsData(); + } + if (!plugin.settings.history) { + plugin.settings.history = { + history: [], + bulkOperations: [], + } as HistoryData; + } + + const settingsAny = plugin.settings.settings as any; + if (!Array.isArray(settingsAny.rules)) { + settingsAny.rules = []; + } + if (!plugin.settings.settings.filters) { + plugin.settings.settings.filters = { filter: [] }; + } + if (!Array.isArray(plugin.settings.settings.filters.filter)) { + plugin.settings.settings.filters.filter = []; + } + + if (settingsAny.enableRuleV2 !== undefined) { + delete settingsAny.enableRuleV2; + await savePersistedSettings(plugin); + } + delete settingsAny.enableLegacyRules; + delete settingsAny.legacyMigrationDismissed; + + if (plugin.settings.settings.enableRuleEvaluationCache === undefined) { + plugin.settings.settings.enableRuleEvaluationCache = true; + } + + if (plugin.settings.settings.enableVaultIndexCache === undefined) { + plugin.settings.settings.enableVaultIndexCache = true; + } + + if (plugin.settings.settings.enablePerformanceDebug === undefined) { + plugin.settings.settings.enablePerformanceDebug = false; + } + + if (!Array.isArray(plugin.settings.settings.rulesV2)) { + plugin.settings.settings.rulesV2 = []; + } + + if (plugin.settings.schemaVersion === undefined) { + plugin.settings.schemaVersion = 1; + } + + const legacyRules = Array.isArray(settingsAny.rules) ? settingsAny.rules : []; + settingsAny.rules = legacyRules.filter( + (rule: any) => + rule && + rule.criteria && + rule.path && + String(rule.criteria).trim() !== '' && + String(rule.path).trim() !== '' + ); + + plugin.settings.settings.filters.filter = + plugin.settings.settings.filters.filter.filter( + f => f && typeof f.value === 'string' && f.value.trim() !== '' + ); + + if ( + RuleMigrationService.shouldMigrate( + settingsAny.rules, + plugin.settings.settings.rulesV2 ?? [] + ) + ) { + console.log('Migrating Rule V1 to Rule V2...'); + plugin.settings.settings.rulesV2 = RuleMigrationService.migrateRules( + settingsAny.rules + ); + console.log( + `Migrated ${(plugin.settings.settings.rulesV2 ?? []).length} rules to V2 format` + ); + settingsAny.rules = []; + await savePersistedSettings(plugin); + } else { + settingsAny.rules = []; + } + + if ( + plugin.settings.settings.rulesV2 && + plugin.settings.settings.rulesV2.length > 0 + ) { + const migrated = RuleMigrationService.migrateRuleV2Triggers( + plugin.settings.settings.rulesV2 + ); + if (migrated) { + console.log('Migrated RuleV2 triggers from ruleType to operator format'); + await savePersistedSettings(plugin); + } + + const repaired = RuleMigrationService.repairRuleV2Properties( + plugin.settings.settings.rulesV2 + ); + if (repaired) { + console.log( + 'Repaired RuleV2 properties triggers with missing propertyName' + ); + await savePersistedSettings(plugin); + } + } + + if ( + plugin.settings.settings.rulesV2 && + plugin.settings.settings.rulesV2.length > 0 + ) { + const validationResult = { + isValid: true, + errors: [] as string[], + warnings: [] as string[], + }; + + SettingsValidator.validateRulesV2Array( + plugin.settings.settings.rulesV2, + validationResult + ); + + if (validationResult.errors.length > 0) { + console.error('RuleV2 validation errors:', validationResult.errors); + } + if (validationResult.warnings.length > 0) { + console.warn('RuleV2 validation warnings:', validationResult.warnings); + } + } +} + +export function buildDefaultSettingsData(): SettingsData { + const defaults = SETTINGS_CONSTANTS.DEFAULT_SETTINGS as any; + return { + triggers: { + enablePeriodicMovement: !!defaults.enablePeriodicMovement, + periodicMovementInterval: defaults.periodicMovementInterval ?? 5, + enableOnEditTrigger: !!defaults.enableOnEditTrigger, + }, + filters: { + filter: Array.isArray(defaults.filter) + ? (defaults.filter as string[]).map(v => ({ value: v }) as Filter) + : [], + }, + retentionPolicy: defaults.retentionPolicy, + rulesV2: [], + enableRuleEvaluationCache: true, + enableVaultIndexCache: true, + enablePerformanceDebug: false, + } as SettingsData; +} + +function migrateFromLegacy(legacy: any): PluginData { + const defaults = SETTINGS_CONSTANTS.DEFAULT_SETTINGS as any; + + const enablePeriodicMovement = + legacy?.enablePeriodicMovement ?? defaults.enablePeriodicMovement ?? false; + const periodicMovementInterval = + legacy?.periodicMovementInterval ?? defaults.periodicMovementInterval ?? 5; + const enableOnEditTrigger = + legacy?.enableOnEditTrigger ?? defaults.enableOnEditTrigger ?? false; + const retentionPolicy = legacy?.retentionPolicy ?? defaults.retentionPolicy; + + const rules: LegacyRuleV1[] = Array.isArray(legacy?.rules) + ? legacy.rules + : []; + const filterValues: string[] = Array.isArray(legacy?.filter) + ? legacy.filter + : []; + const filters: Filter[] = filterValues + .filter(v => typeof v === 'string') + .map(v => ({ value: v })); + + const historyArray = Array.isArray(legacy?.history) ? legacy.history : []; + const bulkOps = Array.isArray(legacy?.bulkOperations) + ? legacy.bulkOperations + : []; + + const data: PluginData = { + settings: { + triggers: { + enablePeriodicMovement, + periodicMovementInterval, + enableOnEditTrigger, + }, + filters: { filter: filters }, + rules, + rulesV2: [], + retentionPolicy, + enableRuleEvaluationCache: true, + enableVaultIndexCache: true, + enablePerformanceDebug: false, + } as unknown as SettingsData, + history: { + history: historyArray, + bulkOperations: bulkOps, + }, + lastSeenVersion: legacy?.lastSeenVersion, + schemaVersion: 1, + }; + + return data; +} + +async function backupLegacyDataJson( + plugin: PluginWithPersistedSettings +): Promise { + try { + const configDir = (plugin.app.vault as any).configDir || '.obsidian'; + const adapter: any = plugin.app.vault.adapter as any; + const dataPath = `${configDir}/plugins/${plugin.manifest.id}/data.json`; + const exists = await adapter.exists?.(dataPath); + if (!exists) return; + const iso = new Date().toISOString().replace(/[:.]/g, '-'); + const backupPath = `${configDir}/plugins/${plugin.manifest.id}/data.backup.${iso}.json`; + const content = await adapter.read(dataPath); + await adapter.write(backupPath, content); + } catch { + // Best-effort backup; ignore errors + } +} diff --git a/src/infrastructure/persistence/plugin-settings-schema.ts b/src/infrastructure/persistence/plugin-settings-schema.ts new file mode 100644 index 0000000..0ddfdec --- /dev/null +++ b/src/infrastructure/persistence/plugin-settings-schema.ts @@ -0,0 +1,25 @@ +import * as v from 'valibot'; + +/** + * Minimal structural validation for persisted plugin JSON (import / sanity checks). + */ +export const PluginDataRootSchema = v.object({ + settings: v.optional(v.any()), + history: v.optional(v.any()), + lastSeenVersion: v.optional(v.string()), + schemaVersion: v.optional(v.number()), +}); + +export function looksLikePluginDataRoot(data: unknown): boolean { + const r = v.safeParse(PluginDataRootSchema, data); + if (!r.success) return false; + const o = r.output; + return ( + o.settings != null && + typeof o.settings === 'object' && + !Array.isArray(o.settings) && + o.history != null && + typeof o.history === 'object' && + !Array.isArray(o.history) + ); +} diff --git a/src/infrastructure/plugin/vault-event-hooks.ts b/src/infrastructure/plugin/vault-event-hooks.ts new file mode 100644 index 0000000..19630a1 --- /dev/null +++ b/src/infrastructure/plugin/vault-event-hooks.ts @@ -0,0 +1,74 @@ +import type { App, TAbstractFile } from 'obsidian'; +import { TFile } from 'obsidian'; +import type { Plugin } from 'obsidian'; + +/** Subset of plugin API used by vault listeners (avoids circular import via `main`). */ +export type PluginWithVaultSync = Plugin & { + app: App; + historyManager: { + addEntryFromVaultEvent( + oldPath: string, + newPath: string, + fileName: string + ): void; + }; + ruleCache: { + handleRename(oldPath: string, newPath: string): void; + markDirty(path: string): void; + handleDelete(path: string): void; + }; + vaultIndexCache?: { + invalidateMarkdownList(): void; + scheduleMetadataDerivedInvalidate(): void; + attachMetadataInvalidationListeners(plugin: Plugin & { app: App }): void; + }; +}; + +export function registerVaultEventHooks(plugin: PluginWithVaultSync): void { + plugin.registerEvent( + plugin.app.vault.on('rename', (file, oldPath) => { + if (file instanceof TFile && file.extension === 'md') { + plugin.historyManager.addEntryFromVaultEvent( + oldPath, + file.path, + file.name + ); + plugin.ruleCache.handleRename(oldPath, file.path); + plugin.vaultIndexCache?.invalidateMarkdownList(); + } + }) + ); + + plugin.registerEvent( + plugin.app.vault.on('modify', (file: TAbstractFile) => { + if (file instanceof TFile && file.extension === 'md') { + plugin.ruleCache.markDirty(file.path); + plugin.vaultIndexCache?.scheduleMetadataDerivedInvalidate(); + } + }) + ); + + plugin.registerEvent( + plugin.app.vault.on('create', (file: TAbstractFile) => { + if (file instanceof TFile && file.extension === 'md') { + plugin.ruleCache.markDirty(file.path); + plugin.vaultIndexCache?.invalidateMarkdownList(); + } + }) + ); + + plugin.registerEvent( + plugin.app.vault.on('delete', (file: TAbstractFile) => { + if (file instanceof TFile && file.extension === 'md') { + plugin.ruleCache.handleDelete(file.path); + plugin.vaultIndexCache?.invalidateMarkdownList(); + } + }) + ); +} + +export function registerVaultIndexMetadataListeners( + plugin: PluginWithVaultSync +): void { + plugin.vaultIndexCache?.attachMetadataInvalidationListeners(plugin); +} diff --git a/src/modals/LegacyMigrationModal.ts b/src/modals/LegacyMigrationModal.ts deleted file mode 100644 index 1fd27c3..0000000 --- a/src/modals/LegacyMigrationModal.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { App, Setting } from 'obsidian'; -import { BaseModal, BaseModalOptions } from './BaseModal'; -import { MobileUtils } from '../utils/MobileUtils'; - -export interface LegacyMigrationModalOptions extends BaseModalOptions { - onMigrate: () => Promise; - onDismiss: () => void; - onDismissPermanently: () => Promise; -} - -/** - * Modal prompting users with V1 (legacy) rules to migrate to Rules V2. - * Offers: Migrate now, Ask me later, Don't ask again. - */ -export class LegacyMigrationModal extends BaseModal { - private modalOptions: LegacyMigrationModalOptions; - - constructor(app: App, options: LegacyMigrationModalOptions) { - super(app, { - title: 'Migrate to Rules V2', - cssClass: 'noteMover-legacy-migration-modal', - size: 'small', - ...options, - }); - this.modalOptions = options; - } - - protected createContent(): void { - const { contentEl } = this; - const isMobile = MobileUtils.isMobile(); - - const messageEl = contentEl.createEl('div', { - cls: isMobile - ? 'noteMover-legacy-migration-message noteMover-legacy-migration-message-mobile' - : 'noteMover-legacy-migration-message', - }); - messageEl.createEl('p', { - text: 'Rules V2 is now the default. You are currently using the legacy Rules V1 system.', - }); - messageEl.createEl('p', { - text: 'We recommend migrating to Rules V2 for more flexible rule matching with multiple conditions and logical operators. Your existing rules can be migrated automatically.', - }); - - if (isMobile) { - this.createMobileButtons(contentEl); - } else { - this.createDesktopButtons(contentEl); - } - } - - private createMobileButtons(container: HTMLElement): void { - // 1. Migrate to Rules V2 (primary) - const migrateSetting = new Setting(container).addButton(btn => { - btn - .setButtonText('Migrate to Rules V2') - .setCta() - .onClick(async () => { - await this.modalOptions.onMigrate(); - this.close(); - }); - }); - const migrateBtn = migrateSetting.settingEl.querySelector('button'); - if (migrateBtn) { - migrateBtn.style.width = '100%'; - migrateBtn.style.minHeight = '48px'; - } - - // 2. Ask me later - const laterSetting = new Setting(container).addButton(btn => { - btn.setButtonText('Ask me later').onClick(() => { - this.modalOptions.onDismiss(); - this.close(); - }); - }); - const laterBtn = laterSetting.settingEl.querySelector('button'); - if (laterBtn) { - laterBtn.style.width = '100%'; - laterBtn.style.minHeight = '48px'; - } - - // 3. Don't ask again - const dontAskSetting = new Setting(container).addButton(btn => { - btn.setButtonText("Don't ask again").onClick(async () => { - await this.modalOptions.onDismissPermanently(); - this.close(); - }); - }); - const dontAskBtn = dontAskSetting.settingEl.querySelector('button'); - if (dontAskBtn) { - dontAskBtn.style.width = '100%'; - dontAskBtn.style.minHeight = '48px'; - } - } - - private createDesktopButtons(container: HTMLElement): void { - const buttonContainer = this.createButtonContainer(container); - - // Migrate (primary) - this.createButton( - buttonContainer, - 'Migrate to Rules V2', - async () => { - await this.modalOptions.onMigrate(); - this.close(); - }, - { isPrimary: true } - ); - - // Ask me later - this.createButton(buttonContainer, 'Ask me later', () => { - this.modalOptions.onDismiss(); - this.close(); - }); - - // Don't ask again - this.createButton(buttonContainer, "Don't ask again", async () => { - await this.modalOptions.onDismissPermanently(); - this.close(); - }); - } -} diff --git a/src/modals/PreviewModal.ts b/src/modals/PreviewModal.ts index 3bf1c5c..22787d3 100644 --- a/src/modals/PreviewModal.ts +++ b/src/modals/PreviewModal.ts @@ -1,4 +1,4 @@ -import { Setting, App, TFile, Notice } from 'obsidian'; +import { Setting, App, TFile } from 'obsidian'; import { MovePreview, PreviewEntry } from '../types/MovePreview'; import NoteMoverShortcutPlugin from 'main'; import { NoticeManager } from '../utils/NoticeManager'; @@ -9,6 +9,7 @@ import { BaseModal, BaseModalOptions } from './BaseModal'; export class PreviewModal extends BaseModal { private movePreview: MovePreview; + private actionFooterEl: HTMLElement | null = null; constructor( app: App, @@ -158,6 +159,7 @@ export class PreviewModal extends BaseModal { ? 'noteMover-modal-footer noteMover-modal-footer-mobile' : 'noteMover-modal-footer', }); + this.actionFooterEl = footer; const buttonContainer = this.createButtonContainer(footer); @@ -217,17 +219,36 @@ export class PreviewModal extends BaseModal { } private async executeMoves() { - this.close(); - const successfulEntries = this.movePreview.successfulMoves; let movedCount = 0; let errorCount = 0; + const abortCtl = new AbortController(); - const bulkOperationId = - this.plugin.historyManager.startBulkOperation('bulk'); + if (this.actionFooterEl) { + this.actionFooterEl.empty(); + const status = this.actionFooterEl.createDiv({ + cls: 'noteMover-preview-bulk-status', + text: `Moving files… (${successfulEntries.length} planned)`, + }); + const row = this.actionFooterEl.createDiv({ + cls: 'noteMover-preview-bulk-actions', + }); + new Setting(row).addButton(btn => + btn.setButtonText('Stop').onClick(() => { + abortCtl.abort(); + status.setText('Stopping after current file…'); + }) + ); + } + + this.plugin.historyManager.startBulkOperation('bulk'); try { - for (const entry of successfulEntries) { + for (let i = 0; i < successfulEntries.length; i++) { + const entry = successfulEntries[i]; + if (abortCtl.signal.aborted) { + break; + } try { const file = this.app.vault.getAbstractFileByPath(entry.currentPath); if (!(file instanceof TFile) || !entry.targetPath) continue; @@ -258,12 +279,35 @@ export class PreviewModal extends BaseModal { handleError(error, `Error moving file ${entry.fileName}`, false); errorCount++; } + if ((i + 1) % 50 === 0 && i + 1 < successfulEntries.length) { + await new Promise(resolve => { + const ric = ( + globalThis as typeof globalThis & { + requestIdleCallback?: ( + cb: IdleRequestCallback, + opts?: IdleRequestOptions + ) => number; + } + ).requestIdleCallback; + if (typeof ric === 'function') { + ric(() => resolve(), { timeout: 250 }); + } else { + setTimeout(resolve, 0); + } + }); + } } } finally { this.plugin.historyManager.endBulkOperation(); } - if (errorCount === 0) { + this.close(); + + if (abortCtl.signal.aborted) { + NoticeManager.info( + `Bulk move stopped. ${movedCount} file(s) moved, ${errorCount} error(s).` + ); + } else if (errorCount === 0) { NoticeManager.success(`Successfully moved ${movedCount} files!`); } else { NoticeManager.warning( diff --git a/src/modals/RuleEditorModal.ts b/src/modals/RuleEditorModal.ts index 6202a56..4f857ab 100644 --- a/src/modals/RuleEditorModal.ts +++ b/src/modals/RuleEditorModal.ts @@ -12,30 +12,22 @@ import { TagSuggest } from '../settings/suggesters/TagSuggest'; import { PropertySuggest } from '../settings/suggesters/PropertySuggest'; import { PropertyValueSuggest } from '../settings/suggesters/PropertyValueSuggest'; import { DragDropManager } from '../utils/DragDropManager'; -import { MobileUtils } from '../utils/MobileUtils'; import { getOperatorsForCriteriaType, getOperatorsForPropertyType, getDefaultOperatorForCriteriaType, - getAvailablePropertyTypes, isRegexOperator, operatorRequiresValue, getPropertyTypeFromVault, } from '../utils/OperatorMapping'; -import { - MobileModalInput, - MobileModalToggle, - MobileModalButton, - MobileModalSection, - MobileTriggerCard, - MobileTriggerCardCallbacks, -} from './mobile'; +import type { PluginVaultIndexCache } from '../infrastructure/cache/plugin-vault-index-cache'; interface RuleEditorModalOptions { rule: RuleV2; isEditMode: boolean; onSave: (rule: RuleV2) => Promise; onDelete?: () => Promise; + vaultIndexCache?: PluginVaultIndexCache; } export class RuleEditorModal extends BaseModal { @@ -43,7 +35,6 @@ export class RuleEditorModal extends BaseModal { private workingRule: RuleV2; private dragDropManager: DragDropManager | null = null; private triggersContainer: HTMLElement | null = null; - private mobileTriggerCards: MobileTriggerCard[] = []; constructor(app: App, options: RuleEditorModalOptions) { super(app, { @@ -59,55 +50,7 @@ export class RuleEditorModal extends BaseModal { protected createContent(): void { const { contentEl } = this; - - if (MobileUtils.isMobile()) { - this.createMobileContent(contentEl); - } else { - this.createDesktopContent(contentEl); - } - } - - private createMobileContent(container: HTMLElement): void { - // Name Card - const nameInput = new MobileModalInput( - container, - 'Name', - undefined, - 'Enter rule name', - this.workingRule.name, - async value => { - this.workingRule.name = value; - } - ); - - // Active Toggle Card - new MobileModalToggle( - container, - 'Active', - 'Enable or disable this rule', - this.workingRule.active, - async value => { - this.workingRule.active = value; - } - ); - - // Match Conditions Card - this.createMobileMatchConditions(container); - - // Destination Card - this.createMobileDestination(container); - - // Separator - container.createEl('hr', { cls: 'noteMover-rule-editor-separator' }); - - // Conditions Section - this.createMobileConditionsSection(container); - - // Separator - container.createEl('hr', { cls: 'noteMover-rule-editor-separator' }); - - // Footer Actions - this.createMobileFooterActions(container); + this.createDesktopContent(contentEl); } private createDesktopContent(container: HTMLElement): void { @@ -164,47 +107,6 @@ export class RuleEditorModal extends BaseModal { } } - private createMobileMatchConditions(container: HTMLElement): void { - const card = container.createDiv({ cls: 'noteMover-mobile-modal-card' }); - const header = card.createDiv({ - cls: 'noteMover-mobile-modal-card-header', - }); - header.createDiv({ - cls: 'noteMover-mobile-modal-card-title', - text: 'Match Conditions', - }); - const content = card.createDiv({ - cls: 'noteMover-mobile-modal-card-content', - }); - - const buttonContainer = content.createDiv({ - cls: 'noteMover-rule-aggregation-buttons-mobile', - }); - - const aggregations: AggregationType[] = ['all', 'any', 'none']; - aggregations.forEach(agg => { - const button = buttonContainer.createEl('button', { - text: agg.charAt(0).toUpperCase() + agg.slice(1), - cls: 'noteMover-rule-aggregation-button', - }); - - if (this.workingRule.aggregation === agg) { - button.addClass('is-active'); - } - - button.onclick = () => { - this.workingRule.aggregation = agg; - // Update button states - buttonContainer - .querySelectorAll('.noteMover-rule-aggregation-button') - .forEach(btn => { - btn.removeClass('is-active'); - }); - button.addClass('is-active'); - }; - }); - } - private createMatchConditionsSelector(container: HTMLElement): void { const setting = new Setting(container).setName('Match Conditions'); @@ -236,45 +138,6 @@ export class RuleEditorModal extends BaseModal { }); } - private createMobileDestination(container: HTMLElement): void { - const card = container.createDiv({ cls: 'noteMover-mobile-modal-card' }); - const header = card.createDiv({ - cls: 'noteMover-mobile-modal-card-header', - }); - header.createDiv({ - cls: 'noteMover-mobile-modal-card-title', - text: 'Destination', - }); - card.createDiv({ - cls: 'noteMover-mobile-modal-card-description', - text: 'Folder or template where files matching this rule will be moved', - }); - card.createDiv({ - cls: 'noteMover-mobile-modal-card-description', - text: 'Supports templates like {{tag.tasks/personal}} or {{property.status}}', - }); - const content = card.createDiv({ - cls: 'noteMover-mobile-modal-card-content', - }); - - const searchContainer = content.createDiv({ - cls: 'search-input-container', - }); - const input = searchContainer.createEl('input', { - type: 'text', - cls: 'noteMover-mobile-text-input', - placeholder: - 'Example: folder1/folder2 or Personal/Tasks/{{property.status}}', - value: this.workingRule.destination, - }); - - new FolderSuggest(this.app, input); - - input.addEventListener('input', () => { - this.workingRule.destination = input.value; - }); - } - private createDestinationInput(container: HTMLElement): void { let destinationInputEl: any = null; @@ -310,35 +173,6 @@ export class RuleEditorModal extends BaseModal { } } - private createMobileConditionsSection(container: HTMLElement): void { - const section = new MobileModalSection(container, 'Conditions'); - const sectionEl = section.getSectionElement(); - - // Triggers container - this.triggersContainer = sectionEl.createDiv({ - cls: 'noteMover-rule-triggers-container', - }); - - this.renderMobileTriggers(); - - // Add Condition Button - new MobileModalButton( - sectionEl, - '', - undefined, - '+ Add Condition', - () => { - this.workingRule.triggers.push({ - criteriaType: 'tag', - operator: 'includes item', - value: '', - }); - this.renderMobileTriggers(); - }, - {} - ); - } - private createConditionsSection(container: HTMLElement): void { const section = container.createDiv({ cls: 'noteMover-rule-conditions-section', @@ -369,68 +203,6 @@ export class RuleEditorModal extends BaseModal { ); } - private renderMobileTriggers(): void { - if (!this.triggersContainer) return; - - this.triggersContainer.empty(); - this.mobileTriggerCards = []; - - this.workingRule.triggers.forEach((trigger, index) => { - const callbacks: MobileTriggerCardCallbacks = { - onCriteriaTypeChange: () => { - this.renderMobileTriggers(); - }, - onOperatorChange: () => { - this.renderMobileTriggers(); - }, - onPropertyNameChange: () => { - this.renderMobileTriggers(); - }, - onValueChange: () => { - // Value change doesn't require re-render - }, - onMoveUp: () => { - if (index > 0) { - const [movedTrigger] = this.workingRule.triggers.splice(index, 1); - this.workingRule.triggers.splice(index - 1, 0, movedTrigger); - this.renderMobileTriggers(); - } - }, - onMoveDown: () => { - if (index < this.workingRule.triggers.length - 1) { - const [movedTrigger] = this.workingRule.triggers.splice(index, 1); - this.workingRule.triggers.splice(index + 1, 0, movedTrigger); - this.renderMobileTriggers(); - } - }, - onDelete: () => { - this.workingRule.triggers.splice(index, 1); - if (this.workingRule.triggers.length === 0) { - this.workingRule.triggers.push({ - criteriaType: 'tag', - operator: 'includes item', - value: '', - }); - } - this.renderMobileTriggers(); - }, - canMoveUp: index > 0, - canMoveDown: index < this.workingRule.triggers.length - 1, - onRender: () => { - this.renderMobileTriggers(); - }, - }; - - const triggerCard = new MobileTriggerCard( - this.triggersContainer!, - trigger, - callbacks, - this.app - ); - this.mobileTriggerCards.push(triggerCard); - }); - } - private renderTriggers(): void { if (!this.triggersContainer) return; @@ -541,7 +313,8 @@ export class RuleEditorModal extends BaseModal { // Automatische Typ-Erkennung const detectedType = getPropertyTypeFromVault( this.app, - trigger.propertyName + trigger.propertyName, + this.ruleOptions.vaultIndexCache ); if (detectedType) { trigger.propertyType = detectedType; @@ -565,7 +338,7 @@ export class RuleEditorModal extends BaseModal { // Add suggesters based on criteriaType if (trigger.criteriaType === 'tag') { - new TagSuggest(this.app, valueInput); + new TagSuggest(this.app, valueInput, this.ruleOptions.vaultIndexCache); } else if (trigger.criteriaType === 'folder') { new FolderSuggest(this.app, valueInput); } else if ( @@ -578,7 +351,8 @@ export class RuleEditorModal extends BaseModal { this.app, valueInput, trigger.propertyName, - trigger.propertyType + trigger.propertyType, + this.ruleOptions.vaultIndexCache ); } } @@ -679,59 +453,6 @@ export class RuleEditorModal extends BaseModal { }); } - private createMobileFooterActions(container: HTMLElement): void { - const footer = container.createDiv({ - cls: 'noteMover-rule-editor-footer-mobile', - }); - - // Remove Rule button (only in edit mode) - if (this.ruleOptions.isEditMode && this.ruleOptions.onDelete) { - new MobileModalButton( - footer, - '', - undefined, - 'Remove Rule', - async () => { - const confirmed = confirm( - `Are you sure you want to delete the rule "${this.workingRule.name}"?\n\nThis action cannot be undone.` - ); - if (confirmed && this.ruleOptions.onDelete) { - await this.ruleOptions.onDelete(); - this.close(); - } - }, - { isDanger: true } - ); - } - - // Cancel button - new MobileModalButton( - footer, - '', - undefined, - 'Cancel', - () => { - this.close(); - }, - {} - ); - - // Save button - new MobileModalButton( - footer, - '', - undefined, - 'Save', - async () => { - if (this.validateRule()) { - await this.ruleOptions.onSave(this.workingRule); - this.close(); - } - }, - { isPrimary: true } - ); - } - private createFooterActions(container: HTMLElement): void { const footer = container.createDiv({ cls: 'noteMover-rule-editor-footer', diff --git a/src/modals/index.ts b/src/modals/index.ts index 0d2684e..45fb892 100644 --- a/src/modals/index.ts +++ b/src/modals/index.ts @@ -6,5 +6,3 @@ export { HistoryModal } from './HistoryModal'; export { PreviewModal } from './PreviewModal'; export { UpdateModal } from './UpdateModal'; export { RuleEditorModal } from './RuleEditorModal'; -export { LegacyMigrationModal } from './LegacyMigrationModal'; -export type { LegacyMigrationModalOptions } from './LegacyMigrationModal'; diff --git a/src/modals/mobile/MobileModalButton.ts b/src/modals/mobile/MobileModalButton.ts deleted file mode 100644 index 70eb6dc..0000000 --- a/src/modals/mobile/MobileModalButton.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { MobileModalCard } from './MobileModalCard'; - -export interface MobileModalButtonOptions { - isPrimary?: boolean; - isWarning?: boolean; - isDanger?: boolean; -} - -/** - * Mobile-optimized button component for modals - */ -export class MobileModalButton { - private modalCard: MobileModalCard; - private buttonEl: HTMLButtonElement; - - constructor( - container: HTMLElement, - title: string, - description: string | undefined, - buttonText: string, - onClick: () => void | Promise, - options: MobileModalButtonOptions = {} - ) { - this.modalCard = new MobileModalCard(container, title, description); - this.modalCard.addClass('noteMover-mobile-modal-button'); - - const contentEl = this.modalCard.getContentElement(); - - // Create button - this.buttonEl = contentEl.createEl('button', { - cls: 'noteMover-mobile-action-btn', - text: buttonText, - }); - - // Apply button type styles - if (options.isPrimary) { - this.buttonEl.addClass('mod-cta'); - this.buttonEl.style.background = 'var(--interactive-accent)'; - this.buttonEl.style.color = 'var(--text-on-accent)'; - } else if (options.isWarning || options.isDanger) { - this.buttonEl.addClass('noteMover-mobile-delete-btn'); - } - - // Add click handler - this.buttonEl.addEventListener('click', async () => { - await onClick(); - }); - } - - setButtonText(text: string): void { - this.buttonEl.textContent = text; - } - - getButtonElement(): HTMLButtonElement { - return this.buttonEl; - } - - getCardElement(): HTMLElement { - return this.modalCard.getCardElement(); - } -} diff --git a/src/modals/mobile/MobileModalCard.ts b/src/modals/mobile/MobileModalCard.ts deleted file mode 100644 index 0d00ff0..0000000 --- a/src/modals/mobile/MobileModalCard.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Base mobile modal card component with consistent card-based layout - * Similar to MobileSettingItem but optimized for modal usage - */ -export class MobileModalCard { - private cardEl: HTMLElement; - private headerEl: HTMLElement; - private titleEl: HTMLElement; - private descriptionEl: HTMLElement | null = null; - private contentEl: HTMLElement; - - constructor(container: HTMLElement, title: string, description?: string) { - // Create card container - this.cardEl = container.createDiv({ cls: 'noteMover-mobile-modal-card' }); - - // Create header - this.headerEl = this.cardEl.createDiv({ - cls: 'noteMover-mobile-modal-card-header', - }); - this.titleEl = this.headerEl.createDiv({ - cls: 'noteMover-mobile-modal-card-title', - }); - this.titleEl.textContent = title; - - // Create description if provided - if (description) { - this.descriptionEl = this.cardEl.createDiv({ - cls: 'noteMover-mobile-modal-card-description', - }); - this.descriptionEl.textContent = description; - } - - // Create content container - this.contentEl = this.cardEl.createDiv({ - cls: 'noteMover-mobile-modal-card-content', - }); - } - - getCardElement(): HTMLElement { - return this.cardEl; - } - - getContentElement(): HTMLElement { - return this.contentEl; - } - - getHeaderElement(): HTMLElement { - return this.headerEl; - } - - setDescription(text: string): void { - if (this.descriptionEl) { - this.descriptionEl.textContent = text; - } else { - this.descriptionEl = this.cardEl.createDiv({ - cls: 'noteMover-mobile-modal-card-description', - }); - this.descriptionEl.textContent = text; - } - } - - addClass(cls: string): void { - this.cardEl.addClass(cls); - } -} diff --git a/src/modals/mobile/MobileModalInput.ts b/src/modals/mobile/MobileModalInput.ts deleted file mode 100644 index 386ba08..0000000 --- a/src/modals/mobile/MobileModalInput.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { MobileModalCard } from './MobileModalCard'; - -/** - * Mobile-optimized input component for modals - */ -export class MobileModalInput { - private modalCard: MobileModalCard; - private inputEl: HTMLInputElement; - - constructor( - container: HTMLElement, - title: string, - description: string | undefined, - placeholder: string, - value: string, - onChange: (value: string) => void | Promise - ) { - this.modalCard = new MobileModalCard(container, title, description); - this.modalCard.addClass('noteMover-mobile-modal-input'); - - const contentEl = this.modalCard.getContentElement(); - - // Create input - this.inputEl = contentEl.createEl('input', { - cls: 'noteMover-mobile-text-input', - type: 'text', - attr: { - placeholder: placeholder, - value: value, - }, - }); - - // Add change handler - this.inputEl.addEventListener('input', async () => { - await onChange(this.inputEl.value); - }); - } - - setValue(value: string): void { - this.inputEl.value = value; - } - - getValue(): string { - return this.inputEl.value; - } - - getInputElement(): HTMLInputElement { - return this.inputEl; - } - - getCardElement(): HTMLElement { - return this.modalCard.getCardElement(); - } -} diff --git a/src/modals/mobile/MobileModalSection.ts b/src/modals/mobile/MobileModalSection.ts deleted file mode 100644 index 53fd2f2..0000000 --- a/src/modals/mobile/MobileModalSection.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Mobile-optimized section component for modals - * Provides consistent spacing and styling for modal sections - */ -export class MobileModalSection { - private sectionEl: HTMLElement; - private titleEl: HTMLElement | null = null; - - constructor(container: HTMLElement, title?: string) { - this.sectionEl = container.createDiv({ - cls: 'noteMover-mobile-modal-section', - }); - - if (title) { - this.titleEl = this.sectionEl.createEl('h3', { - cls: 'noteMover-mobile-modal-section-title', - text: title, - }); - } - } - - getSectionElement(): HTMLElement { - return this.sectionEl; - } - - addClass(cls: string): void { - this.sectionEl.addClass(cls); - } -} diff --git a/src/modals/mobile/MobileModalToggle.ts b/src/modals/mobile/MobileModalToggle.ts deleted file mode 100644 index ee6f650..0000000 --- a/src/modals/mobile/MobileModalToggle.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { MobileModalCard } from './MobileModalCard'; - -/** - * Mobile-optimized toggle component for modals - */ -export class MobileModalToggle { - private modalCard: MobileModalCard; - private toggleEl: HTMLElement; - private toggleInput: HTMLInputElement; - - constructor( - container: HTMLElement, - title: string, - description: string | undefined, - value: boolean, - onChange: (value: boolean) => void | Promise - ) { - this.modalCard = new MobileModalCard(container, title, description); - this.modalCard.addClass('noteMover-mobile-modal-toggle'); - - const headerEl = this.modalCard.getHeaderElement(); - - // Create toggle switch directly in header (right side) - this.toggleEl = headerEl.createDiv({ - cls: 'noteMover-mobile-toggle-switch', - }); - this.toggleInput = document.createElement('input'); - this.toggleInput.type = 'checkbox'; - this.toggleInput.className = 'noteMover-mobile-toggle-input'; - this.toggleInput.checked = value; - this.toggleEl.appendChild(this.toggleInput); - - // Add visual toggle switch - const toggleSlider = this.toggleEl.createDiv({ - cls: 'noteMover-mobile-toggle-slider', - }); - - // Update visual state - this.updateToggleState(value); - - // Add click handler - this.toggleInput.addEventListener('change', async () => { - const newValue = this.toggleInput.checked; - this.updateToggleState(newValue); - await onChange(newValue); - }); - - // Make entire toggle area clickable - this.toggleEl.addEventListener('click', e => { - if (e.target !== this.toggleInput) { - this.toggleInput.click(); - } - }); - - // Hide the content element since toggle is in header - const contentEl = this.modalCard.getContentElement(); - contentEl.style.display = 'none'; - } - - private updateToggleState(value: boolean): void { - if (value) { - this.toggleEl.addClass('is-active'); - } else { - this.toggleEl.removeClass('is-active'); - } - } - - setValue(value: boolean): void { - this.toggleInput.checked = value; - this.updateToggleState(value); - } - - getValue(): boolean { - return this.toggleInput.checked; - } - - getCardElement(): HTMLElement { - return this.modalCard.getCardElement(); - } -} diff --git a/src/modals/mobile/MobileTriggerCard.ts b/src/modals/mobile/MobileTriggerCard.ts deleted file mode 100644 index 1b68268..0000000 --- a/src/modals/mobile/MobileTriggerCard.ts +++ /dev/null @@ -1,424 +0,0 @@ -import { Trigger, CriteriaType, Operator } from '../../types/RuleV2'; -import { App } from 'obsidian'; -import { setIcon } from 'obsidian'; -import { MobileUtils } from '../../utils/MobileUtils'; -import { FolderSuggest } from '../../settings/suggesters/FolderSuggest'; -import { TagSuggest } from '../../settings/suggesters/TagSuggest'; -import { PropertySuggest } from '../../settings/suggesters/PropertySuggest'; -import { PropertyValueSuggest } from '../../settings/suggesters/PropertyValueSuggest'; -import { - getDefaultOperatorForCriteriaType, - getPropertyTypeFromVault, - operatorRequiresValue, - getOperatorsForCriteriaType, - getOperatorsForPropertyType, -} from '../../utils/OperatorMapping'; - -export interface MobileTriggerCardCallbacks { - onCriteriaTypeChange?: (value: CriteriaType) => void; - onOperatorChange?: (value: Operator) => void; - onPropertyNameChange?: (value: string) => void; - onValueChange?: (value: string) => void; - onMoveUp?: () => void; - onMoveDown?: () => void; - onDelete?: () => void; - canMoveUp?: boolean; - canMoveDown?: boolean; - onRender?: () => void; // Called when trigger needs to be re-rendered -} - -/** - * Mobile-optimized trigger card component - * Card-based layout for individual trigger conditions - */ -export class MobileTriggerCard { - private cardEl: HTMLElement; - private headerEl: HTMLElement; - private fieldsContainer: HTMLElement; - private criteriaTypeSelect: HTMLSelectElement; - private operatorSelect: HTMLSelectElement; - private propertyNameInput: HTMLInputElement | null = null; - private valueInput: HTMLInputElement | null = null; - private moveUpButton: HTMLButtonElement | null = null; - private moveDownButton: HTMLButtonElement | null = null; - private deleteButton: HTMLButtonElement; - - constructor( - container: HTMLElement, - trigger: Trigger, - callbacks: MobileTriggerCardCallbacks, - app: App - ) { - // Create card - this.cardEl = container.createDiv({ - cls: 'noteMover-mobile-trigger-card', - }); - - // Create header with move buttons and delete button - this.headerEl = this.cardEl.createDiv({ - cls: 'noteMover-mobile-trigger-card-header', - }); - - // Move buttons container - const moveButtonsContainer = this.headerEl.createDiv({ - cls: 'noteMover-mobile-trigger-move-buttons', - }); - - // Move Up button - if (callbacks.canMoveUp !== false) { - const upButton = MobileUtils.createMoveButton( - moveButtonsContainer, - 'up', - async () => { - if (callbacks.onMoveUp) { - await callbacks.onMoveUp(); - } - }, - false - ); - if (upButton) { - this.moveUpButton = upButton; - } - } - - // Move Down button - if (callbacks.canMoveDown !== false) { - const downButton = MobileUtils.createMoveButton( - moveButtonsContainer, - 'down', - async () => { - if (callbacks.onMoveDown) { - await callbacks.onMoveDown(); - } - }, - false - ); - if (downButton) { - this.moveDownButton = downButton; - } - } - - // Delete button - const deleteBtnContainer = this.headerEl.createDiv({ - cls: 'noteMover-mobile-trigger-delete-container', - }); - this.deleteButton = deleteBtnContainer.createEl('button', { - cls: 'noteMover-mobile-trigger-delete-btn', - }); - setIcon(this.deleteButton, 'x'); - this.deleteButton.addEventListener('click', async () => { - if (callbacks.onDelete) { - await callbacks.onDelete(); - } - }); - - // Create fields container - this.fieldsContainer = this.cardEl.createDiv({ - cls: 'noteMover-mobile-trigger-fields', - }); - - // CriteriaType Dropdown - const criteriaLabel = this.fieldsContainer.createDiv({ - cls: 'noteMover-mobile-trigger-field-label', - text: 'Criteria Type', - }); - this.criteriaTypeSelect = this.fieldsContainer.createEl('select', { - cls: 'noteMover-mobile-text-input', - }); - const criteriaTypes: CriteriaType[] = [ - 'tag', - 'fileName', - 'folder', - 'created_at', - 'modified_at', - 'extension', - 'links', - 'embeds', - 'properties', - 'headings', - ]; - criteriaTypes.forEach(ct => { - const option = this.criteriaTypeSelect.createEl('option', { - value: ct, - text: ct, - }); - if (trigger.criteriaType === ct) { - option.selected = true; - } - }); - this.criteriaTypeSelect.addEventListener('change', () => { - trigger.criteriaType = this.criteriaTypeSelect.value as CriteriaType; - trigger.operator = getDefaultOperatorForCriteriaType( - trigger.criteriaType - ); - if (trigger.criteriaType !== 'properties') { - delete trigger.propertyName; - delete trigger.propertyType; - } - if (callbacks.onCriteriaTypeChange) { - callbacks.onCriteriaTypeChange(trigger.criteriaType); - } - }); - - // Operator Dropdown (will be populated by populateOperatorDropdown) - const operatorLabel = this.fieldsContainer.createDiv({ - cls: 'noteMover-mobile-trigger-field-label', - text: 'Operator', - }); - this.operatorSelect = this.fieldsContainer.createEl('select', { - cls: 'noteMover-mobile-text-input', - }); - this.populateOperatorDropdown(trigger, app); - this.operatorSelect.addEventListener('change', () => { - trigger.operator = this.operatorSelect.value as Operator; - if (callbacks.onOperatorChange) { - callbacks.onOperatorChange(trigger.operator); - } - }); - - // Property-specific fields (only shown for properties criteria) - if (trigger.criteriaType === 'properties') { - const propertyLabel = this.fieldsContainer.createDiv({ - cls: 'noteMover-mobile-trigger-field-label', - text: 'Property Name', - }); - this.propertyNameInput = this.fieldsContainer.createEl('input', { - type: 'text', - cls: 'noteMover-mobile-text-input', - placeholder: 'Property Name', - value: trigger.propertyName || '', - }); - - new PropertySuggest(app, this.propertyNameInput); - - this.propertyNameInput.addEventListener('input', () => { - trigger.propertyName = this.propertyNameInput!.value; - const detectedType = getPropertyTypeFromVault( - app, - trigger.propertyName - ); - if (detectedType) { - trigger.propertyType = detectedType; - // Property type detection requires re-render to update operator dropdown - if (callbacks.onRender) { - callbacks.onRender(); - } - } else { - // Only call onPropertyNameChange if no type was detected (no re-render needed) - if (callbacks.onPropertyNameChange) { - callbacks.onPropertyNameChange(trigger.propertyName || ''); - } - } - }); - } - - // Value Input (only if operator requires value) - if (operatorRequiresValue(trigger.operator)) { - const valueLabel = this.fieldsContainer.createDiv({ - cls: 'noteMover-mobile-trigger-field-label', - text: trigger.criteriaType === 'tag' ? 'Tag' : 'Value', - }); - this.valueInput = this.fieldsContainer.createEl('input', { - type: 'text', - cls: 'noteMover-mobile-text-input', - placeholder: trigger.criteriaType === 'tag' ? '#tag' : 'Value', - value: trigger.value || '', - }); - this.valueInput.addEventListener('input', () => { - trigger.value = this.valueInput!.value; - if (callbacks.onValueChange) { - callbacks.onValueChange(trigger.value); - } - }); - - // Add suggesters based on criteriaType - if (trigger.criteriaType === 'tag') { - new TagSuggest(app, this.valueInput); - } else if (trigger.criteriaType === 'folder') { - new FolderSuggest(app, this.valueInput); - } else if ( - trigger.criteriaType === 'properties' && - trigger.propertyName && - trigger.propertyType - ) { - new PropertyValueSuggest( - app, - this.valueInput, - trigger.propertyName, - trigger.propertyType - ); - } - } - } - - private populateOperatorDropdown(trigger: Trigger, app: App): void { - this.operatorSelect.empty(); - - let operators: Operator[]; - if (trigger.criteriaType === 'properties' && trigger.propertyType) { - operators = getOperatorsForPropertyType(trigger.propertyType); - } else { - operators = getOperatorsForCriteriaType(trigger.criteriaType); - } - - operators.forEach(op => { - const option = this.operatorSelect.createEl('option', { - value: op, - text: op, - }); - if (trigger.operator === op) { - option.selected = true; - } - }); - } - - updateOperatorDropdown(trigger: Trigger, app: App): void { - this.populateOperatorDropdown(trigger, app); - } - - updateValueField( - trigger: Trigger, - app: App, - callbacks: MobileTriggerCardCallbacks - ): void { - const needsValue = operatorRequiresValue(trigger.operator); - const hasValue = this.valueInput !== null; - - if (needsValue && !hasValue) { - // Create value input - const valueLabel = this.fieldsContainer.createDiv({ - cls: 'noteMover-mobile-trigger-field-label', - text: trigger.criteriaType === 'tag' ? 'Tag' : 'Value', - }); - this.valueInput = this.fieldsContainer.createEl('input', { - type: 'text', - cls: 'noteMover-mobile-text-input', - placeholder: trigger.criteriaType === 'tag' ? '#tag' : 'Value', - value: trigger.value || '', - }); - this.valueInput.addEventListener('input', () => { - trigger.value = this.valueInput!.value; - if (callbacks.onValueChange) { - callbacks.onValueChange(trigger.value); - } - }); - - // Add suggesters - if (trigger.criteriaType === 'tag') { - new TagSuggest(app, this.valueInput); - } else if (trigger.criteriaType === 'folder') { - new FolderSuggest(app, this.valueInput); - } else if ( - trigger.criteriaType === 'properties' && - trigger.propertyName && - trigger.propertyType - ) { - new PropertyValueSuggest( - app, - this.valueInput, - trigger.propertyName, - trigger.propertyType - ); - } - } else if (!needsValue && hasValue) { - // Remove value input and its label - const valueLabel = this.valueInput!.previousElementSibling; - if ( - valueLabel && - valueLabel.classList.contains('noteMover-mobile-trigger-field-label') - ) { - valueLabel.remove(); - } - this.valueInput!.remove(); - this.valueInput = null; - } else if (needsValue && hasValue && this.valueInput) { - // Update existing value input - this.valueInput.value = trigger.value || ''; - } - } - - updatePropertyField( - trigger: Trigger, - app: App, - callbacks: MobileTriggerCardCallbacks - ): void { - const needsProperty = trigger.criteriaType === 'properties'; - const hasProperty = this.propertyNameInput !== null; - - if (needsProperty && !hasProperty) { - // Create property input before operator select - const operatorLabel = this.operatorSelect.previousElementSibling; - const propertyLabel = this.fieldsContainer.createDiv({ - cls: 'noteMover-mobile-trigger-field-label', - text: 'Property Name', - }); - this.propertyNameInput = this.fieldsContainer.createEl('input', { - type: 'text', - cls: 'noteMover-mobile-text-input', - placeholder: 'Property Name', - value: trigger.propertyName || '', - }); - - // Insert before operator label - if (operatorLabel) { - this.fieldsContainer.insertBefore(propertyLabel, operatorLabel); - this.fieldsContainer.insertBefore( - this.propertyNameInput, - operatorLabel - ); - } - - new PropertySuggest(app, this.propertyNameInput); - this.propertyNameInput.addEventListener('input', () => { - trigger.propertyName = this.propertyNameInput!.value; - const detectedType = getPropertyTypeFromVault( - app, - trigger.propertyName - ); - if (detectedType) { - trigger.propertyType = detectedType; - if (callbacks.onRender) { - callbacks.onRender(); - } - } - if (callbacks.onPropertyNameChange) { - callbacks.onPropertyNameChange(trigger.propertyName || ''); - } - }); - } else if (!needsProperty && hasProperty) { - // Remove property input and its label - const propertyLabel = this.propertyNameInput!.previousElementSibling; - if ( - propertyLabel && - propertyLabel.classList.contains('noteMover-mobile-trigger-field-label') - ) { - propertyLabel.remove(); - } - this.propertyNameInput!.remove(); - this.propertyNameInput = null; - } else if (needsProperty && hasProperty && this.propertyNameInput) { - // Update existing property input - this.propertyNameInput.value = trigger.propertyName || ''; - } - } - - getCardElement(): HTMLElement { - return this.cardEl; - } - - getCriteriaTypeSelect(): HTMLSelectElement { - return this.criteriaTypeSelect; - } - - getOperatorSelect(): HTMLSelectElement { - return this.operatorSelect; - } - - getPropertyNameInput(): HTMLInputElement | null { - return this.propertyNameInput; - } - - getValueInput(): HTMLInputElement | null { - return this.valueInput; - } -} diff --git a/src/modals/mobile/index.ts b/src/modals/mobile/index.ts deleted file mode 100644 index f8ff7c6..0000000 --- a/src/modals/mobile/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { MobileModalCard } from './MobileModalCard'; -export { MobileModalInput } from './MobileModalInput'; -export { MobileModalToggle } from './MobileModalToggle'; -export { MobileModalButton } from './MobileModalButton'; -export { MobileModalSection } from './MobileModalSection'; -export { MobileTriggerCard } from './MobileTriggerCard'; -export type { MobileTriggerCardCallbacks } from './MobileTriggerCard'; diff --git a/src/settings/Settings.ts b/src/settings/Settings.ts index 60374e3..955d502 100644 --- a/src/settings/Settings.ts +++ b/src/settings/Settings.ts @@ -1,45 +1,15 @@ import NoteMoverShortcutPlugin from 'main'; import { PluginSettingTab } from 'obsidian'; -import { - HistoryEntry, - BulkOperation, - RetentionPolicy, -} from '../types/HistoryEntry'; -import { SETTINGS_CONSTANTS } from '../config/constants'; import { PeriodicMovementSettingsSection, FilterSettingsSection, RulesSettingsSection, HistorySettingsSection, ImportExportSettingsSection, - LegacySettingsSection, + PerformanceDebugSettingsSection, } from './sections'; import { DebounceManager } from '../utils/DebounceManager'; import { MobileUtils } from '../utils/MobileUtils'; -import { MobileSettingsRenderer } from './mobile/MobileSettingsRenderer'; - -interface Rule { - criteria: string; // Format: "type: value" (e.g. "tag: #project", "fileName: notes.md") - path: string; -} - -/** - * @deprecated Use PluginData instead - */ -export interface NoteMoverShortcutSettings { - enablePeriodicMovement: boolean; - periodicMovementInterval: number; - enableOnEditTrigger?: boolean; - filter: string[]; - rules: Rule[]; - history?: HistoryEntry[]; - bulkOperations?: BulkOperation[]; - lastSeenVersion?: string; - retentionPolicy?: RetentionPolicy; -} - -export const DEFAULT_SETTINGS: NoteMoverShortcutSettings = - SETTINGS_CONSTANTS.DEFAULT_SETTINGS; export class NoteMoverShortcutSettingsTab extends PluginSettingTab { private periodicMovementSettings: PeriodicMovementSettingsSection; @@ -47,7 +17,7 @@ export class NoteMoverShortcutSettingsTab extends PluginSettingTab { private rulesSettings: RulesSettingsSection; private historySettings: HistorySettingsSection; private importExportSettings: ImportExportSettingsSection; - private legacySettings: LegacySettingsSection; + private performanceDebugSettings: PerformanceDebugSettingsSection; private debounceManager: DebounceManager; constructor(private plugin: NoteMoverShortcutPlugin) { @@ -85,7 +55,7 @@ export class NoteMoverShortcutSettingsTab extends PluginSettingTab { this.containerEl, debouncedDisplay ); - this.legacySettings = new LegacySettingsSection( + this.performanceDebugSettings = new PerformanceDebugSettingsSection( plugin, this.containerEl, debouncedDisplay @@ -98,17 +68,6 @@ export class NoteMoverShortcutSettingsTab extends PluginSettingTab { // Add mobile-specific classes to container MobileUtils.addMobileClass(this.containerEl); - // Use mobile renderer if on mobile, otherwise use desktop implementation - if (MobileUtils.isMobile()) { - const mobileRenderer = new MobileSettingsRenderer( - this.plugin, - this.containerEl - ); - mobileRenderer.render(); - return; - } - - // Desktop implementation (existing code) // Clean up existing section instances before creating new ones this.cleanupExistingSections(); @@ -147,7 +106,7 @@ export class NoteMoverShortcutSettingsTab extends PluginSettingTab { this.containerEl, debouncedDisplay ); - this.legacySettings = new LegacySettingsSection( + this.performanceDebugSettings = new PerformanceDebugSettingsSection( this.plugin, this.containerEl, debouncedDisplay @@ -165,21 +124,13 @@ export class NoteMoverShortcutSettingsTab extends PluginSettingTab { this.importExportSettings.addImportExportSettings(); - this.legacySettings.addLegacySettings(); + this.performanceDebugSettings.addPerformanceDebugSettings(); } /** - * Ensure arrays exist without removing empty rules during display + * Ensure filter array exists without removing empty rules during display */ private ensureArraysExist(): void { - // Ensure rules array exists and is valid - if (!Array.isArray(this.plugin.settings.settings?.rules)) { - (this.plugin.settings as any).settings = - this.plugin.settings.settings || ({} as any); - (this.plugin.settings.settings as any).rules = []; - } - - // Ensure filter array exists and is valid if (!Array.isArray(this.plugin.settings.settings?.filters?.filter)) { (this.plugin.settings as any).settings = this.plugin.settings.settings || ({} as any); @@ -189,35 +140,6 @@ export class NoteMoverShortcutSettingsTab extends PluginSettingTab { } } - /** - * Validate settings to prevent data loss during rendering - * This method should only be called when explicitly validating settings - */ - private validateSettings(): void { - // Ensure arrays exist first - this.ensureArraysExist(); - - // Remove any invalid rules (empty criteria or path) - this.plugin.settings.settings.rules = - this.plugin.settings.settings.rules.filter( - (rule: any) => - rule && - rule.criteria && - rule.path && - rule.criteria.trim() !== '' && - rule.path.trim() !== '' - ); - - // Remove any invalid filters (empty strings) - this.plugin.settings.settings.filters.filter = - this.plugin.settings.settings.filters.filter.filter( - (filter: any) => - filter && - typeof filter.value === 'string' && - filter.value.trim() !== '' - ); - } - /** * Clean up existing section instances to prevent memory leaks */ diff --git a/src/settings/mobile/MobileSettingsRenderer.ts b/src/settings/mobile/MobileSettingsRenderer.ts deleted file mode 100644 index cb686ad..0000000 --- a/src/settings/mobile/MobileSettingsRenderer.ts +++ /dev/null @@ -1,38 +0,0 @@ -import NoteMoverShortcutPlugin from 'main'; -import { MobileTriggersSection } from './sections/MobileTriggersSection'; -import { MobileFiltersSection } from './sections/MobileFiltersSection'; -import { MobileRulesSection } from './sections/MobileRulesSection'; -import { MobileHistorySection } from './sections/MobileHistorySection'; -import { MobileImportExportSection } from './sections/MobileImportExportSection'; -import { MobileLegacySection } from './sections/MobileLegacySection'; - -/** - * Main mobile settings renderer that orchestrates all mobile sections - */ -export class MobileSettingsRenderer { - constructor( - private plugin: NoteMoverShortcutPlugin, - private containerEl: HTMLElement - ) {} - - render(): void { - this.containerEl.addClass('noteMover-mobile-settings-container'); - this.containerEl.empty(); - - // Render all sections in order - new MobileTriggersSection(this.plugin, this.containerEl, () => - this.render() - ).render(); - new MobileFiltersSection(this.plugin, this.containerEl, () => - this.render() - ).render(); - new MobileRulesSection(this.plugin, this.containerEl, () => - this.render() - ).render(); - new MobileHistorySection(this.plugin, this.containerEl).render(); - new MobileImportExportSection(this.plugin, this.containerEl).render(); - new MobileLegacySection(this.plugin, this.containerEl, () => - this.render() - ).render(); - } -} diff --git a/src/settings/mobile/components/MobileButtonSetting.ts b/src/settings/mobile/components/MobileButtonSetting.ts deleted file mode 100644 index 1de1120..0000000 --- a/src/settings/mobile/components/MobileButtonSetting.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { MobileSettingItem } from './MobileSettingItem'; - -export interface MobileButtonOptions { - isPrimary?: boolean; - isWarning?: boolean; - isDanger?: boolean; -} - -/** - * Mobile-optimized button setting component - */ -export class MobileButtonSetting { - private settingItem: MobileSettingItem; - private buttonEl: HTMLButtonElement; - - constructor( - container: HTMLElement, - title: string, - description: string | undefined, - buttonText: string, - onClick: () => void | Promise, - options: MobileButtonOptions = {} - ) { - this.settingItem = new MobileSettingItem(container, title, description); - this.settingItem.addClass('noteMover-mobile-button-setting'); - - const controlEl = this.settingItem.getControlElement(); - - // Create button - this.buttonEl = controlEl.createEl('button', { - cls: 'noteMover-mobile-action-btn', - text: buttonText, - }); - - // Apply button type styles - if (options.isPrimary) { - this.buttonEl.addClass('mod-cta'); - this.buttonEl.style.background = 'var(--interactive-accent)'; - this.buttonEl.style.color = 'var(--text-on-accent)'; - } else if (options.isWarning || options.isDanger) { - this.buttonEl.addClass('noteMover-mobile-delete-btn'); - } - - // Add click handler - this.buttonEl.addEventListener('click', async () => { - await onClick(); - }); - } - - setButtonText(text: string): void { - this.buttonEl.textContent = text; - } - - getButtonElement(): HTMLButtonElement { - return this.buttonEl; - } - - getCardElement(): HTMLElement { - return this.settingItem.getCardElement(); - } -} diff --git a/src/settings/mobile/components/MobileInputSetting.ts b/src/settings/mobile/components/MobileInputSetting.ts deleted file mode 100644 index a395fa9..0000000 --- a/src/settings/mobile/components/MobileInputSetting.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { MobileSettingItem } from './MobileSettingItem'; - -/** - * Mobile-optimized input setting component - */ -export class MobileInputSetting { - private settingItem: MobileSettingItem; - private inputEl: HTMLInputElement; - - constructor( - container: HTMLElement, - title: string, - description: string | undefined, - placeholder: string, - value: string, - onChange: (value: string) => void | Promise - ) { - this.settingItem = new MobileSettingItem(container, title, description); - this.settingItem.addClass('noteMover-mobile-input-setting'); - - const controlEl = this.settingItem.getControlElement(); - - // Create input - this.inputEl = controlEl.createEl('input', { - cls: 'noteMover-mobile-text-input', - type: 'text', - attr: { - placeholder: placeholder, - value: value, - }, - }); - - // Add change handler - this.inputEl.addEventListener('input', async () => { - await onChange(this.inputEl.value); - }); - } - - setValue(value: string): void { - this.inputEl.value = value; - } - - getValue(): string { - return this.inputEl.value; - } - - getInputElement(): HTMLInputElement { - return this.inputEl; - } - - getCardElement(): HTMLElement { - return this.settingItem.getCardElement(); - } -} diff --git a/src/settings/mobile/components/MobileRuleItem.ts b/src/settings/mobile/components/MobileRuleItem.ts deleted file mode 100644 index 0628237..0000000 --- a/src/settings/mobile/components/MobileRuleItem.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { RuleV2 } from '../../../types/RuleV2'; -import { Rule } from '../../../types/Rule'; -import { setIcon } from 'obsidian'; -import { MobileUtils } from '../../../utils/MobileUtils'; - -export interface MobileRuleItemCallbacks { - onToggle?: (active: boolean) => void | Promise; - onEdit?: () => void; - onClone?: () => void | Promise; - onDelete?: () => void | Promise; - onMoveUp?: () => void | Promise; - onMoveDown?: () => void | Promise; - canMoveUp?: boolean; - canMoveDown?: boolean; -} - -/** - * Mobile-optimized rule item component for RuleV2 - */ -export class MobileRuleItemV2 { - private cardEl: HTMLElement; - private headerEl: HTMLElement; - private nameEl: HTMLElement; - private toggleEl: HTMLElement; - private toggleInput: HTMLInputElement; - private actionsEl: HTMLElement; - private moveButtonsContainer: HTMLElement; - private moveUpButton: HTMLButtonElement; - private moveDownButton: HTMLButtonElement; - private editButton: HTMLButtonElement; - private cloneButton: HTMLButtonElement; - private deleteButton: HTMLButtonElement; - - constructor( - container: HTMLElement, - rule: RuleV2, - callbacks: MobileRuleItemCallbacks - ) { - // Create card - this.cardEl = container.createDiv({ cls: 'noteMover-mobile-rule-card' }); - - // Create header with name and toggle - this.headerEl = this.cardEl.createDiv({ - cls: 'noteMover-mobile-rule-header', - }); - this.nameEl = this.headerEl.createDiv({ - cls: 'noteMover-mobile-rule-name', - }); - this.nameEl.textContent = rule.name || 'Unnamed Rule'; - - // Create toggle - this.toggleEl = this.headerEl.createDiv({ - cls: 'noteMover-mobile-rule-toggle', - }); - this.toggleInput = document.createElement('input'); - this.toggleInput.type = 'checkbox'; - this.toggleInput.className = 'noteMover-mobile-toggle-input'; - this.toggleInput.checked = rule.active; - this.toggleEl.appendChild(this.toggleInput); - - const toggleSlider = this.toggleEl.createDiv({ - cls: 'noteMover-mobile-toggle-slider', - }); - - this.updateToggleState(rule.active); - - // Toggle handler - this.toggleInput.addEventListener('change', async () => { - const newValue = this.toggleInput.checked; - this.updateToggleState(newValue); - if (callbacks.onToggle) { - await callbacks.onToggle(newValue); - } - }); - - this.toggleEl.addEventListener('click', e => { - if (e.target !== this.toggleInput) { - this.toggleInput.click(); - } - }); - - // Create actions row - this.actionsEl = this.cardEl.createDiv({ - cls: 'noteMover-mobile-rule-actions', - }); - - // Create move buttons container - this.moveButtonsContainer = this.actionsEl.createDiv({ - cls: 'noteMover-mobile-move-buttons-container', - }); - - // Move Up button - if (callbacks.canMoveUp !== false) { - const upButton = MobileUtils.createMoveButton( - this.moveButtonsContainer, - 'up', - async () => { - if (callbacks.onMoveUp) { - await callbacks.onMoveUp(); - } - }, - false - ); - if (upButton) { - this.moveUpButton = upButton; - } - } - - // Move Down button - if (callbacks.canMoveDown !== false) { - const downButton = MobileUtils.createMoveButton( - this.moveButtonsContainer, - 'down', - async () => { - if (callbacks.onMoveDown) { - await callbacks.onMoveDown(); - } - }, - false - ); - if (downButton) { - this.moveDownButton = downButton; - } - } - - // Edit button - this.editButton = this.actionsEl.createEl('button', { - cls: 'noteMover-mobile-action-btn noteMover-mobile-edit-btn', - text: 'Edit', - }); - this.editButton.addEventListener('click', () => { - if (callbacks.onEdit) { - callbacks.onEdit(); - } - }); - - // Clone button - this.cloneButton = this.actionsEl.createEl('button', { - cls: 'noteMover-mobile-action-btn noteMover-mobile-clone-btn', - text: 'Clone', - }); - this.cloneButton.addEventListener('click', async () => { - if (callbacks.onClone) { - await callbacks.onClone(); - } - }); - - // Delete button - this.deleteButton = this.actionsEl.createEl('button', { - cls: 'noteMover-mobile-action-btn noteMover-mobile-delete-btn', - text: 'Delete', - }); - this.deleteButton.addEventListener('click', async () => { - if (callbacks.onDelete) { - await callbacks.onDelete(); - } - }); - } - - private updateToggleState(value: boolean): void { - if (value) { - this.toggleEl.addClass('is-active'); - } else { - this.toggleEl.removeClass('is-active'); - } - // Also update the input state - this.toggleInput.checked = value; - } - - setActive(active: boolean): void { - this.toggleInput.checked = active; - this.updateToggleState(active); - } - - getCardElement(): HTMLElement { - return this.cardEl; - } -} - -/** - * Mobile-optimized rule item component for RuleV1 - */ -/** - * Mobile-optimized rule item component for Rules V1 (legacy). - * Rules V1 is no longer in active development. Used only when Legacy Mode is enabled. - * @deprecated Legacy code. Prefer MobileRuleItemV2. - */ -export class MobileRuleItemV1 { - private cardEl: HTMLElement; - private criteriaInput: HTMLInputElement; - private pathInput: HTMLInputElement; - private moveButtonsContainer: HTMLElement; - private moveUpButton: HTMLButtonElement; - private moveDownButton: HTMLButtonElement; - private deleteButton: HTMLButtonElement; - - constructor( - container: HTMLElement, - rule: Rule, - callbacks: { - onCriteriaChange?: (value: string) => void | Promise; - onPathChange?: (value: string) => void | Promise; - onDelete?: () => void | Promise; - onMoveUp?: () => void | Promise; - onMoveDown?: () => void | Promise; - canMoveUp?: boolean; - canMoveDown?: boolean; - } - ) { - // Create card - this.cardEl = container.createDiv({ cls: 'noteMover-mobile-rule-card' }); - - // Criteria input - const criteriaLabel = this.cardEl.createDiv({ - cls: 'noteMover-mobile-input-label', - text: 'Criteria', - }); - this.criteriaInput = this.cardEl.createEl('input', { - cls: 'noteMover-mobile-text-input', - type: 'text', - attr: { - placeholder: 'e.g., tag: #project', - value: rule.criteria || '', - }, - }); - - if (callbacks.onCriteriaChange) { - this.criteriaInput.addEventListener('input', async () => { - if (callbacks.onCriteriaChange) { - await callbacks.onCriteriaChange(this.criteriaInput.value); - } - }); - } - - // Path input - const pathLabel = this.cardEl.createDiv({ - cls: 'noteMover-mobile-input-label', - text: 'Destination Path', - }); - this.pathInput = this.cardEl.createEl('input', { - cls: 'noteMover-mobile-text-input', - type: 'text', - attr: { - placeholder: 'e.g., Projects/', - value: rule.path || '', - }, - }); - - if (callbacks.onPathChange) { - this.pathInput.addEventListener('input', async () => { - if (callbacks.onPathChange) { - await callbacks.onPathChange(this.pathInput.value); - } - }); - } - - // Create move buttons container - this.moveButtonsContainer = this.cardEl.createDiv({ - cls: 'noteMover-mobile-move-buttons-container', - }); - - // Move Up button - if (callbacks.canMoveUp !== false) { - const upButton = MobileUtils.createMoveButton( - this.moveButtonsContainer, - 'up', - async () => { - if (callbacks.onMoveUp) { - await callbacks.onMoveUp(); - } - }, - false - ); - if (upButton) { - this.moveUpButton = upButton; - } - } - - // Move Down button - if (callbacks.canMoveDown !== false) { - const downButton = MobileUtils.createMoveButton( - this.moveButtonsContainer, - 'down', - async () => { - if (callbacks.onMoveDown) { - await callbacks.onMoveDown(); - } - }, - false - ); - if (downButton) { - this.moveDownButton = downButton; - } - } - - // Delete button - this.deleteButton = this.cardEl.createEl('button', { - cls: 'noteMover-mobile-action-btn noteMover-mobile-delete-btn', - text: 'Delete', - }); - - if (callbacks.onDelete) { - this.deleteButton.addEventListener('click', async () => { - if (callbacks.onDelete) { - await callbacks.onDelete(); - } - }); - } - } - - getCriteriaValue(): string { - return this.criteriaInput.value; - } - - getPathValue(): string { - return this.pathInput.value; - } - - setCriteriaValue(value: string): void { - this.criteriaInput.value = value; - } - - setPathValue(value: string): void { - this.pathInput.value = value; - } - - getCardElement(): HTMLElement { - return this.cardEl; - } -} diff --git a/src/settings/mobile/components/MobileSettingItem.ts b/src/settings/mobile/components/MobileSettingItem.ts deleted file mode 100644 index fcde30e..0000000 --- a/src/settings/mobile/components/MobileSettingItem.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Base mobile setting item component with compact card-based layout - */ -export class MobileSettingItem { - private cardEl: HTMLElement; - private headerEl: HTMLElement; - private titleEl: HTMLElement; - private descriptionEl: HTMLElement | null = null; - private controlEl: HTMLElement; - - constructor(container: HTMLElement, title: string, description?: string) { - // Create card container - this.cardEl = container.createDiv({ cls: 'noteMover-mobile-setting-card' }); - - // Create header - this.headerEl = this.cardEl.createDiv({ - cls: 'noteMover-mobile-setting-header', - }); - this.titleEl = this.headerEl.createDiv({ - cls: 'noteMover-mobile-setting-title', - }); - this.titleEl.textContent = title; - - // Create description if provided - if (description) { - this.descriptionEl = this.cardEl.createDiv({ - cls: 'noteMover-mobile-setting-description', - }); - this.descriptionEl.textContent = description; - } - - // Create control container - this.controlEl = this.cardEl.createDiv({ - cls: 'noteMover-mobile-setting-control', - }); - } - - getCardElement(): HTMLElement { - return this.cardEl; - } - - getControlElement(): HTMLElement { - return this.controlEl; - } - - setDescription(text: string): void { - if (this.descriptionEl) { - this.descriptionEl.textContent = text; - } else { - this.descriptionEl = this.cardEl.createDiv({ - cls: 'noteMover-mobile-setting-description', - }); - this.descriptionEl.textContent = text; - } - } - - addClass(cls: string): void { - this.cardEl.addClass(cls); - } -} diff --git a/src/settings/mobile/components/MobileToggleSetting.ts b/src/settings/mobile/components/MobileToggleSetting.ts deleted file mode 100644 index a9c9886..0000000 --- a/src/settings/mobile/components/MobileToggleSetting.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { MobileSettingItem } from './MobileSettingItem'; - -/** - * Mobile-optimized toggle setting component - */ -export class MobileToggleSetting { - private settingItem: MobileSettingItem; - private toggleEl: HTMLElement; - private toggleInput: HTMLInputElement; - - constructor( - container: HTMLElement, - title: string, - description: string | undefined, - value: boolean, - onChange: (value: boolean) => void | Promise - ) { - this.settingItem = new MobileSettingItem(container, title, description); - this.settingItem.addClass('noteMover-mobile-toggle-setting'); - - const cardEl = this.settingItem.getCardElement(); - const headerEl = cardEl.querySelector( - '.noteMover-mobile-setting-header' - ) as HTMLElement; - - if (!headerEl) { - throw new Error('Header element not found'); - } - - // Create toggle switch directly in header (right side) - this.toggleEl = headerEl.createDiv({ - cls: 'noteMover-mobile-toggle-switch', - }); - this.toggleInput = document.createElement('input'); - this.toggleInput.type = 'checkbox'; - this.toggleInput.className = 'noteMover-mobile-toggle-input'; - this.toggleInput.checked = value; - this.toggleEl.appendChild(this.toggleInput); - - // Add visual toggle switch - const toggleSlider = this.toggleEl.createDiv({ - cls: 'noteMover-mobile-toggle-slider', - }); - - // Update visual state - this.updateToggleState(value); - - // Add click handler - this.toggleInput.addEventListener('change', async () => { - const newValue = this.toggleInput.checked; - this.updateToggleState(newValue); - await onChange(newValue); - }); - - // Make entire toggle area clickable - this.toggleEl.addEventListener('click', e => { - if (e.target !== this.toggleInput) { - this.toggleInput.click(); - } - }); - - // Hide the control element since toggle is in header - const controlEl = this.settingItem.getControlElement(); - controlEl.style.display = 'none'; - } - - private updateToggleState(value: boolean): void { - if (value) { - this.toggleEl.addClass('is-active'); - } else { - this.toggleEl.removeClass('is-active'); - } - } - - setValue(value: boolean): void { - this.toggleInput.checked = value; - this.updateToggleState(value); - } - - getValue(): boolean { - return this.toggleInput.checked; - } - - getCardElement(): HTMLElement { - return this.settingItem.getCardElement(); - } -} diff --git a/src/settings/mobile/sections/MobileFiltersSection.ts b/src/settings/mobile/sections/MobileFiltersSection.ts deleted file mode 100644 index dd32527..0000000 --- a/src/settings/mobile/sections/MobileFiltersSection.ts +++ /dev/null @@ -1,179 +0,0 @@ -import NoteMoverShortcutPlugin from 'main'; -import { MobileButtonSetting } from '../components/MobileButtonSetting'; -import { MobileInputSetting } from '../components/MobileInputSetting'; -import { AdvancedSuggest } from '../../suggesters/AdvancedSuggest'; -import { SETTINGS_CONSTANTS } from '../../../config/constants'; -import { MobileUtils } from '../../../utils/MobileUtils'; - -/** - * Mobile-optimized filters section - */ -export class MobileFiltersSection { - private filterInputs: Array<{ - input: MobileInputSetting; - suggest: AdvancedSuggest; - }> = []; - private sectionContainer: HTMLElement; - - constructor( - private plugin: NoteMoverShortcutPlugin, - private containerEl: HTMLElement, - private refreshDisplay?: () => void - ) { - this.sectionContainer = this.containerEl.createDiv({ - cls: 'noteMover-mobile-section-container', - }); - } - - render(): void { - this.sectionContainer.empty(); - - // Section heading - const heading = this.sectionContainer.createDiv({ - cls: 'noteMover-mobile-section-heading', - }); - heading.textContent = 'Filter'; - - // Description - const desc = this.sectionContainer.createDiv({ - cls: 'noteMover-mobile-section-description', - }); - desc.textContent = - 'Blacklist filters. Files matching any criteria will be excluded from movement.'; - - // Filters list container - const filtersContainer = this.sectionContainer.createDiv({ - cls: 'noteMover-mobile-filters-list-container', - }); - - // Render existing filters - this.renderFilters(filtersContainer); - - // Add Filter Button - const addFilterButton = new MobileButtonSetting( - this.sectionContainer, - '', - undefined, - 'Add new filter', - async () => { - this.plugin.settings.settings.filters.filter.push({ value: '' }); - await this.plugin.save_settings(); - this.plugin.noteMover.updateRuleManager(); - if (this.refreshDisplay) { - this.refreshDisplay(); - } - }, - { isPrimary: true } - ); - } - - private moveFilter(index: number, direction: number): void { - const filters = this.plugin.settings.settings.filters.filter; - if (!filters || filters.length === 0) return; - - const newIndex = Math.max( - 0, - Math.min(filters.length - 1, index + direction) - ); - if (newIndex === index) return; - - [filters[index], filters[newIndex]] = [filters[newIndex], filters[index]]; - this.plugin.save_settings(); - this.plugin.noteMover.updateRuleManager(); - if (this.refreshDisplay) { - this.refreshDisplay(); - } - } - - private renderFilters(container: HTMLElement): void { - // Clean up existing inputs - this.filterInputs = []; - container.empty(); - - const filters = this.plugin.settings.settings.filters.filter; - filters.forEach((filter, index) => { - // Create filter card - const filterCard = container.createDiv({ - cls: 'noteMover-mobile-filter-card', - }); - - // Filter input - const inputLabel = filterCard.createDiv({ - cls: 'noteMover-mobile-input-label', - text: `Filter ${index + 1}`, - }); - - const inputContainer = filterCard.createDiv(); - const input = inputContainer.createEl('input', { - cls: 'noteMover-mobile-text-input', - type: 'text', - attr: { - placeholder: SETTINGS_CONSTANTS.PLACEHOLDER_TEXTS.FILTER, - value: (filter?.value as string) || '', - }, - }); - - // Add AdvancedSuggest - const suggest = new AdvancedSuggest(this.plugin.app, input); - - // Input change handler - input.addEventListener('input', async () => { - if (!input.value || input.value.trim() === '') { - filters[index] = { value: '' }; - } else { - filters[index] = { - value: input.value, - }; - } - await this.plugin.save_settings(); - this.plugin.noteMover.updateRuleManager(); - }); - - // Create move buttons container - const moveButtonsContainer = filterCard.createDiv({ - cls: 'noteMover-mobile-move-buttons-container', - }); - - // Move Up button - if (index > 0) { - MobileUtils.createMoveButton( - moveButtonsContainer, - 'up', - async () => { - this.moveFilter(index, -1); - }, - false - ); - } - - // Move Down button - if (index < filters.length - 1) { - MobileUtils.createMoveButton( - moveButtonsContainer, - 'down', - async () => { - this.moveFilter(index, 1); - }, - false - ); - } - - // Delete button - const deleteButton = filterCard.createEl('button', { - cls: 'noteMover-mobile-action-btn noteMover-mobile-delete-btn', - text: 'Delete', - }); - - deleteButton.addEventListener('click', async () => { - filters.splice(index, 1); - await this.plugin.save_settings(); - this.plugin.noteMover.updateRuleManager(); - if (this.refreshDisplay) { - this.refreshDisplay(); - } - }); - - this.filterInputs.push({ input: null as any, suggest }); - }); - } -} diff --git a/src/settings/mobile/sections/MobileHistorySection.ts b/src/settings/mobile/sections/MobileHistorySection.ts deleted file mode 100644 index be94797..0000000 --- a/src/settings/mobile/sections/MobileHistorySection.ts +++ /dev/null @@ -1,151 +0,0 @@ -import NoteMoverShortcutPlugin from 'main'; -import { MobileButtonSetting } from '../components/MobileButtonSetting'; -import { MobileInputSetting } from '../components/MobileInputSetting'; -import { ConfirmModal } from '../../../modals/ConfirmModal'; -import { - SETTINGS_CONSTANTS, - HISTORY_CONSTANTS, -} from '../../../config/constants'; -import { RetentionPolicy } from '../../../types/HistoryEntry'; - -/** - * Mobile-optimized history section - */ -export class MobileHistorySection { - constructor( - private plugin: NoteMoverShortcutPlugin, - private containerEl: HTMLElement - ) {} - - render(): void { - // Section heading - const heading = this.containerEl.createDiv({ - cls: 'noteMover-mobile-section-heading', - }); - heading.textContent = 'History'; - - // Retention Policy - this.renderRetentionPolicy(); - - // Clean up old entries button - const retentionPolicy = this.plugin.settings.settings.retentionPolicy || { - ...HISTORY_CONSTANTS.DEFAULT_RETENTION_POLICY, - }; - - new MobileButtonSetting( - this.containerEl, - 'Clean up old entries', - `Remove entries older than ${retentionPolicy.value} ${retentionPolicy.unit}`, - 'Clean up now', - async () => { - await this.plugin.historyManager.cleanupOldEntries(); - }, - { isPrimary: true } - ); - - // Clear history button - new MobileButtonSetting( - this.containerEl, - 'Clear history', - 'Clears the history of moved notes', - SETTINGS_CONSTANTS.UI_TEXTS.CLEAR_HISTORY, - async () => { - const confirmed = await ConfirmModal.show(this.plugin.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(); - } - }, - { isWarning: true } - ); - } - - private renderRetentionPolicy(): void { - // Initialize retention policy if not set - if (!this.plugin.settings.settings.retentionPolicy) { - this.plugin.settings.settings.retentionPolicy = { - ...HISTORY_CONSTANTS.DEFAULT_RETENTION_POLICY, - } as RetentionPolicy; - } - - const retentionPolicy = this.plugin.settings.settings.retentionPolicy; - - // Retention Policy Card - const policyCard = this.containerEl.createDiv({ - cls: 'noteMover-mobile-setting-card', - }); - - // Title - const title = policyCard.createDiv({ - cls: 'noteMover-mobile-setting-title', - text: SETTINGS_CONSTANTS.UI_TEXTS.RETENTION_POLICY_TITLE, - }); - - // Description - const desc = policyCard.createDiv({ - cls: 'noteMover-mobile-setting-description', - text: SETTINGS_CONSTANTS.UI_TEXTS.RETENTION_POLICY_DESC, - }); - - // Value and Unit container - const controlsContainer = policyCard.createDiv({ - cls: 'noteMover-mobile-retention-controls', - }); - - // Value input - const valueInput = controlsContainer.createEl('input', { - type: 'text', - cls: 'noteMover-mobile-text-input', - attr: { - placeholder: '30', - value: retentionPolicy.value.toString(), - }, - }); - - valueInput.addEventListener('input', async () => { - const numValue = parseInt(valueInput.value); - if (!isNaN(numValue) && numValue > 0) { - this.plugin.settings.settings.retentionPolicy!.value = numValue; - await (this.plugin as any).save_settings(); - // Update description - desc.textContent = `Remove entries older than ${numValue} ${retentionPolicy.unit}`; - } - }); - - // Unit dropdown - const unitSelect = controlsContainer.createEl('select', { - cls: 'noteMover-mobile-dropdown', - }); - - const daysOption = unitSelect.createEl('option', { - text: SETTINGS_CONSTANTS.UI_TEXTS.RETENTION_POLICY_DAYS, - attr: { value: 'days' }, - }); - const weeksOption = unitSelect.createEl('option', { - text: SETTINGS_CONSTANTS.UI_TEXTS.RETENTION_POLICY_WEEKS, - attr: { value: 'weeks' }, - }); - const monthsOption = unitSelect.createEl('option', { - text: SETTINGS_CONSTANTS.UI_TEXTS.RETENTION_POLICY_MONTHS, - attr: { value: 'months' }, - }); - - unitSelect.value = retentionPolicy.unit; - - unitSelect.addEventListener('change', async () => { - this.plugin.settings.settings.retentionPolicy!.unit = unitSelect.value as - | 'days' - | 'weeks' - | 'months'; - await (this.plugin as any).save_settings(); - // Update description - desc.textContent = `Remove entries older than ${retentionPolicy.value} ${unitSelect.value}`; - }); - } -} diff --git a/src/settings/mobile/sections/MobileImportExportSection.ts b/src/settings/mobile/sections/MobileImportExportSection.ts deleted file mode 100644 index 260c8c6..0000000 --- a/src/settings/mobile/sections/MobileImportExportSection.ts +++ /dev/null @@ -1,173 +0,0 @@ -import NoteMoverShortcutPlugin from 'main'; -import { MobileButtonSetting } from '../components/MobileButtonSetting'; -import { SETTINGS_CONSTANTS } from '../../../config/constants'; -import { SettingsValidator } from '../../../utils/SettingsValidator'; -import { RuleMigrationService } from '../../../core/RuleMigrationService'; -import { createError, handleError } from '../../../utils/Error'; -import { NoticeManager } from '../../../utils/NoticeManager'; - -/** - * Mobile-optimized import/export section - */ -export class MobileImportExportSection { - constructor( - private plugin: NoteMoverShortcutPlugin, - private containerEl: HTMLElement - ) {} - - render(): void { - // Section heading - const heading = this.containerEl.createDiv({ - cls: 'noteMover-mobile-section-heading', - }); - heading.textContent = 'Import/Export'; - - // Export Settings Button - new MobileButtonSetting( - this.containerEl, - 'Export settings', - 'Export your current settings as a JSON file', - SETTINGS_CONSTANTS.UI_TEXTS.EXPORT_SETTINGS, - async () => { - await this.exportSettings(); - }, - { isPrimary: true } - ); - - // Import Settings Button - Ensure it's always visible - new MobileButtonSetting( - this.containerEl, - 'Import settings', - 'Import settings from a JSON file', - SETTINGS_CONSTANTS.UI_TEXTS.IMPORT_SETTINGS, - async () => { - await this.importSettings(); - }, - { isWarning: true } - ); - } - - private async exportSettings(): Promise { - try { - // Build export object without history - const settingsToExport: any = { - triggers: this.plugin.settings.settings.triggers, - filters: this.plugin.settings.settings.filters, - rules: this.plugin.settings.settings.rules, - rulesV2: this.plugin.settings.settings.rulesV2, - enableLegacyRules: this.plugin.settings.settings.enableLegacyRules, - legacyMigrationDismissed: - this.plugin.settings.settings.legacyMigrationDismissed, - retentionPolicy: this.plugin.settings.settings.retentionPolicy, - }; - - const jsonString = JSON.stringify(settingsToExport, null, 2); - const blob = new Blob([jsonString], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `note-mover-settings-${Date.now()}.json`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - - NoticeManager.success('Settings exported successfully'); - } catch (error) { - handleError( - createError('Failed to export settings', error), - 'Export settings', - false - ); - } - } - - private async importSettings(): Promise { - try { - // Create file input - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.json'; - input.style.display = 'none'; - - input.addEventListener('change', async e => { - const file = (e.target as HTMLInputElement).files?.[0]; - if (!file) return; - - try { - const text = await file.text(); - const importedSettings = JSON.parse(text); - - // Validate imported settings - const validationResult = - SettingsValidator.validateSettings(importedSettings); - if (!validationResult.isValid) { - NoticeManager.error( - `Invalid settings file: ${validationResult.errors.join(', ')}` - ); - return; - } - - // Show confirmation - const confirmed = confirm( - 'Import settings will replace your current settings. Continue?' - ); - if (!confirmed) return; - - // Migrate rules if needed - if (importedSettings.rules && !importedSettings.rulesV2) { - importedSettings.rulesV2 = RuleMigrationService.migrateRules( - importedSettings.rules - ); - } - - // Import settings - this.plugin.settings.settings.triggers = - importedSettings.triggers || this.plugin.settings.settings.triggers; - this.plugin.settings.settings.filters = - importedSettings.filters || this.plugin.settings.settings.filters; - this.plugin.settings.settings.rules = - importedSettings.rules || this.plugin.settings.settings.rules; - this.plugin.settings.settings.rulesV2 = - importedSettings.rulesV2 || this.plugin.settings.settings.rulesV2; - if (importedSettings.enableLegacyRules !== undefined) { - this.plugin.settings.settings.enableLegacyRules = - !!importedSettings.enableLegacyRules; - } else if (importedSettings.enableRuleV2 !== undefined) { - this.plugin.settings.settings.enableLegacyRules = - !importedSettings.enableRuleV2; - } - if (importedSettings.legacyMigrationDismissed !== undefined) { - this.plugin.settings.settings.legacyMigrationDismissed = - !!importedSettings.legacyMigrationDismissed; - } - this.plugin.settings.settings.retentionPolicy = - importedSettings.retentionPolicy || - this.plugin.settings.settings.retentionPolicy; - - await this.plugin.save_settings(); - this.plugin.noteMover.updateRuleManager(); - - NoticeManager.success('Settings imported successfully'); - } catch (error) { - handleError( - createError('Failed to import settings', error), - 'Import settings', - false - ); - } finally { - document.body.removeChild(input); - } - }); - - document.body.appendChild(input); - input.click(); - } catch (error) { - handleError( - createError('Failed to open file picker', error), - 'Import settings', - false - ); - } - } -} diff --git a/src/settings/mobile/sections/MobileLegacySection.ts b/src/settings/mobile/sections/MobileLegacySection.ts deleted file mode 100644 index 02c7827..0000000 --- a/src/settings/mobile/sections/MobileLegacySection.ts +++ /dev/null @@ -1,39 +0,0 @@ -import NoteMoverShortcutPlugin from 'main'; -import { MobileToggleSetting } from '../components/MobileToggleSetting'; - -/** - * Mobile section for the Legacy Rules (V1) toggle, placed at the bottom of the settings. - */ -export class MobileLegacySection { - constructor( - private plugin: NoteMoverShortcutPlugin, - private containerEl: HTMLElement, - private refreshDisplay?: () => void - ) {} - - render(): void { - const sectionContainer = this.containerEl.createDiv({ - cls: 'noteMover-mobile-section-container', - }); - - const heading = sectionContainer.createDiv({ - cls: 'noteMover-mobile-section-heading', - }); - heading.textContent = 'Legacy'; - - new MobileToggleSetting( - sectionContainer, - 'Enable Legacy Rules (V1)', - 'Use the older rule format. Rules V2 is the default.', - this.plugin.settings.settings.enableLegacyRules ?? false, - async value => { - this.plugin.settings.settings.enableLegacyRules = value; - await this.plugin.save_settings(); - this.plugin.noteMover.updateRuleManager(); - if (this.refreshDisplay) { - this.refreshDisplay(); - } - } - ); - } -} diff --git a/src/settings/mobile/sections/MobileRulesSection.ts b/src/settings/mobile/sections/MobileRulesSection.ts deleted file mode 100644 index 6c93e5b..0000000 --- a/src/settings/mobile/sections/MobileRulesSection.ts +++ /dev/null @@ -1,305 +0,0 @@ -import NoteMoverShortcutPlugin from 'main'; -import { MobileToggleSetting } from '../components/MobileToggleSetting'; -import { MobileButtonSetting } from '../components/MobileButtonSetting'; -import { - MobileRuleItemV2, - MobileRuleItemV1, -} from '../components/MobileRuleItem'; -import { RuleEditorModal } from '../../../modals/RuleEditorModal'; -import { RuleV2 } from '../../../types/RuleV2'; -import { Rule } from '../../../types/Rule'; -import { AdvancedSuggest } from '../../suggesters/AdvancedSuggest'; -import { FolderSuggest } from '../../suggesters/FolderSuggest'; -import { createError, handleError } from '../../../utils/Error'; -import { SETTINGS_CONSTANTS } from '../../../config/constants'; - -/** - * Mobile-optimized rules section - */ -export class MobileRulesSection { - private ruleItems: Array = []; - private sectionContainer: HTMLElement; - - constructor( - private plugin: NoteMoverShortcutPlugin, - private containerEl: HTMLElement, - private refreshDisplay?: () => void - ) { - this.sectionContainer = this.containerEl.createDiv({ - cls: 'noteMover-mobile-section-container', - }); - } - - render(): void { - this.sectionContainer.empty(); - - // Section heading - const heading = this.sectionContainer.createDiv({ - cls: 'noteMover-mobile-section-heading', - }); - heading.textContent = 'Rules'; - - // Description - const desc = this.sectionContainer.createDiv({ - cls: 'noteMover-mobile-section-description', - }); - desc.textContent = - 'Move files to folders based on criteria. First matching rule applies.'; - - // Add Rule Button - new MobileButtonSetting( - this.sectionContainer, - '', - undefined, - this.plugin.settings.settings.enableLegacyRules - ? 'Add new rule' - : '+ Add Rule', - async () => { - if (this.plugin.settings.settings.enableLegacyRules) { - this.plugin.settings.settings.rules.push({ criteria: '', path: '' }); - await this.plugin.save_settings(); - this.plugin.noteMover.updateRuleManager(); - if (this.refreshDisplay) { - this.refreshDisplay(); - } - } else { - this.openRuleEditorModal(null); - } - }, - { isPrimary: true } - ); - - // Rules List - const rulesContainer = this.sectionContainer.createDiv({ - cls: 'noteMover-mobile-rules-list-container', - }); - - if (this.plugin.settings.settings.enableLegacyRules) { - this.renderRulesV1(rulesContainer); - } else { - this.renderRulesV2(rulesContainer); - } - } - - private moveRuleV2(index: number, direction: number): void { - const rulesV2 = this.plugin.settings.settings.rulesV2; - if (!rulesV2 || rulesV2.length === 0) return; - - const newIndex = Math.max( - 0, - Math.min(rulesV2.length - 1, index + direction) - ); - if (newIndex === index) return; - - [rulesV2[index], rulesV2[newIndex]] = [rulesV2[newIndex], rulesV2[index]]; - this.plugin.save_settings(); - this.plugin.noteMover.updateRuleManager(); - if (this.refreshDisplay) { - this.refreshDisplay(); - } - } - - private moveRuleV1(index: number, direction: number): void { - const rules = this.plugin.settings.settings.rules; - if (!rules || rules.length === 0) return; - - const newIndex = Math.max(0, Math.min(rules.length - 1, index + direction)); - if (newIndex === index) return; - - [rules[index], rules[newIndex]] = [rules[newIndex], rules[index]]; - this.plugin.save_settings(); - this.plugin.noteMover.updateRuleManager(); - if (this.refreshDisplay) { - this.refreshDisplay(); - } - } - - private renderRulesV2(container: HTMLElement): void { - if (!this.plugin.settings.settings.rulesV2) { - this.plugin.settings.settings.rulesV2 = []; - } - - this.ruleItems = []; - const rulesV2 = this.plugin.settings.settings.rulesV2; - - rulesV2.forEach((rule, index) => { - const ruleItem = new MobileRuleItemV2(container, rule, { - onToggle: async active => { - rulesV2[index].active = active; - await this.plugin.save_settings(); - }, - onEdit: () => { - this.openRuleEditorModal(index); - }, - onClone: async () => { - const clonedRule = JSON.parse(JSON.stringify(rule)) as RuleV2; - const baseName = rule.name?.trim() || 'Unnamed Rule'; - clonedRule.name = `${baseName} (copy)`; - rulesV2.splice(index + 1, 0, clonedRule); - await this.plugin.save_settings(); - this.plugin.noteMover.updateRuleManager(); - if (this.refreshDisplay) { - this.refreshDisplay(); - } - }, - onDelete: async () => { - const confirmed = confirm( - `Delete rule "${rule.name}"?\n\nThis cannot be undone.` - ); - if (confirmed) { - rulesV2.splice(index, 1); - await this.plugin.save_settings(); - this.plugin.noteMover.updateRuleManager(); - if (this.refreshDisplay) { - this.refreshDisplay(); - } - } - }, - onMoveUp: async () => { - if (index > 0) { - this.moveRuleV2(index, -1); - } - }, - onMoveDown: async () => { - if (index < rulesV2.length - 1) { - this.moveRuleV2(index, 1); - } - }, - canMoveUp: index > 0, - canMoveDown: index < rulesV2.length - 1, - }); - - this.ruleItems.push(ruleItem); - }); - } - - private renderRulesV1(container: HTMLElement): void { - this.ruleItems = []; - const rules = this.plugin.settings.settings.rules; - - rules.forEach((rule, index) => { - const ruleItem = new MobileRuleItemV1(container, rule, { - onCriteriaChange: async value => { - if ( - value && - rules.some((r, i) => i !== index && r.criteria === value) - ) { - handleError( - createError( - 'This criteria already has a folder associated with it' - ), - 'Rules setting', - false - ); - return; - } - - rules[index].criteria = value; - await this.plugin.save_settings(); - this.plugin.noteMover.updateRuleManager(); - }, - onPathChange: async value => { - rules[index].path = value; - await this.plugin.save_settings(); - }, - onDelete: async () => { - rules.splice(index, 1); - await this.plugin.save_settings(); - this.plugin.noteMover.updateRuleManager(); - if (this.refreshDisplay) { - this.refreshDisplay(); - } - }, - onMoveUp: async () => { - if (index > 0) { - this.moveRuleV1(index, -1); - } - }, - onMoveDown: async () => { - if (index < rules.length - 1) { - this.moveRuleV1(index, 1); - } - }, - canMoveUp: index > 0, - canMoveDown: index < rules.length - 1, - }); - - // Add AdvancedSuggest for criteria input - const criteriaInput = ruleItem - .getCardElement() - .querySelector('input[type="text"]') as HTMLInputElement; - if (criteriaInput) { - new AdvancedSuggest(this.plugin.app, criteriaInput); - } - - // Add FolderSuggest for path input - const pathInputs = ruleItem - .getCardElement() - .querySelectorAll('input[type="text"]'); - if (pathInputs.length > 1) { - new FolderSuggest(this.plugin.app, pathInputs[1] as HTMLInputElement); - } - - this.ruleItems.push(ruleItem); - }); - } - - private openRuleEditorModal(ruleIndex: number | null): void { - const isEditMode = ruleIndex !== null; - let rule: RuleV2; - - if (isEditMode) { - rule = JSON.parse( - JSON.stringify(this.plugin.settings.settings.rulesV2![ruleIndex!]) - ); - } else { - rule = { - name: 'New Rule', - destination: '', - aggregation: 'all', - triggers: [ - { - criteriaType: 'tag', - operator: 'includes item', - value: '', - }, - ], - active: true, - }; - } - - const modal = new RuleEditorModal(this.plugin.app, { - rule, - isEditMode, - onSave: async (updatedRule: RuleV2) => { - if (!this.plugin.settings.settings.rulesV2) { - this.plugin.settings.settings.rulesV2 = []; - } - - if (isEditMode) { - this.plugin.settings.settings.rulesV2[ruleIndex!] = updatedRule; - } else { - this.plugin.settings.settings.rulesV2.push(updatedRule); - } - - await this.plugin.save_settings(); - this.plugin.noteMover.updateRuleManager(); - if (this.refreshDisplay) { - this.refreshDisplay(); - } - }, - onDelete: isEditMode - ? async () => { - this.plugin.settings.settings.rulesV2!.splice(ruleIndex!, 1); - await this.plugin.save_settings(); - this.plugin.noteMover.updateRuleManager(); - if (this.refreshDisplay) { - this.refreshDisplay(); - } - } - : undefined, - }); - - modal.open(); - } -} diff --git a/src/settings/mobile/sections/MobileTriggersSection.ts b/src/settings/mobile/sections/MobileTriggersSection.ts deleted file mode 100644 index 2e41bbf..0000000 --- a/src/settings/mobile/sections/MobileTriggersSection.ts +++ /dev/null @@ -1,112 +0,0 @@ -import NoteMoverShortcutPlugin from 'main'; -import { MobileToggleSetting } from '../components/MobileToggleSetting'; -import { MobileInputSetting } from '../components/MobileInputSetting'; -import { createError, handleError } from '../../../utils/Error'; -import { SETTINGS_CONSTANTS } from '../../../config/constants'; - -/** - * Mobile-optimized triggers section - */ -export class MobileTriggersSection { - private sectionContainer: HTMLElement; - - constructor( - private plugin: NoteMoverShortcutPlugin, - private containerEl: HTMLElement, - private refreshDisplay?: () => void - ) { - this.sectionContainer = this.containerEl.createDiv({ - cls: 'noteMover-mobile-section-container', - }); - } - - render(): void { - this.sectionContainer.empty(); - - // Section heading - const heading = this.sectionContainer.createDiv({ - cls: 'noteMover-mobile-section-heading', - }); - heading.textContent = 'Triggers'; - - // On Edit Trigger - new MobileToggleSetting( - this.sectionContainer, - 'Enable on-edit trigger', - 'Move notes automatically when edited', - this.plugin.settings.settings.triggers.enableOnEditTrigger === true, - async value => { - this.plugin.settings.settings.triggers.enableOnEditTrigger = value; - await this.plugin.save_settings(); - (this.plugin as any).triggerHandler?.toggleOnEditListener(); - } - ); - - // Periodic Movement Toggle - const periodicToggle = new MobileToggleSetting( - this.sectionContainer, - 'Enable periodic movement', - 'Move notes at regular intervals', - this.plugin.settings.settings.triggers.enablePeriodicMovement, - async value => { - this.plugin.settings.settings.triggers.enablePeriodicMovement = value; - await this.plugin.save_settings(); - (this.plugin as any).triggerHandler?.togglePeriodic(); - // Refresh display to show/hide interval input - if (this.refreshDisplay) { - this.refreshDisplay(); - } - } - ); - - // Rule Evaluation Cache (beta) - new MobileToggleSetting( - this.sectionContainer, - 'Enable rule evaluation cache (beta)', - 'Skip re-evaluation for unchanged files to improve performance', - this.plugin.settings.settings.enableRuleEvaluationCache ?? false, - async value => { - this.plugin.settings.settings.enableRuleEvaluationCache = value; - await this.plugin.save_settings(); - if (!value) { - this.plugin.ruleCache.invalidateAll(); - } - } - ); - - // Periodic Movement Interval (only if enabled) - if (this.plugin.settings.settings.triggers.enablePeriodicMovement) { - new MobileInputSetting( - this.sectionContainer, - 'Periodic movement interval', - 'Interval in minutes', - SETTINGS_CONSTANTS.PLACEHOLDER_TEXTS.INTERVAL, - this.plugin.settings.settings.triggers.periodicMovementInterval.toString(), - 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.settings.triggers.periodicMovementInterval = - interval; - await this.plugin.save_settings(); - (this.plugin as any).triggerHandler?.togglePeriodic(); - } - ); - } - } -} diff --git a/src/settings/sections/FilterSettingsSection.ts b/src/settings/sections/FilterSettingsSection.ts index 334ccba..80c1918 100644 --- a/src/settings/sections/FilterSettingsSection.ts +++ b/src/settings/sections/FilterSettingsSection.ts @@ -75,7 +75,11 @@ export class FilterSettingsSection { const s = new Setting(filtersContainer) .addSearch(cb => { // AdvancedSuggest instead of TagSuggest - const advancedSuggest = new AdvancedSuggest(this.app, cb.inputEl); + const advancedSuggest = new AdvancedSuggest( + this.app, + cb.inputEl, + this.plugin.vaultIndexCache + ); // Track the instance for cleanup this.advancedSuggestInstances.push(advancedSuggest); cb.setPlaceholder(SETTINGS_CONSTANTS.PLACEHOLDER_TEXTS.FILTER) diff --git a/src/settings/sections/ImportExportSettingsSection.ts b/src/settings/sections/ImportExportSettingsSection.ts index 50959a8..f885ad2 100644 --- a/src/settings/sections/ImportExportSettingsSection.ts +++ b/src/settings/sections/ImportExportSettingsSection.ts @@ -104,8 +104,9 @@ export class ImportExportSettingsSection { // Show success message NoticeManager.info(SETTINGS_CONSTANTS.UI_TEXTS.EXPORT_SUCCESS); } catch (error) { + const msg = error instanceof Error ? error.message : String(error); handleError( - createError(`Export failed: ${error.message}`), + createError(`Export failed: ${msg}`), 'Export settings', false ); @@ -216,7 +217,7 @@ export class ImportExportSettingsSection { } if (Array.isArray(s.rules)) { - current.settings.rules = s.rules; + (current.settings as any).rules = s.rules; } if (s.retentionPolicy) { @@ -232,17 +233,6 @@ export class ImportExportSettingsSection { current.schemaVersion = importedSettings.schemaVersion; } - // Import enableLegacyRules (or migrate from old enableRuleV2) - if (s.enableLegacyRules !== undefined) { - current.settings.enableLegacyRules = !!s.enableLegacyRules; - } else if ((s as any).enableRuleV2 !== undefined) { - current.settings.enableLegacyRules = !(s as any).enableRuleV2; - } - if (s.legacyMigrationDismissed !== undefined) { - current.settings.legacyMigrationDismissed = - !!s.legacyMigrationDismissed; - } - // Import RuleV2 if present if (s.rulesV2 !== undefined) { current.settings.rulesV2 = s.rulesV2; @@ -272,7 +262,8 @@ export class ImportExportSettingsSection { .map((v: string) => ({ value: v })); } if (Array.isArray(sanitizedSettings.rules)) { - current.settings.rules = sanitizedSettings.rules; + (current.settings as { rules?: unknown[] }).rules = + sanitizedSettings.rules; } if (sanitizedSettings.retentionPolicy) { current.settings.retentionPolicy = @@ -283,22 +274,21 @@ export class ImportExportSettingsSection { } } - // Migrate V1 rules to V2 when not in legacy mode - if (!current.settings.enableLegacyRules) { - if ( - RuleMigrationService.shouldMigrate( - current.settings.rules, - current.settings.rulesV2 - ) - ) { - console.log('Migrating imported Rule V1 to Rule V2...'); - current.settings.rulesV2 = RuleMigrationService.migrateRules( - current.settings.rules - ); - console.log( - `Migrated ${current.settings.rulesV2.length} imported rules to V2 format` - ); - } + const legacyRules = (current.settings as any).rules; + if ( + Array.isArray(legacyRules) && + RuleMigrationService.shouldMigrate( + legacyRules, + current.settings.rulesV2 ?? [] + ) + ) { + console.log('Migrating imported Rule V1 to Rule V2...'); + current.settings.rulesV2 = + RuleMigrationService.migrateRules(legacyRules); + (current.settings as any).rules = []; + console.log( + `Migrated ${(current.settings.rulesV2 ?? []).length} imported rules to V2 format` + ); } // Save settings @@ -314,9 +304,10 @@ export class ImportExportSettingsSection { NoticeManager.success(SETTINGS_CONSTANTS.UI_TEXTS.IMPORT_SUCCESS); } } catch (error) { - if (error.message !== 'File selection cancelled') { + const msg = error instanceof Error ? error.message : String(error); + if (msg !== 'File selection cancelled') { handleError( - createError(`Import failed: ${error.message}`), + createError(`Import failed: ${msg}`), 'Import settings', false ); diff --git a/src/settings/sections/LegacySettingsSection.ts b/src/settings/sections/LegacySettingsSection.ts deleted file mode 100644 index d5f31e4..0000000 --- a/src/settings/sections/LegacySettingsSection.ts +++ /dev/null @@ -1,38 +0,0 @@ -import NoteMoverShortcutPlugin from 'main'; -import { Setting } from 'obsidian'; - -/** - * Settings section for the Legacy Rules (V1) toggle, placed at the bottom of the settings. - */ -export class LegacySettingsSection { - constructor( - private plugin: NoteMoverShortcutPlugin, - private containerEl: HTMLElement, - private refreshDisplay: () => void - ) {} - - addLegacySettings(): void { - new Setting(this.containerEl).setName('Legacy').setHeading(); - - const legacyDesc = document.createDocumentFragment(); - legacyDesc.append( - 'Enable Legacy Rules (V1) to use the older rule format. Rules V2 is now the default and supports multiple conditions and logical operators.', - document.createElement('br'), - 'Legacy mode is not actively developed. Consider migrating to Rules V2 via the migration prompt.' - ); - - new Setting(this.containerEl) - .setName('Enable Legacy Rules (V1)') - .setDesc(legacyDesc) - .addToggle(toggle => - toggle - .setValue(this.plugin.settings.settings.enableLegacyRules ?? false) - .onChange(async value => { - this.plugin.settings.settings.enableLegacyRules = value; - await this.plugin.save_settings(); - this.plugin.noteMover.updateRuleManager(); - this.refreshDisplay(); - }) - ); - } -} diff --git a/src/settings/sections/PerformanceDebugSettingsSection.ts b/src/settings/sections/PerformanceDebugSettingsSection.ts new file mode 100644 index 0000000..a1ec48f --- /dev/null +++ b/src/settings/sections/PerformanceDebugSettingsSection.ts @@ -0,0 +1,88 @@ +import NoteMoverShortcutPlugin from 'main'; +import { Setting } from 'obsidian'; +import { NoticeManager } from '../../utils/NoticeManager'; + +/** + * Vault index cache toggle, performance debug logging, and trace export. + */ +export class PerformanceDebugSettingsSection { + constructor( + private plugin: NoteMoverShortcutPlugin, + private containerEl: HTMLElement, + private refreshDisplay: () => void + ) {} + + addPerformanceDebugSettings(): void { + new Setting(this.containerEl).setName('Performance').setHeading(); + + new Setting(this.containerEl) + .setName('Enable vault index cache') + .setDesc( + 'Caches the markdown file list and derived tag/property indices. Turn off to always scan the vault (useful for debugging stale suggestions).' + ) + .addToggle(toggle => + toggle + .setValue( + this.plugin.settings.settings.enableVaultIndexCache !== false + ) + .onChange(async value => { + this.plugin.settings.settings.enableVaultIndexCache = value; + await this.plugin.save_settings(); + this.plugin.vaultIndexCache.invalidateMarkdownList(); + this.refreshDisplay(); + }) + ); + + new Setting(this.containerEl) + .setName('Enable performance debug logs') + .setDesc( + 'Logs timing spans to the developer console as [NoteMover perf] and records them for export. Disable when not profiling.' + ) + .addToggle(toggle => + toggle + .setValue( + this.plugin.settings.settings.enablePerformanceDebug === true + ) + .onChange(async value => { + this.plugin.settings.settings.enablePerformanceDebug = value; + await this.plugin.save_settings(); + this.refreshDisplay(); + }) + ); + + const debugOn = + this.plugin.settings.settings.enablePerformanceDebug === true; + + new Setting(this.containerEl) + .setName('Export performance trace') + .setDesc( + debugOn + ? 'Writes recorded timings as JSON to _note-mover-traces/ in your vault for before/after comparison.' + : 'Turn on performance debug logs first to record timings.' + ) + .addButton(btn => + btn + .setButtonText('Export trace JSON') + .setDisabled(!debugOn) + .onClick(async () => { + if (this.plugin.settings.settings.enablePerformanceDebug !== true) { + NoticeManager.warning( + 'Enable performance debug logs first, then run the actions you want to measure.' + ); + return; + } + try { + const path = + await this.plugin.performanceTrace.writeExportToVault( + this.plugin.app + ); + NoticeManager.success(`Performance trace saved: ${path}`); + } catch (err) { + NoticeManager.error( + `Could not export trace: ${err instanceof Error ? err.message : String(err)}` + ); + } + }) + ); + } +} diff --git a/src/settings/sections/PeriodicMovementSettingsSection.ts b/src/settings/sections/PeriodicMovementSettingsSection.ts index 8e2e23a..706ba4d 100644 --- a/src/settings/sections/PeriodicMovementSettingsSection.ts +++ b/src/settings/sections/PeriodicMovementSettingsSection.ts @@ -89,12 +89,12 @@ export class TriggerSettingsSection { ); const cacheSetting = new Setting(this.containerEl) - .setName('Enable rule evaluation cache (beta)') + .setName('Enable rule evaluation cache') .setDesc(cacheDesc) .addToggle(toggle => toggle .setValue( - this.plugin.settings.settings.enableRuleEvaluationCache ?? false + this.plugin.settings.settings.enableRuleEvaluationCache ?? true ) .onChange(async value => { this.plugin.settings.settings.enableRuleEvaluationCache = value; diff --git a/src/settings/sections/RulesSettingsSection.ts b/src/settings/sections/RulesSettingsSection.ts index 1e456a5..24af32f 100644 --- a/src/settings/sections/RulesSettingsSection.ts +++ b/src/settings/sections/RulesSettingsSection.ts @@ -1,8 +1,5 @@ import NoteMoverShortcutPlugin from 'main'; import { App, Setting } from 'obsidian'; -import { FolderSuggest } from '../suggesters/FolderSuggest'; -import { AdvancedSuggest } from '../suggesters/AdvancedSuggest'; -import { createError, handleError } from '../../utils/Error'; import { SETTINGS_CONSTANTS } from '../../config/constants'; import { DragDropManager } from '../../utils/DragDropManager'; import { RuleEditorModal } from '../../modals/RuleEditorModal'; @@ -11,7 +8,6 @@ import { MobileUtils } from '../../utils/MobileUtils'; export class RulesSettingsSection { private dragDropManager: DragDropManager | null = null; - private advancedSuggestInstances: AdvancedSuggest[] = []; constructor( private plugin: NoteMoverShortcutPlugin, @@ -33,29 +29,6 @@ export class RulesSettingsSection { } addAddRuleButtonSetting(): void { - if (this.plugin.settings.settings.enableLegacyRules) { - this.addAddRuleV1ButtonSetting(); - } else { - this.addAddRuleV2ButtonSetting(); - } - } - - private addAddRuleV1ButtonSetting(): void { - new Setting(this.containerEl).addButton(btn => - btn - .setButtonText('Add new rule') - .setCta() - .onClick(async () => { - this.plugin.settings.settings.rules.push({ criteria: '', path: '' }); - await this.plugin.save_settings(); - // Update RuleManager - this.plugin.noteMover.updateRuleManager(); - this.refreshDisplay(); - }) - ); - } - - private addAddRuleV2ButtonSetting(): void { new Setting(this.containerEl).addButton(btn => btn .setButtonText('+ Add Rule') @@ -67,170 +40,17 @@ export class RulesSettingsSection { } addRulesArray(): void { - if (this.plugin.settings.settings.enableLegacyRules) { - this.addRulesV1Array(); - } else { - this.addRulesV2Array(); - } - } - - private addRulesV1Array(): void { - // Clean up existing AdvancedSuggest instances before creating new ones - this.cleanupAdvancedSuggestInstances(); - - const isMobile = MobileUtils.isMobile(); - - // Create a container for rules with drag & drop - const rulesContainer = document.createElement('div'); - rulesContainer.className = 'noteMover-rules-container'; - if (isMobile) { - rulesContainer.addClass('noteMover-mobile-rules-container'); - } - this.containerEl.appendChild(rulesContainer); - - // Setup drag & drop manager - this.setupDragDropManager(rulesContainer); - - this.plugin.settings.settings.rules.forEach((rule, index) => { - const s = new Setting(rulesContainer) - .addSearch(cb => { - // AdvancedSuggest instead of TagSuggest - const advancedSuggest = new AdvancedSuggest(this.app, cb.inputEl); - // Track the instance for cleanup - this.advancedSuggestInstances.push(advancedSuggest); - cb.setPlaceholder(SETTINGS_CONSTANTS.PLACEHOLDER_TEXTS.CRITERIA) - .setValue(rule.criteria) - .onChange(async value => { - if ( - value && - this.plugin.settings.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.settings.rules[index].criteria = value; - await this.plugin.save_settings(); - // Update RuleManager - this.plugin.noteMover.updateRuleManager(); - }); - // @ts-ignore - cb.containerEl.addClass('noteMover-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.settings.rules[index].path = value; - await this.plugin.save_settings(); - }); - // @ts-ignore - cb.containerEl.addClass('noteMover-search'); - }) - .addExtraButton(btn => - btn.setIcon('cross').onClick(async () => { - this.plugin.settings.settings.rules.splice(index, 1); - await this.plugin.save_settings(); - // Update RuleManager - this.plugin.noteMover.updateRuleManager(); - this.refreshDisplay(); - }) - ); - - // Add drag handle to the setting - this.addDragHandle(s.settingEl, index); - s.infoEl.remove(); - - // Add mobile optimization classes - if (isMobile) { - s.settingEl.addClass('noteMover-mobile-rule-item'); - const controlEl = s.settingEl.querySelector('.setting-item-control'); - if (controlEl) { - (controlEl as HTMLElement).addClass('noteMover-mobile-rule-controls'); - } - } - }); - } - - moveRule(index: number, direction: number) { - const rules = this.plugin.settings.settings.rules; - const newIndex = Math.max(0, Math.min(rules.length - 1, index + direction)); - [rules[index], rules[newIndex]] = [rules[newIndex], rules[index]]; - this.plugin.save_settings(); - this.refreshDisplay(); - } - - private setupDragDropManager(container: HTMLElement): void { - // Clean up existing manager - if (this.dragDropManager) { - this.dragDropManager.destroy(); - } - - this.dragDropManager = new DragDropManager(container, { - onReorder: (fromIndex: number, toIndex: number) => { - this.reorderRules(fromIndex, toIndex); - }, - onSave: async () => { - await this.plugin.save_settings(); - this.plugin.noteMover.updateRuleManager(); - // Refresh display after settings are fully saved - this.refreshDisplay(); - }, - itemSelector: '.setting-item', - handleSelector: '.noteMover-drag-handle', - }); - } - - private addDragHandle(settingEl: HTMLElement, index: number): void { - const handle = DragDropManager.createDragHandle(); - const handleContainer = document.createElement('div'); - handleContainer.className = 'noteMover-drag-handle-container'; - handleContainer.appendChild(handle); - - // Insert handle at the beginning of the setting - settingEl.insertBefore(handleContainer, settingEl.firstChild); - settingEl.classList.add('noteMover-with-drag-handle'); - } - - private reorderRules(fromIndex: number, toIndex: number): void { - if (fromIndex === toIndex) return; - - const rules = this.plugin.settings.settings.rules; - const [movedRule] = rules.splice(fromIndex, 1); - rules.splice(toIndex, 0, movedRule); - - // Note: refreshDisplay() will be called by the onSave callback after settings are saved + this.addRulesV2Array(); } private get app(): App { return this.plugin.app; } - /** - * Clean up AdvancedSuggest instances to prevent memory leaks - */ - private cleanupAdvancedSuggestInstances(): void { - this.advancedSuggestInstances.forEach(instance => { - instance.destroy(); - }); - this.advancedSuggestInstances = []; - } - /** * Public cleanup method to be called when the section is destroyed */ public cleanup(): void { - this.cleanupAdvancedSuggestInstances(); if (this.dragDropManager) { this.dragDropManager.destroy(); this.dragDropManager = null; @@ -458,6 +278,7 @@ export class RulesSettingsSection { this.refreshDisplay(); } : undefined, + vaultIndexCache: this.plugin.vaultIndexCache, }); modal.open(); diff --git a/src/settings/sections/index.ts b/src/settings/sections/index.ts index af2e045..a8bc4ae 100644 --- a/src/settings/sections/index.ts +++ b/src/settings/sections/index.ts @@ -3,4 +3,4 @@ export { FilterSettingsSection } from './FilterSettingsSection'; export { RulesSettingsSection } from './RulesSettingsSection'; export { HistorySettingsSection } from './HistorySettingsSection'; export { ImportExportSettingsSection } from './ImportExportSettingsSection'; -export { LegacySettingsSection } from './LegacySettingsSection'; +export { PerformanceDebugSettingsSection } from './PerformanceDebugSettingsSection'; diff --git a/src/settings/suggesters/AdvancedSuggest.ts b/src/settings/suggesters/AdvancedSuggest.ts index 8a21e52..8e1ba61 100644 --- a/src/settings/suggesters/AdvancedSuggest.ts +++ b/src/settings/suggesters/AdvancedSuggest.ts @@ -1,5 +1,6 @@ import { App, AbstractInputSuggest, TFolder, TAbstractFile } from 'obsidian'; import { MetadataExtractor } from '../../core/MetadataExtractor'; +import type { PluginVaultIndexCache } from '../../infrastructure/cache/plugin-vault-index-cache'; export type SuggestType = | 'tag' @@ -36,7 +37,8 @@ export class AdvancedSuggest extends AbstractInputSuggest { constructor( public app: App, - private inputEl: HTMLInputElement + private inputEl: HTMLInputElement, + private vaultIndexCache?: PluginVaultIndexCache ) { super(app, inputEl); this.metadataExtractor = new MetadataExtractor(app); @@ -57,7 +59,11 @@ export class AdvancedSuggest extends AbstractInputSuggest { } private loadTags(): void { - this.tags = this.metadataExtractor.extractAllTags(); + if (this.vaultIndexCache) { + this.tags = this.vaultIndexCache.getAllTagsCached(this.app); + } else { + this.tags = this.metadataExtractor.extractAllTags(); + } } private loadFolders(): void { @@ -66,7 +72,9 @@ export class AdvancedSuggest extends AbstractInputSuggest { } private loadFileNames(): void { - const files = this.app.vault.getMarkdownFiles(); + const files = this.vaultIndexCache + ? this.vaultIndexCache.getMarkdownFilesCached(this.app) + : this.app.vault.getMarkdownFiles(); this.fileNames = files.map(f => f.name); } @@ -85,6 +93,17 @@ export class AdvancedSuggest extends AbstractInputSuggest { } private loadPropertyKeysAndValues(): void { + if (this.vaultIndexCache) { + const { keys, valuesByKey } = + this.vaultIndexCache.getPropertyKeysAndValuesCached(this.app); + this.propertyKeys = new Set(keys); + this.propertyValues = new Map(); + for (const [key, set] of valuesByKey) { + this.propertyValues.set(key, new Set(set)); + } + return; + } + const files = this.app.vault.getMarkdownFiles(); files.forEach(file => { const cachedMetadata = this.app.metadataCache.getFileCache(file); diff --git a/src/settings/suggesters/PropertyValueSuggest.ts b/src/settings/suggesters/PropertyValueSuggest.ts index 53cdebe..20c58f4 100644 --- a/src/settings/suggesters/PropertyValueSuggest.ts +++ b/src/settings/suggesters/PropertyValueSuggest.ts @@ -1,15 +1,17 @@ import { App, AbstractInputSuggest } from 'obsidian'; +import type { PluginVaultIndexCache } from '../../infrastructure/cache/plugin-vault-index-cache'; export class PropertyValueSuggest extends AbstractInputSuggest { private propertyName: string; private propertyType: 'text' | 'number' | 'checkbox' | 'date' | 'list'; - private availableValues: Set; + private availableValues = new Set(); constructor( app: App, private inputEl: HTMLInputElement, propertyName: string, - propertyType: 'text' | 'number' | 'checkbox' | 'date' | 'list' + propertyType: 'text' | 'number' | 'checkbox' | 'date' | 'list', + private vaultIndexCache?: PluginVaultIndexCache ) { super(app, inputEl); this.propertyName = propertyName; @@ -25,6 +27,15 @@ export class PropertyValueSuggest extends AbstractInputSuggest { return; } + if (this.vaultIndexCache) { + this.availableValues = this.vaultIndexCache.getPropertyValueSetCached( + this.app, + this.propertyName, + this.propertyType + ); + return; + } + const files = this.app.vault.getMarkdownFiles(); for (const file of files) { diff --git a/src/settings/suggesters/TagSuggest.ts b/src/settings/suggesters/TagSuggest.ts index 468bf48..5102e21 100644 --- a/src/settings/suggesters/TagSuggest.ts +++ b/src/settings/suggesters/TagSuggest.ts @@ -1,5 +1,6 @@ import { App, AbstractInputSuggest } from 'obsidian'; import { MetadataExtractor } from '../../core/MetadataExtractor'; +import type { PluginVaultIndexCache } from '../../infrastructure/cache/plugin-vault-index-cache'; export class TagSuggest extends AbstractInputSuggest { private tags: Set; @@ -7,7 +8,8 @@ export class TagSuggest extends AbstractInputSuggest { constructor( app: App, - private inputEl: HTMLInputElement + private inputEl: HTMLInputElement, + private vaultIndexCache?: PluginVaultIndexCache ) { super(app, inputEl); this.tags = new Set(); @@ -16,7 +18,11 @@ export class TagSuggest extends AbstractInputSuggest { } private loadTags(): void { - this.tags = this.metadataExtractor.extractAllTags(); + if (this.vaultIndexCache) { + this.tags = this.vaultIndexCache.getAllTagsCached(this.app); + } else { + this.tags = this.metadataExtractor.extractAllTags(); + } } getSuggestions(inputStr: string): string[] { diff --git a/src/test-utils/obsidian-vitest-stub.ts b/src/test-utils/obsidian-vitest-stub.ts new file mode 100644 index 0000000..24df06f --- /dev/null +++ b/src/test-utils/obsidian-vitest-stub.ts @@ -0,0 +1,14 @@ +/** + * Minimal stub so Vitest can load modules that import `obsidian`. + * Not used at runtime in Obsidian. + */ +export class TAbstractFile {} +export class TFile extends TAbstractFile { + extension = 'md'; + path = ''; + name = ''; +} + +export function getAllTags(_cache: Record): string[] { + return []; +} diff --git a/src/types/PluginData.ts b/src/types/PluginData.ts index 45b97bd..a7c0c38 100644 --- a/src/types/PluginData.ts +++ b/src/types/PluginData.ts @@ -1,5 +1,4 @@ import { BulkOperation, HistoryEntry, RetentionPolicy } from './HistoryEntry'; -import { Rule } from './Rule'; import { RuleV2 } from './RuleV2'; /** @@ -15,12 +14,13 @@ export interface PluginData { export interface SettingsData { triggers: TriggerSettings; filters: FilterSettings; - rules: Rule[]; retentionPolicy: RetentionPolicy; - enableLegacyRules?: boolean; - legacyMigrationDismissed?: boolean; rulesV2?: RuleV2[]; enableRuleEvaluationCache?: boolean; + /** When false, vault markdown list / tag / property index cache is bypassed (always fresh scans). Default true. */ + enableVaultIndexCache?: boolean; + /** When true, records timing spans and logs `[NoteMover perf]` to the console. */ + enablePerformanceDebug?: boolean; } export interface HistoryData { diff --git a/src/types/Rule.ts b/src/types/Rule.ts deleted file mode 100644 index b8d8a31..0000000 --- a/src/types/Rule.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Rule V1 interface (legacy). - * Use RuleV2 from './RuleV2' for new code. Rules V1 is no longer in active development. - * @deprecated Legacy format. Prefer RuleV2. Kept for backward compatibility when Legacy Mode is enabled. - */ -export interface Rule { - criteria: string; // Format: "type: value" (z.B. "tag: #project", "fileName: notes.md", "path: documents/") - path: string; -} diff --git a/src/utils/OperatorMapping.ts b/src/utils/OperatorMapping.ts index dcb778a..f0adc55 100644 --- a/src/utils/OperatorMapping.ts +++ b/src/utils/OperatorMapping.ts @@ -12,6 +12,10 @@ import { CheckboxPropertyOperator, } from '../types/RuleV2'; import { App } from 'obsidian'; +import type { PluginVaultIndexCache } from '../infrastructure/cache/plugin-vault-index-cache'; + +/** When using vault index cache, cap how many distinct property samples we scan for type inference. */ +const PROPERTY_TYPE_INFERENCE_MAX_SAMPLES = 80; /** * Utility functions for mapping operators to criteria types and property types @@ -335,16 +339,25 @@ export function operatorRequiresValue(operator: Operator): boolean { */ export function getPropertyTypeFromVault( app: App, - propertyName: string + propertyName: string, + vaultIndexCache?: PluginVaultIndexCache | null ): 'text' | 'number' | 'checkbox' | 'date' | 'list' | null { - const files = app.vault.getMarkdownFiles(); + const files = vaultIndexCache + ? vaultIndexCache.getMarkdownFilesCached(app) + : app.vault.getMarkdownFiles(); const valueSamples: any[] = []; + const maxSamples = vaultIndexCache + ? PROPERTY_TYPE_INFERENCE_MAX_SAMPLES + : Infinity; // Collect samples of the property value from multiple files for (const file of files) { const cache = app.metadataCache.getFileCache(file); if (cache?.frontmatter?.[propertyName] !== undefined) { valueSamples.push(cache.frontmatter[propertyName]); + if (valueSamples.length >= maxSamples) { + break; + } } } diff --git a/src/utils/SettingsValidator.ts b/src/utils/SettingsValidator.ts index b865fd9..3096397 100644 --- a/src/utils/SettingsValidator.ts +++ b/src/utils/SettingsValidator.ts @@ -1,8 +1,7 @@ -import { NoteMoverShortcutSettings } from '../settings/Settings'; import { HistoryEntry, BulkOperation } from '../types/HistoryEntry'; import { PluginData } from '../types/PluginData'; import { RuleV2, Trigger, CriteriaType, Operator } from '../types/RuleV2'; -import { validateDestinationTemplate } from './DestinationTemplate'; +import { validateDestinationTemplate } from '../domain/templates/DestinationTemplate'; import { getOperatorsForCriteriaType, getOperatorsForPropertyType, @@ -18,14 +17,6 @@ export interface ValidationResult { warnings: string[]; } -/** - * @deprecated Use RuleV2 from '../types/RuleV2' instead. This will be removed in a future version. - */ -export interface Rule { - criteria: string; - path: string; -} - export class SettingsValidator { /** * Validates a complete settings object @@ -49,7 +40,7 @@ export class SettingsValidator { return this.validatePluginData(settings as PluginData); } - // Otherwise treat as legacy NoteMoverShortcutSettings + // Flat legacy JSON (pre–PluginData export shape) return this.validateLegacySettings(settings); } @@ -70,14 +61,14 @@ export class SettingsValidator { return ( ('triggers' in s && typeof s.triggers === 'object') || ('filters' in s && typeof s.filters === 'object') || - Array.isArray(s.rules) + Array.isArray(s.rulesV2) ); } return false; } /** - * Validate legacy NoteMoverShortcutSettings + * Validate flat legacy settings JSON (historical export format without nested `settings`). */ private static validateLegacySettings(settings: any): ValidationResult { const result: ValidationResult = { @@ -394,8 +385,7 @@ export class SettingsValidator { result: ValidationResult ): boolean { if (value === undefined || value === null) { - result.errors.push('Field "rules" is required'); - return false; + return true; } if (!Array.isArray(value)) { @@ -506,7 +496,7 @@ export class SettingsValidator { /** * Creates a clean settings object with only valid fields */ - static sanitizeSettings(settings: any): Partial { + static sanitizeSettings(settings: any): Record { const sanitized: any = {}; // Copy valid fields diff --git a/styles.css b/styles.css index ce879d7..46cfab7 100644 --- a/styles.css +++ b/styles.css @@ -3122,3 +3122,14 @@ padding-top: 16px; } } + +.noteMover-preview-bulk-status { + margin-bottom: 0.5rem; + font-size: 0.9em; + color: var(--text-muted); +} + +.noteMover-preview-bulk-actions { + display: flex; + justify-content: flex-end; +} diff --git a/tsconfig.json b/tsconfig.json index 95aabf8..dcf2e46 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,15 +4,19 @@ "inlineSourceMap": true, "inlineSources": true, "module": "ESNext", - "target": "ES6", + "target": "ES2022", + "moduleResolution": "Bundler", "allowJs": true, - "noImplicitAny": true, - "moduleResolution": "node", + "strict": true, + "strictPropertyInitialization": false, + "noUncheckedIndexedAccess": false, + "verbatimModuleSyntax": false, + "skipLibCheck": true, "importHelpers": true, "isolatedModules": true, - "strictNullChecks": true, "esModuleInterop": true, - "lib": ["DOM", "ES5", "ES6", "ES7"] + "lib": ["DOM", "ES2022"], + "types": ["node", "vitest/globals"] }, "include": ["**/*.ts"] } diff --git a/versions.json b/versions.json index 16453d2..c2c149f 100644 --- a/versions.json +++ b/versions.json @@ -26,5 +26,7 @@ "0.6.0": "1.5.0", "0.7.0": "1.5.0", "0.7.1": "1.5.0", - "0.7.2": "1.5.0" + "0.7.2": "1.5.0", + "0.8.0": "1.5.0", + "1.0.0": "1.5.0" } \ No newline at end of file diff --git a/vitest.config.mjs b/vitest.config.mjs new file mode 100644 index 0000000..331ae57 --- /dev/null +++ b/vitest.config.mjs @@ -0,0 +1,24 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + resolve: { + conditions: ['browser', 'development'], + alias: { + obsidian: path.resolve(__dirname, 'src/test-utils/obsidian-vitest-stub.ts'), + }, + }, + test: { + globals: true, + environment: 'jsdom', + include: ['src/**/*.test.ts', 'src/**/*.spec.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'html'], + include: ['src/domain/**/*.ts'], + }, + }, +});