mirror of
https://github.com/bueckerlars/obsidian-note-mover-shortcut.git
synced 2026-07-22 05:46:00 +00:00
chore: restructured into new modular architecture
This commit is contained in:
parent
655234cdb8
commit
9b2571bb9e
84 changed files with 5654 additions and 6075 deletions
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -1,5 +1,24 @@
|
||||||
# Changelog
|
# 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)
|
## [0.7.1](https://github.com/bueckerlars/obsidian-note-mover-shortcut/compare/0.7.0...0.7.1)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
|
||||||
33
README.md
33
README.md
|
|
@ -32,10 +32,8 @@ Notes:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
- **Toggle blacklist/whitelist**: Choose between:
|
- **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).
|
||||||
- **Blacklist**: Move all files EXCEPT those matching the specified criteria
|
- **Filter criteria**: Add criteria to exclude from movement. Supported types:
|
||||||
- **Whitelist**: Move ONLY files matching the specified criteria
|
|
||||||
- **Filter criteria**: Add criteria to include/exclude from movement. Supported types:
|
|
||||||
- **Tags**: `tag: tagname` - Match files with specific tags (e.g., `tag: #inbox`, `tag: work/project`)
|
- **Tags**: `tag: tagname` - Match files with specific tags (e.g., `tag: #inbox`, `tag: work/project`)
|
||||||
- **Properties**: Property-based criteria from frontmatter:
|
- **Properties**: Property-based criteria from frontmatter:
|
||||||
- `property: key` - Match files that have the specified property key
|
- `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
|
- **Creation Date**: `created_at: date` - Match files based on creation date
|
||||||
- **Update Date**: `updated_at: date` - Match files based on last modification date
|
- **Update Date**: `updated_at: date` - Match files based on last modification date
|
||||||
|
|
||||||
#### Rules
|
#### Rules (Rule V2)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
- **Rules description**: Define custom rules for moving files based on various criteria. Rules are always active.
|
- **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).
|
||||||
- **Rule configuration**: For each rule, specify:
|
- **Order**: If multiple rules match, the **first** matching rule in the list wins.
|
||||||
- **Criteria**: The criteria that triggers the rule. Supported types:
|
- **Destinations**: Folders are created when needed. Template placeholders such as `{{tag.x}}` / `{{property.y}}` are supported (see below).
|
||||||
- **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.
|
|
||||||
|
|
||||||
##### Template Rules for Dynamic Destinations (Rule V2)
|
##### Template Rules for Dynamic Destinations (Rule V2)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ const context = await esbuild.context({
|
||||||
...builtins,
|
...builtins,
|
||||||
],
|
],
|
||||||
format: 'cjs',
|
format: 'cjs',
|
||||||
target: 'es2018',
|
target: 'es2022',
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
sourcemap: prod ? false : 'inline',
|
sourcemap: prod ? false : 'inline',
|
||||||
treeShaking: true,
|
treeShaking: true,
|
||||||
|
|
|
||||||
467
main.ts
467
main.ts
|
|
@ -1,4 +1,4 @@
|
||||||
import { Plugin, TFile, TAbstractFile } from 'obsidian';
|
import { Plugin } from 'obsidian';
|
||||||
import { NoteMoverShortcut } from 'src/core/NoteMoverShortcut';
|
import { NoteMoverShortcut } from 'src/core/NoteMoverShortcut';
|
||||||
import { CommandHandler } from 'src/handlers/CommandHandler';
|
import { CommandHandler } from 'src/handlers/CommandHandler';
|
||||||
import { NoteMoverShortcutSettingsTab } from 'src/settings/Settings';
|
import { NoteMoverShortcutSettingsTab } from 'src/settings/Settings';
|
||||||
|
|
@ -6,471 +6,90 @@ import { HistoryManager } from 'src/core/HistoryManager';
|
||||||
import { TriggerEventHandler } from 'src/core/TriggerEventHandler';
|
import { TriggerEventHandler } from 'src/core/TriggerEventHandler';
|
||||||
import { UpdateManager } from 'src/core/UpdateManager';
|
import { UpdateManager } from 'src/core/UpdateManager';
|
||||||
import { RuleEvaluationCache } from 'src/core/RuleEvaluationCache';
|
import { RuleEvaluationCache } from 'src/core/RuleEvaluationCache';
|
||||||
|
import type { PluginData } from 'src/types/PluginData';
|
||||||
import {
|
import {
|
||||||
PluginData,
|
loadPersistedSettings,
|
||||||
SettingsData,
|
savePersistedSettings,
|
||||||
HistoryData,
|
} from 'src/infrastructure/persistence/plugin-settings-controller';
|
||||||
Filter,
|
import {
|
||||||
} from 'src/types/PluginData';
|
registerVaultEventHooks,
|
||||||
import { SETTINGS_CONSTANTS } from 'src/config/constants';
|
registerVaultIndexMetadataListeners,
|
||||||
import { SettingsValidator } from 'src/utils/SettingsValidator';
|
} from 'src/infrastructure/plugin/vault-event-hooks';
|
||||||
import { RuleMigrationService } from 'src/core/RuleMigrationService';
|
import { PluginVaultIndexCache } from 'src/infrastructure/cache/plugin-vault-index-cache';
|
||||||
import { LegacyMigrationModal } from 'src/modals/LegacyMigrationModal';
|
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 {
|
export default class NoteMoverShortcutPlugin extends Plugin {
|
||||||
public settings: PluginData;
|
public settings!: PluginData;
|
||||||
public noteMover: NoteMoverShortcut;
|
public noteMover!: NoteMoverShortcut;
|
||||||
public command_handler: CommandHandler;
|
public command_handler!: CommandHandler;
|
||||||
public historyManager: HistoryManager;
|
public historyManager!: HistoryManager;
|
||||||
public updateManager: UpdateManager;
|
public updateManager!: UpdateManager;
|
||||||
public triggerHandler: TriggerEventHandler;
|
public triggerHandler!: TriggerEventHandler;
|
||||||
public ruleCache: RuleEvaluationCache;
|
public ruleCache!: RuleEvaluationCache;
|
||||||
private settingTab: NoteMoverShortcutSettingsTab;
|
public vaultIndexCache!: PluginVaultIndexCache;
|
||||||
|
public performanceTrace!: PerformanceTraceRecorder;
|
||||||
|
private settingTab!: NoteMoverShortcutSettingsTab;
|
||||||
|
/** Application-layer facades (use-cases); core logic remains on `noteMover`. */
|
||||||
|
public appServices!: PluginApplicationServices;
|
||||||
|
|
||||||
async onload() {
|
async onload() {
|
||||||
await this.load_settings();
|
await loadPersistedSettings(this);
|
||||||
|
|
||||||
this.ruleCache = new RuleEvaluationCache();
|
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 = new HistoryManager(this);
|
||||||
this.historyManager.loadHistoryFromSettings();
|
this.historyManager.loadHistoryFromSettings();
|
||||||
this.updateManager = new UpdateManager(this);
|
this.updateManager = new UpdateManager(this);
|
||||||
this.noteMover = new NoteMoverShortcut(this);
|
this.noteMover = new NoteMoverShortcut(this);
|
||||||
|
this.appServices = createPluginApplicationServices(this);
|
||||||
this.triggerHandler = new TriggerEventHandler(this);
|
this.triggerHandler = new TriggerEventHandler(this);
|
||||||
this.command_handler = new CommandHandler(this);
|
this.command_handler = new CommandHandler(this);
|
||||||
this.command_handler.setup();
|
this.command_handler.setup();
|
||||||
|
|
||||||
this.addRibbonIcon('book-plus', 'NoteMover', (evt: MouseEvent) => {
|
this.addRibbonIcon('book-plus', 'NoteMover', () => {
|
||||||
this.noteMover.moveFocusedNoteToDestination();
|
this.noteMover.moveFocusedNoteToDestination();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.settingTab = new NoteMoverShortcutSettingsTab(this);
|
this.settingTab = new NoteMoverShortcutSettingsTab(this);
|
||||||
this.addSettingTab(this.settingTab);
|
this.addSettingTab(this.settingTab);
|
||||||
|
|
||||||
// Initialize triggers
|
|
||||||
this.triggerHandler.togglePeriodic();
|
this.triggerHandler.togglePeriodic();
|
||||||
this.triggerHandler.toggleOnEditListener();
|
this.triggerHandler.toggleOnEditListener();
|
||||||
|
|
||||||
// Event listeners for history and cache dirty-tracking
|
registerVaultEventHooks(this);
|
||||||
this.setupVaultEventListeners();
|
registerVaultIndexMetadataListeners(this);
|
||||||
|
|
||||||
// Seed the rules hash so the cache knows the current config
|
|
||||||
this.syncRuleCacheHash();
|
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.app.workspace.onLayoutReady(() => {
|
||||||
this.updateManager.checkForUpdates();
|
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() {
|
onunload() {
|
||||||
// Cleanup settings tab debounce manager
|
this.settingTab?.cleanup();
|
||||||
if (this.settingTab) {
|
this.triggerHandler?.cleanup();
|
||||||
this.settingTab.cleanup();
|
|
||||||
}
|
|
||||||
// Cleanup trigger handler debounce manager
|
|
||||||
if (this.triggerHandler) {
|
|
||||||
this.triggerHandler.cleanup();
|
|
||||||
}
|
|
||||||
// Event listeners are automatically removed when the plugin is unloaded
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sync the rule-evaluation cache hash with the current settings. */
|
|
||||||
public syncRuleCacheHash(): void {
|
public syncRuleCacheHash(): void {
|
||||||
const s = this.settings.settings;
|
const s = this.settings.settings;
|
||||||
this.ruleCache.updateRulesHash(
|
this.ruleCache.updateRulesHash(s.rulesV2 ?? [], s.filters.filter);
|
||||||
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);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async save_settings(): Promise<void> {
|
async save_settings(): Promise<void> {
|
||||||
// Ensure history in settings is kept in sync and robust against corruption
|
await savePersistedSettings(this);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async load_settings(): Promise<void> {
|
async load_settings(): Promise<void> {
|
||||||
const savedData: any = await this.loadData();
|
await loadPersistedSettings(this);
|
||||||
|
|
||||||
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<void> {
|
|
||||||
// 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<void> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"id": "note-mover-shortcut",
|
"id": "note-mover-shortcut",
|
||||||
"name": "NoteMover shortcut",
|
"name": "NoteMover shortcut",
|
||||||
"version": "0.7.2",
|
"version": "1.0.0",
|
||||||
"minAppVersion": "1.5.0",
|
"minAppVersion": "1.5.0",
|
||||||
"description": "Quickly and easily move notes to a predefined folder. Perfect for organizing your notes.",
|
"description": "Quickly and easily move notes to a predefined folder. Perfect for organizing your notes.",
|
||||||
"author": "bueckerlars",
|
"author": "bueckerlars",
|
||||||
|
|
|
||||||
3196
package-lock.json
generated
3196
package-lock.json
generated
File diff suppressed because it is too large
Load diff
18
package.json
18
package.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "obsidian-note-mover-shortcut",
|
"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.",
|
"description": "Quickly and easily move notes to a predefined folder. Perfect for organizing your notes.",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -13,24 +13,32 @@
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"lint": "eslint . --ext .ts",
|
"lint": "eslint . --ext .ts",
|
||||||
"lint:fix": "eslint . --ext .ts --fix",
|
"lint:fix": "eslint . --ext .ts --fix",
|
||||||
|
"test": "vitest run --config vitest.config.mjs",
|
||||||
|
"test:watch": "vitest --config vitest.config.mjs",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "bueckerlars",
|
"author": "bueckerlars",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^16.11.6",
|
"@types/node": "^20.19.39",
|
||||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
"@typescript-eslint/eslint-plugin": "^8.58.2",
|
||||||
"@typescript-eslint/parser": "5.29.0",
|
"@typescript-eslint/parser": "^8.58.2",
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"builtin-modules": "3.3.0",
|
"builtin-modules": "3.3.0",
|
||||||
"esbuild": "^0.25.9",
|
"esbuild": "^0.25.9",
|
||||||
|
"eslint": "^8.57.1",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
"lint-staged": "^16.2.1",
|
"lint-staged": "^16.2.1",
|
||||||
"obsidian": "latest",
|
"obsidian": "latest",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"tslib": "2.4.0",
|
"tslib": "2.4.0",
|
||||||
"tsx": "^4.20.6",
|
"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": {
|
"dependencies": {
|
||||||
"@popperjs/core": "^2.11.8"
|
"@popperjs/core": "^2.11.8"
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ function parseChangelog(content: string): ChangelogEntry[] {
|
||||||
// Recognize version header (e.g. "## [0.2.1]" or "## [0.1.6]")
|
// Recognize version header (e.g. "## [0.2.1]" or "## [0.1.6]")
|
||||||
const versionMatch = line.match(/^##\s*\[?(\d+\.\d+\.\d+)\]?/);
|
const versionMatch = line.match(/^##\s*\[?(\d+\.\d+\.\d+)\]?/);
|
||||||
if (versionMatch) {
|
if (versionMatch) {
|
||||||
|
const nextVersion = versionMatch[1];
|
||||||
|
if (!nextVersion) continue;
|
||||||
// Save previous version if exists
|
// Save previous version if exists
|
||||||
if (currentVersion) {
|
if (currentVersion) {
|
||||||
entries.push({
|
entries.push({
|
||||||
|
|
@ -41,7 +43,7 @@ function parseChangelog(content: string): ChangelogEntry[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start new version
|
// Start new version
|
||||||
currentVersion = versionMatch[1];
|
currentVersion = nextVersion;
|
||||||
currentChanges = {};
|
currentChanges = {};
|
||||||
currentSection = null;
|
currentSection = null;
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
18
src/application/history-service.ts
Normal file
18
src/application/history-service.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/application/move-file-service.ts
Normal file
33
src/application/move-file-service.ts
Normal file
|
|
@ -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<void> {
|
||||||
|
await this.plugin.noteMover.moveFocusedNoteToDestination();
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveFileBasedOnTags(
|
||||||
|
file: TFile,
|
||||||
|
defaultFolder: string,
|
||||||
|
skipFilter = false
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.plugin.noteMover.moveFileBasedOnTags(
|
||||||
|
file,
|
||||||
|
defaultFolder,
|
||||||
|
skipFilter
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveAllFilesInVault(opts?: { signal?: AbortSignal }): Promise<void> {
|
||||||
|
await this.plugin.noteMover.moveAllFilesInVault(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRuleManager(): void {
|
||||||
|
this.plugin.noteMover.updateRuleManager();
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/application/plugin-application-services.ts
Normal file
20
src/application/plugin-application-services.ts
Normal file
|
|
@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
14
src/application/preview-service.ts
Normal file
14
src/application/preview-service.ts
Normal file
|
|
@ -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<MovePreview> {
|
||||||
|
return this.plugin.noteMover.generateVaultMovePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
generateActiveNotePreview(): Promise<MovePreview | null> {
|
||||||
|
return this.plugin.noteMover.generateActiveNotePreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<boolean> {
|
|
||||||
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<boolean> {
|
|
||||||
const targetPath = combinePath(targetFolder, file.name);
|
|
||||||
return this.moveFile(file, targetPath, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Undoes a move operation by moving file back to original location
|
|
||||||
*
|
|
||||||
* @param destinationPath - Current file location
|
|
||||||
* @param sourcePath - Original location to restore
|
|
||||||
* @param fileName - File name for notifications
|
|
||||||
* @param options - Undo configuration
|
|
||||||
* @returns True if successful
|
|
||||||
*/
|
|
||||||
public async undoMove(
|
|
||||||
destinationPath: string,
|
|
||||||
sourcePath: string,
|
|
||||||
fileName: string,
|
|
||||||
options: {
|
|
||||||
showNotifications?: boolean;
|
|
||||||
} = {}
|
|
||||||
): Promise<boolean> {
|
|
||||||
const { showNotifications = false } = options;
|
|
||||||
|
|
||||||
// Validate file exists at destination
|
|
||||||
const file = this.validateFileExists(destinationPath);
|
|
||||||
if (!file) {
|
|
||||||
const error = `File not found at path: ${destinationPath}`;
|
|
||||||
handleError(createError(error), 'undoMove', showNotifications);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure source folder exists
|
|
||||||
const sourceFolder = getParentPath(sourcePath);
|
|
||||||
if (sourceFolder && !(await ensureFolderExists(this.app, sourceFolder))) {
|
|
||||||
const error = `Failed to create source folder: ${sourceFolder}`;
|
|
||||||
handleError(createError(error), 'undoMove', showNotifications);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (showNotifications) {
|
|
||||||
NoticeManager.info(
|
|
||||||
`Attempting to move ${fileName} from ${destinationPath} to ${sourcePath}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark plugin move start
|
|
||||||
this.markPluginMoveStart();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Perform the undo move
|
|
||||||
await this.app.fileManager.renameFile(file, sourcePath);
|
|
||||||
|
|
||||||
if (showNotifications) {
|
|
||||||
NoticeManager.success(
|
|
||||||
`Successfully moved ${fileName} back to ${sourcePath}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} finally {
|
|
||||||
// Always mark plugin move end
|
|
||||||
this.markPluginMoveEnd();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const errorMsg = `Error during undo operation for ${fileName}`;
|
|
||||||
handleError(error, errorMsg, showNotifications);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Undoes a move using a HistoryEntry object
|
|
||||||
*
|
|
||||||
* @param entry - HistoryEntry with paths
|
|
||||||
* @param options - Same as undoMove()
|
|
||||||
* @returns True if successful
|
|
||||||
*/
|
|
||||||
public async undoMoveFromHistoryEntry(
|
|
||||||
entry: HistoryEntry,
|
|
||||||
options: {
|
|
||||||
showNotifications?: boolean;
|
|
||||||
} = {}
|
|
||||||
): Promise<boolean> {
|
|
||||||
return this.undoMove(
|
|
||||||
entry.destinationPath,
|
|
||||||
entry.sourcePath,
|
|
||||||
entry.fileName,
|
|
||||||
options
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Batch move multiple files with progress tracking
|
|
||||||
*
|
|
||||||
* @param operations - Array of {file, targetPath, originalPath?}
|
|
||||||
* @param options - Batch configuration with optional progress callback
|
|
||||||
* @returns Results with success/failure counts and errors
|
|
||||||
*/
|
|
||||||
public async batchMoveFiles(
|
|
||||||
operations: Array<{
|
|
||||||
file: TFile;
|
|
||||||
targetPath: string;
|
|
||||||
originalPath?: string;
|
|
||||||
}>,
|
|
||||||
options: {
|
|
||||||
trackInHistory?: boolean;
|
|
||||||
showNotifications?: boolean;
|
|
||||||
onProgress?: (
|
|
||||||
completed: number,
|
|
||||||
total: number,
|
|
||||||
currentFile: string
|
|
||||||
) => void;
|
|
||||||
} = {}
|
|
||||||
): Promise<{
|
|
||||||
successful: number;
|
|
||||||
failed: number;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { App, TFile } from 'obsidian';
|
||||||
import { getAllTags } from 'obsidian';
|
import { getAllTags } from 'obsidian';
|
||||||
import { FileMetadata } from '../types/Common';
|
import { FileMetadata } from '../types/Common';
|
||||||
import { handleError } from '../utils/Error';
|
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
|
* Extracts and provides standardized access to file metadata
|
||||||
|
|
@ -144,30 +146,7 @@ export class MetadataExtractor {
|
||||||
* @returns Array of individual values
|
* @returns Array of individual values
|
||||||
*/
|
*/
|
||||||
public parseListProperty(value: any): string[] {
|
public parseListProperty(value: any): string[] {
|
||||||
if (value === null || value === undefined) {
|
return parseListPropertyDomain(value);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -177,20 +156,7 @@ export class MetadataExtractor {
|
||||||
* @returns True if the value represents multiple items
|
* @returns True if the value represents multiple items
|
||||||
*/
|
*/
|
||||||
public isListProperty(value: any): boolean {
|
public isListProperty(value: any): boolean {
|
||||||
if (value === null || value === undefined) {
|
return isListPropertyDomain(value);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -198,9 +164,13 @@ export class MetadataExtractor {
|
||||||
* Includes additional fields: extension, links, embeds, headings
|
* Includes additional fields: extension, links, embeds, headings
|
||||||
*
|
*
|
||||||
* @param file - TFile to extract metadata from
|
* @param file - TFile to extract metadata from
|
||||||
|
* @param needsContent - When true, reads file body (for blacklist `content:` filters).
|
||||||
* @returns Complete FileMetadata with V2 fields
|
* @returns Complete FileMetadata with V2 fields
|
||||||
*/
|
*/
|
||||||
public async extractFileMetadataV2(file: TFile): Promise<FileMetadata> {
|
public async extractFileMetadataV2(
|
||||||
|
file: TFile,
|
||||||
|
needsContent = false
|
||||||
|
): Promise<FileMetadata> {
|
||||||
try {
|
try {
|
||||||
const fileName = file.name;
|
const fileName = file.name;
|
||||||
const filePath = file.path;
|
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 {
|
return {
|
||||||
fileName,
|
fileName,
|
||||||
filePath,
|
filePath,
|
||||||
tags,
|
tags,
|
||||||
properties,
|
properties,
|
||||||
fileContent: '',
|
fileContent,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
extension,
|
extension,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { TFile } from 'obsidian';
|
import { TFile } from 'obsidian';
|
||||||
import { NoticeManager } from '../utils/NoticeManager';
|
import { NoticeManager } from '../utils/NoticeManager';
|
||||||
import { RuleManager } from './RuleManager';
|
|
||||||
import { RuleManagerV2 } from './RuleManagerV2';
|
import { RuleManagerV2 } from './RuleManagerV2';
|
||||||
import { createError, handleError } from '../utils/Error';
|
import { createError, handleError } from '../utils/Error';
|
||||||
import { combinePath, ensureFolderExists } from '../utils/PathUtils';
|
import { combinePath, ensureFolderExists } from '../utils/PathUtils';
|
||||||
|
|
@ -15,33 +14,26 @@ import {
|
||||||
const BULK_CHUNK_SIZE = 50;
|
const BULK_CHUNK_SIZE = 50;
|
||||||
|
|
||||||
export class NoteMoverShortcut {
|
export class NoteMoverShortcut {
|
||||||
private ruleManager: RuleManager;
|
|
||||||
private ruleManagerV2: RuleManagerV2;
|
private ruleManagerV2: RuleManagerV2;
|
||||||
|
|
||||||
constructor(private plugin: NoteMoverShortcutPlugin) {
|
constructor(private plugin: NoteMoverShortcutPlugin) {
|
||||||
this.ruleManager = new RuleManager(plugin.app, '/');
|
this.ruleManagerV2 = new RuleManagerV2(
|
||||||
this.ruleManagerV2 = new RuleManagerV2(plugin.app, '/');
|
plugin.app,
|
||||||
|
'/',
|
||||||
|
plugin.performanceTrace
|
||||||
|
);
|
||||||
this.updateRuleManager();
|
this.updateRuleManager();
|
||||||
}
|
}
|
||||||
|
|
||||||
public updateRuleManager(): void {
|
public updateRuleManager(): void {
|
||||||
// Use V1 (legacy) only when legacy mode is enabled; otherwise V2 is default
|
const rulesV2 = Array.isArray(this.plugin.settings.settings.rulesV2)
|
||||||
if (this.plugin.settings.settings.enableLegacyRules === true) {
|
? this.plugin.settings.settings.rulesV2
|
||||||
this.ruleManager.setRules(this.plugin.settings.settings.rules);
|
: [];
|
||||||
this.ruleManager.setFilter(
|
this.ruleManagerV2.setRules(rulesV2);
|
||||||
this.plugin.settings.settings.filters.filter.map(f => f.value)
|
this.ruleManagerV2.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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-hash the rules config; invalidates the cache when anything changed
|
|
||||||
this.plugin.syncRuleCacheHash();
|
this.plugin.syncRuleCacheHash();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,169 +88,201 @@ export class NoteMoverShortcut {
|
||||||
defaultFolder: string,
|
defaultFolder: string,
|
||||||
skipFilter = false
|
skipFilter = false
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const { app } = this.plugin;
|
return this.plugin.performanceTrace.recordAsync(
|
||||||
const cache = this.plugin.ruleCache;
|
'NoteMoverShortcut.moveFileBasedOnTags',
|
||||||
const cacheEnabled =
|
async () => {
|
||||||
this.plugin.settings.settings.enableRuleEvaluationCache === true;
|
const { app } = this.plugin;
|
||||||
const originalPath = file.path;
|
const cache = this.plugin.ruleCache;
|
||||||
const mtime = file.stat?.mtime ?? 0;
|
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
|
// Fast path: if the cache is enabled and already knows the result for
|
||||||
// this file, skip the expensive rule evaluation entirely.
|
// this file, skip the expensive rule evaluation entirely.
|
||||||
if (cacheEnabled && !cache.needsEvaluation(originalPath, mtime)) {
|
if (cacheEnabled && !cache.needsEvaluation(originalPath, mtime)) {
|
||||||
const cachedDest = cache.getCachedDestination(originalPath);
|
const cachedDest = cache.getCachedDestination(originalPath);
|
||||||
if (cachedDest === null || cachedDest === undefined) {
|
if (cachedDest === null || cachedDest === undefined) {
|
||||||
return false; // No rule matched last time and nothing changed
|
return false; // No rule matched last time and nothing changed
|
||||||
}
|
}
|
||||||
const cachedPath = combinePath(cachedDest, file.name);
|
const cachedPath = combinePath(cachedDest, file.name);
|
||||||
if (originalPath === cachedPath) {
|
if (originalPath === cachedPath) {
|
||||||
return false; // Already in correct folder
|
return false; // Already in correct folder
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let targetFolder = defaultFolder;
|
let targetFolder = defaultFolder;
|
||||||
|
|
||||||
let result: string | null = null;
|
let result: string | null = null;
|
||||||
|
|
||||||
if (this.plugin.settings.settings.enableLegacyRules === true) {
|
result = await this.ruleManagerV2.moveFileBasedOnTags(
|
||||||
result = await this.ruleManager.moveFileBasedOnTags(file, skipFilter);
|
file,
|
||||||
} else {
|
skipFilter
|
||||||
result = await this.ruleManagerV2.moveFileBasedOnTags(file, skipFilter);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if (cacheEnabled) {
|
if (cacheEnabled) {
|
||||||
cache.store(originalPath, mtime, result);
|
cache.store(originalPath, mtime, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
targetFolder = result;
|
targetFolder = result;
|
||||||
|
|
||||||
const newPath = combinePath(targetFolder, file.name);
|
const newPath = combinePath(targetFolder, file.name);
|
||||||
|
|
||||||
if (originalPath === newPath) {
|
if (originalPath === newPath) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await ensureFolderExists(app, targetFolder))) {
|
if (!(await ensureFolderExists(app, targetFolder))) {
|
||||||
throw createError(`Failed to create target folder: ${targetFolder}`);
|
throw createError(
|
||||||
}
|
`Failed to create target folder: ${targetFolder}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.plugin.historyManager.markPluginMoveStart();
|
this.plugin.historyManager.markPluginMoveStart();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await app.fileManager.renameFile(file, newPath);
|
await app.fileManager.renameFile(file, newPath);
|
||||||
|
|
||||||
this.plugin.historyManager.addEntry({
|
this.plugin.historyManager.addEntry({
|
||||||
sourcePath: originalPath,
|
sourcePath: originalPath,
|
||||||
destinationPath: newPath,
|
destinationPath: newPath,
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
this.plugin.historyManager.markPluginMoveEnd();
|
this.plugin.historyManager.markPluginMoveEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, `Error moving file '${file.path}'`);
|
handleError(error, `Error moving file '${file.path}'`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{ path: file.path, skipFilter }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async moveAllFiles(options: {
|
private async moveAllFiles(options: {
|
||||||
createFolders: boolean;
|
createFolders: boolean;
|
||||||
showNotifications: boolean;
|
showNotifications: boolean;
|
||||||
operationType: OperationType;
|
operationType: OperationType;
|
||||||
|
signal?: AbortSignal;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
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 (files.length === 0) {
|
||||||
if (options.showNotifications && options.operationType === 'bulk') {
|
if (options.showNotifications && options.operationType === 'bulk') {
|
||||||
NoticeManager.info('No files found in vault');
|
NoticeManager.info('No markdown 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++;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
return;
|
||||||
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<void>(resolve => setTimeout(resolve, 0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show completion notice
|
// Start bulk operation
|
||||||
if (successCount > 0 && options.showNotifications) {
|
const bulkOperationId = this.plugin.historyManager.startBulkOperation(
|
||||||
if (options.operationType === 'bulk') {
|
options.operationType
|
||||||
const totalFiles = successCount + errorCount;
|
);
|
||||||
const successMessage =
|
let successCount = 0;
|
||||||
errorCount > 0
|
let errorCount = 0;
|
||||||
? `Moved ${successCount}/${totalFiles} files. ${errorCount} files had errors.`
|
|
||||||
: `Successfully moved ${successCount} files`;
|
|
||||||
|
|
||||||
NoticeManager.showWithUndo(
|
try {
|
||||||
'info',
|
for (let i = 0; i < files.length; i++) {
|
||||||
`Bulk Operation: ${successMessage}`,
|
if (options.signal?.aborted) {
|
||||||
async () => {
|
break;
|
||||||
const success =
|
}
|
||||||
await this.plugin.historyManager.undoBulkOperation(
|
try {
|
||||||
bulkOperationId
|
const wasMoved = await this.moveFileBasedOnTags(files[i], '/');
|
||||||
);
|
if (wasMoved) {
|
||||||
if (success) {
|
successCount++;
|
||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
} catch (error) {
|
||||||
SETTINGS_CONSTANTS.UI_TEXTS.UNDO_ALL
|
errorCount++;
|
||||||
);
|
const errorMessage =
|
||||||
} else {
|
options.operationType === 'periodic'
|
||||||
NoticeManager.info(
|
? `Error moving file '${files[i].path}' during periodic movement`
|
||||||
`Periodic movement: Successfully moved ${successCount} files`
|
: `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<void>(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 {
|
{ operationType: options.operationType }
|
||||||
// End bulk operation
|
);
|
||||||
this.plugin.historyManager.endBulkOperation();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async moveAllFilesInVault() {
|
async moveAllFilesInVault(opts?: { signal?: AbortSignal }) {
|
||||||
await this.moveAllFiles({
|
await this.moveAllFiles({
|
||||||
createFolders: true,
|
createFolders: true,
|
||||||
showNotifications: true,
|
showNotifications: true,
|
||||||
operationType: 'bulk',
|
operationType: 'bulk',
|
||||||
|
signal: opts?.signal,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -327,48 +351,40 @@ export class NoteMoverShortcut {
|
||||||
* Generates a preview of moves for all files in the vault
|
* Generates a preview of moves for all files in the vault
|
||||||
*/
|
*/
|
||||||
async generateVaultMovePreview(): Promise<MovePreview> {
|
async generateVaultMovePreview(): Promise<MovePreview> {
|
||||||
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.ruleManagerV2.generateMovePreview(files, true, 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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a preview for the currently active note
|
* Generates a preview for the currently active note
|
||||||
*/
|
*/
|
||||||
async generateActiveNotePreview(): Promise<MovePreview | null> {
|
async generateActiveNotePreview(): Promise<MovePreview | null> {
|
||||||
const { app } = this.plugin;
|
return this.plugin.performanceTrace.recordAsync(
|
||||||
const activeFile = app.workspace.getActiveFile();
|
'NoteMoverShortcut.generateActiveNotePreview',
|
||||||
|
async () => {
|
||||||
|
const { app } = this.plugin;
|
||||||
|
const activeFile = app.workspace.getActiveFile();
|
||||||
|
|
||||||
if (!activeFile) {
|
if (!activeFile) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.plugin.settings.settings.enableLegacyRules === true) {
|
return await this.ruleManagerV2.generateMovePreview(
|
||||||
return await this.ruleManager.generateMovePreview(
|
[activeFile],
|
||||||
[activeFile],
|
true,
|
||||||
true,
|
true
|
||||||
true
|
);
|
||||||
);
|
},
|
||||||
} else {
|
{}
|
||||||
return await this.ruleManagerV2.generateMovePreview(
|
);
|
||||||
[activeFile],
|
|
||||||
true,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { Rule } from '../types/Rule';
|
|
||||||
import { RuleV2 } from '../types/RuleV2';
|
import { RuleV2 } from '../types/RuleV2';
|
||||||
import { Filter } from '../types/PluginData';
|
import { Filter } from '../types/PluginData';
|
||||||
|
|
||||||
|
|
@ -24,18 +23,8 @@ export class RuleEvaluationCache {
|
||||||
* Recompute the rules hash from the current configuration.
|
* Recompute the rules hash from the current configuration.
|
||||||
* Must be called whenever rules or filters change.
|
* Must be called whenever rules or filters change.
|
||||||
*/
|
*/
|
||||||
public updateRulesHash(
|
public updateRulesHash(rulesV2: RuleV2[], filters: Filter[]): void {
|
||||||
rules: Rule[],
|
const newHash = this.computeRulesHash(rulesV2, filters);
|
||||||
rulesV2: RuleV2[],
|
|
||||||
filters: Filter[],
|
|
||||||
enableLegacyRules: boolean
|
|
||||||
): void {
|
|
||||||
const newHash = this.computeRulesHash(
|
|
||||||
rules,
|
|
||||||
rulesV2,
|
|
||||||
filters,
|
|
||||||
enableLegacyRules
|
|
||||||
);
|
|
||||||
if (newHash !== this.currentRulesHash) {
|
if (newHash !== this.currentRulesHash) {
|
||||||
this.currentRulesHash = newHash;
|
this.currentRulesHash = newHash;
|
||||||
this.invalidateAll();
|
this.invalidateAll();
|
||||||
|
|
@ -103,14 +92,9 @@ export class RuleEvaluationCache {
|
||||||
this.dirtyFiles.clear();
|
this.dirtyFiles.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private computeRulesHash(
|
private computeRulesHash(rulesV2: RuleV2[], filters: Filter[]): string {
|
||||||
rules: Rule[],
|
|
||||||
rulesV2: RuleV2[],
|
|
||||||
filters: Filter[],
|
|
||||||
enableLegacyRules: boolean
|
|
||||||
): string {
|
|
||||||
try {
|
try {
|
||||||
return JSON.stringify({ enableLegacyRules, rules, rulesV2, filters });
|
return JSON.stringify({ rulesV2, filters });
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<string | null> {
|
|
||||||
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<PreviewEntry> {
|
|
||||||
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<MovePreview> {
|
|
||||||
const successfulMoves: PreviewEntry[] = [];
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const preview = await this.generatePreviewForFile(file, !enableFilter);
|
|
||||||
|
|
||||||
if (preview.willBeMoved) {
|
|
||||||
successfulMoves.push(preview);
|
|
||||||
}
|
|
||||||
// Only include files that will be moved - blocked files are ignored
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
successfulMoves,
|
|
||||||
totalFiles: files.length,
|
|
||||||
settings: {
|
|
||||||
isFilterWhitelist: false, // Always blacklist mode
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -6,11 +6,13 @@ import { createError, handleError } from '../utils/Error';
|
||||||
import { combinePath } from '../utils/PathUtils';
|
import { combinePath } from '../utils/PathUtils';
|
||||||
import { MetadataExtractor } from './MetadataExtractor';
|
import { MetadataExtractor } from './MetadataExtractor';
|
||||||
import { RuleMatcherV2 } from './RuleMatcherV2';
|
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 {
|
import {
|
||||||
DestinationTemplateContext,
|
DestinationTemplateContext,
|
||||||
renderDestinationTemplate,
|
renderDestinationTemplate,
|
||||||
} from '../utils/DestinationTemplate';
|
} from '../domain/templates/DestinationTemplate';
|
||||||
|
import type { PerformanceTraceRecorder } from '../infrastructure/debug/performance-trace';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RuleManager for Rule V2 system
|
* RuleManager for Rule V2 system
|
||||||
|
|
@ -25,15 +27,15 @@ export class RuleManagerV2 {
|
||||||
private filter: string[] = []; // Filter remain V1-compatible
|
private filter: string[] = []; // Filter remain V1-compatible
|
||||||
private metadataExtractor: MetadataExtractor;
|
private metadataExtractor: MetadataExtractor;
|
||||||
private ruleMatcherV2: RuleMatcherV2;
|
private ruleMatcherV2: RuleMatcherV2;
|
||||||
private ruleMatcherV1: RuleMatcher; // For filter evaluation
|
private readonly filterEngine = new BlacklistFilterEngine();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private app: App,
|
private app: App,
|
||||||
private defaultFolder: string
|
private defaultFolder: string,
|
||||||
|
private readonly perf: PerformanceTraceRecorder
|
||||||
) {
|
) {
|
||||||
this.metadataExtractor = new MetadataExtractor(app);
|
this.metadataExtractor = new MetadataExtractor(app);
|
||||||
this.ruleMatcherV2 = new RuleMatcherV2(this.metadataExtractor);
|
this.ruleMatcherV2 = new RuleMatcherV2(this.metadataExtractor);
|
||||||
this.ruleMatcherV1 = new RuleMatcher(this.metadataExtractor);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -43,6 +45,7 @@ export class RuleManagerV2 {
|
||||||
*/
|
*/
|
||||||
public setRules(rules: RuleV2[]): void {
|
public setRules(rules: RuleV2[]): void {
|
||||||
this.rules = rules;
|
this.rules = rules;
|
||||||
|
this.ruleMatcherV2.warmRegexCacheFromRules(rules);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -55,6 +58,10 @@ export class RuleManagerV2 {
|
||||||
this.filter = filter;
|
this.filter = filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private filterNeedsContent(): boolean {
|
||||||
|
return filtersNeedContent(this.filter);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes a single file against rules and filters
|
* Processes a single file against rules and filters
|
||||||
*
|
*
|
||||||
|
|
@ -69,49 +76,56 @@ export class RuleManagerV2 {
|
||||||
file: TFile,
|
file: TFile,
|
||||||
skipFilter = false
|
skipFilter = false
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
try {
|
return this.perf.recordAsync(
|
||||||
// Extract V2 metadata
|
'RuleManagerV2.moveFileBasedOnTags',
|
||||||
const metadata = await this.metadataExtractor.extractFileMetadataV2(file);
|
async () => {
|
||||||
|
try {
|
||||||
|
const metadata = await this.metadataExtractor.extractFileMetadataV2(
|
||||||
|
file,
|
||||||
|
this.filterNeedsContent()
|
||||||
|
);
|
||||||
|
|
||||||
// Check if file should be skipped based on filter (using V1 logic)
|
if (
|
||||||
if (
|
!skipFilter &&
|
||||||
!skipFilter &&
|
!this.filterEngine.evaluateFilter(metadata, this.filter)
|
||||||
!this.ruleMatcherV1.evaluateFilter(metadata, this.filter)
|
) {
|
||||||
) {
|
return null; // File is blocked by filter
|
||||||
return null; // File is blocked by filter
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Find matching rule using V2 logic
|
// Find matching rule using V2 logic
|
||||||
const matchingRule = this.ruleMatcherV2.findMatchingRule(
|
const matchingRule = this.ruleMatcherV2.findMatchingRule(
|
||||||
metadata,
|
metadata,
|
||||||
this.rules
|
this.rules
|
||||||
);
|
);
|
||||||
|
|
||||||
if (matchingRule) {
|
if (matchingRule) {
|
||||||
const targetFolder = this.resolveDestinationTemplate(
|
const targetFolder = this.resolveDestinationTemplate(
|
||||||
matchingRule.destination,
|
matchingRule.destination,
|
||||||
metadata
|
metadata
|
||||||
);
|
);
|
||||||
|
|
||||||
// If the destination template resolves to an empty value, treat it as
|
// 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.
|
// "no valid move target" so the file is not moved to the vault root.
|
||||||
if (!targetFolder || targetFolder.trim() === '') {
|
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 null;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
return targetFolder;
|
{ path: file.path, skipFilter }
|
||||||
}
|
);
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -126,13 +140,15 @@ export class RuleManagerV2 {
|
||||||
skipFilter = false
|
skipFilter = false
|
||||||
): Promise<PreviewEntry> {
|
): Promise<PreviewEntry> {
|
||||||
try {
|
try {
|
||||||
// Extract V2 metadata
|
const metadata = await this.metadataExtractor.extractFileMetadataV2(
|
||||||
const metadata = await this.metadataExtractor.extractFileMetadataV2(file);
|
file,
|
||||||
|
this.filterNeedsContent()
|
||||||
|
);
|
||||||
const { tags, fileName, filePath } = metadata;
|
const { tags, fileName, filePath } = metadata;
|
||||||
|
|
||||||
// Check filters first (using V1 logic)
|
// Check filters first (using V1 logic)
|
||||||
if (!skipFilter) {
|
if (!skipFilter) {
|
||||||
const filterDetails = this.ruleMatcherV1.getFilterMatchDetails(
|
const filterDetails = this.filterEngine.getFilterMatchDetails(
|
||||||
metadata,
|
metadata,
|
||||||
this.filter
|
this.filter
|
||||||
);
|
);
|
||||||
|
|
@ -241,24 +257,33 @@ export class RuleManagerV2 {
|
||||||
enableRules: boolean,
|
enableRules: boolean,
|
||||||
enableFilter: boolean
|
enableFilter: boolean
|
||||||
): Promise<MovePreview> {
|
): Promise<MovePreview> {
|
||||||
const successfulMoves: PreviewEntry[] = [];
|
return this.perf.recordAsync(
|
||||||
|
'RuleManagerV2.generateMovePreview',
|
||||||
|
async () => {
|
||||||
|
const successfulMoves: PreviewEntry[] = [];
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const preview = await this.generatePreviewForFile(file, !enableFilter);
|
const preview = await this.generatePreviewForFile(
|
||||||
|
file,
|
||||||
|
!enableFilter
|
||||||
|
);
|
||||||
|
|
||||||
if (preview.willBeMoved) {
|
if (preview.willBeMoved) {
|
||||||
successfulMoves.push(preview);
|
successfulMoves.push(preview);
|
||||||
}
|
}
|
||||||
// Only include files that will be moved - blocked files are ignored
|
// Only include files that will be moved - blocked files are ignored
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
successfulMoves,
|
successfulMoves,
|
||||||
totalFiles: files.length,
|
totalFiles: files.length,
|
||||||
settings: {
|
settings: {
|
||||||
isFilterWhitelist: false, // Always blacklist mode
|
isFilterWhitelist: false, // Always blacklist mode
|
||||||
|
},
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
{ fileCount: files.length, enableRules, enableFilter }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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<string, RegExp> = 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<string, any>,
|
|
||||||
criteria: string
|
|
||||||
): boolean {
|
|
||||||
// Check if criteria contains a colon to separate key and value
|
|
||||||
const colonIndex = criteria.indexOf(':');
|
|
||||||
|
|
||||||
if (colonIndex === -1) {
|
|
||||||
// Format: "key" - check if property exists
|
|
||||||
const key = criteria.trim();
|
|
||||||
return (
|
|
||||||
properties.hasOwnProperty(key) &&
|
|
||||||
properties[key] !== undefined &&
|
|
||||||
properties[key] !== null
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Format: "key:value" - check for exact value match
|
|
||||||
const key = criteria.substring(0, colonIndex).trim();
|
|
||||||
const expectedValue = criteria.substring(colonIndex + 1).trim();
|
|
||||||
|
|
||||||
if (!properties.hasOwnProperty(key)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const actualValue = properties[key];
|
|
||||||
|
|
||||||
// Handle different value types
|
|
||||||
if (actualValue === null || actualValue === undefined) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,825 +1,29 @@
|
||||||
import { FileMetadata } from '../types/Common';
|
import type { FileMetadata } from '../types/Common';
|
||||||
import {
|
import type { RuleV2, Trigger } from '../types/RuleV2';
|
||||||
RuleV2,
|
import type { MetadataExtractor } from './MetadataExtractor';
|
||||||
Trigger,
|
import { RuleMatcherV2Engine } from '../domain/rules/v2/RuleMatcherV2Engine';
|
||||||
AggregationType,
|
|
||||||
TextOperator,
|
|
||||||
ListOperator,
|
|
||||||
DateOperator,
|
|
||||||
BasePropertyOperator,
|
|
||||||
TextPropertyOperator,
|
|
||||||
NumberPropertyOperator,
|
|
||||||
ListPropertyOperator,
|
|
||||||
DatePropertyOperator,
|
|
||||||
CheckboxPropertyOperator,
|
|
||||||
} from '../types/RuleV2';
|
|
||||||
import { MetadataExtractor } from './MetadataExtractor';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles all rule and filter matching logic for RuleV2 system
|
* Rule V2 matcher — thin adapter over {@link RuleMatcherV2Engine} (domain).
|
||||||
*
|
|
||||||
* Provides centralized matching for all CriteriaTypes and Operators with
|
|
||||||
* type-safe evaluation and aggregation support.
|
|
||||||
*
|
|
||||||
* @since 0.5.0
|
|
||||||
*/
|
*/
|
||||||
export class RuleMatcherV2 {
|
export class RuleMatcherV2 {
|
||||||
private metadataExtractor: MetadataExtractor;
|
private readonly engine = new RuleMatcherV2Engine();
|
||||||
private regexCache: Map<string, RegExp>;
|
|
||||||
|
|
||||||
constructor(metadataExtractor: MetadataExtractor) {
|
/** @param _metadataExtractor unused; kept for call-site compatibility. */
|
||||||
this.metadataExtractor = metadataExtractor;
|
constructor(_metadataExtractor?: MetadataExtractor) {}
|
||||||
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
|
|
||||||
*/
|
|
||||||
public findMatchingRule(
|
public findMatchingRule(
|
||||||
metadata: FileMetadata,
|
metadata: FileMetadata,
|
||||||
rules: RuleV2[]
|
rules: RuleV2[]
|
||||||
): RuleV2 | null {
|
): RuleV2 | null {
|
||||||
for (const rule of rules) {
|
return this.engine.findMatchingRule(metadata, 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 {
|
public evaluateTrigger(metadata: FileMetadata, trigger: Trigger): boolean {
|
||||||
switch (trigger.criteriaType) {
|
return this.engine.evaluateTrigger(metadata, trigger);
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public warmRegexCacheFromRules(rules: RuleV2[]): void {
|
||||||
* Evaluates aggregation logic for trigger results
|
this.engine.warmRegexCacheFromRules(rules);
|
||||||
*
|
|
||||||
* @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<string, any>,
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { Rule } from '../types/Rule';
|
|
||||||
import {
|
import {
|
||||||
RuleV2,
|
RuleV2,
|
||||||
Trigger,
|
Trigger,
|
||||||
|
|
@ -6,6 +5,12 @@ import {
|
||||||
Operator,
|
Operator,
|
||||||
TextOperator,
|
TextOperator,
|
||||||
} from '../types/RuleV2';
|
} 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 {
|
import {
|
||||||
getDefaultOperatorForCriteriaType,
|
getDefaultOperatorForCriteriaType,
|
||||||
getDefaultOperatorForPropertyType,
|
getDefaultOperatorForPropertyType,
|
||||||
|
|
@ -23,7 +28,7 @@ export class RuleMigrationService {
|
||||||
* @param rulesV1 - Array of V1 rules to migrate
|
* @param rulesV1 - Array of V1 rules to migrate
|
||||||
* @returns Array of RuleV2 objects
|
* @returns Array of RuleV2 objects
|
||||||
*/
|
*/
|
||||||
public static migrateRules(rulesV1: Rule[]): RuleV2[] {
|
public static migrateRules(rulesV1: LegacyRuleV1[]): RuleV2[] {
|
||||||
if (!Array.isArray(rulesV1) || rulesV1.length === 0) {
|
if (!Array.isArray(rulesV1) || rulesV1.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -40,7 +45,10 @@ export class RuleMigrationService {
|
||||||
* @param index - Rule index for naming
|
* @param index - Rule index for naming
|
||||||
* @returns RuleV2 object or null if migration fails
|
* @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) {
|
if (!ruleV1 || !ruleV1.criteria || !ruleV1.path) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[RuleMigration] Rule ${index + 1}: Skipping invalid rule (missing criteria or path)`
|
`[RuleMigration] Rule ${index + 1}: Skipping invalid rule (missing criteria or path)`
|
||||||
|
|
@ -316,7 +324,10 @@ export class RuleMigrationService {
|
||||||
* @param rulesV2 - V2 rules array
|
* @param rulesV2 - V2 rules array
|
||||||
* @returns true if migration should be performed
|
* @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
|
// Migrate if V1 has rules and V2 is empty or undefined
|
||||||
return (
|
return (
|
||||||
Array.isArray(rulesV1) &&
|
Array.isArray(rulesV1) &&
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ export class TriggerEventHandler {
|
||||||
* @param file - The specific TFile that was modified
|
* @param file - The specific TFile that was modified
|
||||||
*/
|
*/
|
||||||
private async handleOnEdit(file: TFile): Promise<void> {
|
private async handleOnEdit(file: TFile): Promise<void> {
|
||||||
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.
|
// 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
|
// The vault event listener in main.ts does the same, but listener
|
||||||
// ordering is not guaranteed.
|
// ordering is not guaranteed.
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
import { TFile } from 'obsidian';
|
|
||||||
import NoteMoverShortcutPlugin from 'main';
|
import NoteMoverShortcutPlugin from 'main';
|
||||||
import { UpdateModal } from '../modals/UpdateModal';
|
import { UpdateModal } from '../modals/UpdateModal';
|
||||||
import { NoticeManager } from '../utils/NoticeManager';
|
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';
|
import { ChangelogEntry, CHANGELOG_ENTRIES } from '../generated/changelog';
|
||||||
|
|
||||||
export class UpdateManager {
|
export class UpdateManager {
|
||||||
|
|
|
||||||
36
src/domain/filters/blacklist-filter-engine.test.ts
Normal file
36
src/domain/filters/blacklist-filter-engine.test.ts
Normal file
|
|
@ -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>): 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
153
src/domain/filters/blacklist-filter-engine.ts
Normal file
153
src/domain/filters/blacklist-filter-engine.ts
Normal file
|
|
@ -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<string, RegExp>();
|
||||||
|
|
||||||
|
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<string, unknown>,
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/domain/filters/filter-needs-content.test.ts
Normal file
16
src/domain/filters/filter-needs-content.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
14
src/domain/filters/filter-needs-content.ts
Normal file
14
src/domain/filters/filter-needs-content.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
16
src/domain/property/isListProperty.ts
Normal file
16
src/domain/property/isListProperty.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
17
src/domain/property/parseListProperty.test.ts
Normal file
17
src/domain/property/parseListProperty.test.ts
Normal file
|
|
@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
23
src/domain/property/parseListProperty.ts
Normal file
23
src/domain/property/parseListProperty.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
839
src/domain/rules/v2/RuleMatcherV2Engine.ts
Normal file
839
src/domain/rules/v2/RuleMatcherV2Engine.ts
Normal file
|
|
@ -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<string, RegExp>;
|
||||||
|
|
||||||
|
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<string>([
|
||||||
|
'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<string, any>,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/domain/rules/v2/rule-matcher-v2-engine.test.ts
Normal file
59
src/domain/rules/v2/rule-matcher-v2-engine.test.ts
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
28
src/domain/templates/destination-template.test.ts
Normal file
28
src/domain/templates/destination-template.test.ts
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import NoteMoverShortcutPlugin from 'main';
|
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 { HistoryModal } from '../modals/HistoryModal';
|
||||||
import { UpdateModal } from '../modals/UpdateModal';
|
import { UpdateModal } from '../modals/UpdateModal';
|
||||||
import { PreviewModal } from '../modals/PreviewModal';
|
import { PreviewModal } from '../modals/PreviewModal';
|
||||||
|
|
@ -14,7 +15,10 @@ export class CommandHandler {
|
||||||
this.plugin.addCommand({
|
this.plugin.addCommand({
|
||||||
id: 'trigger-note-movement',
|
id: 'trigger-note-movement',
|
||||||
name: 'Move active note to note folder',
|
name: 'Move active note to note folder',
|
||||||
editorCallback: (editor: Editor, view: MarkdownView) => {
|
editorCallback: (
|
||||||
|
_editor: Editor,
|
||||||
|
_ctx: MarkdownView | MarkdownFileInfo
|
||||||
|
) => {
|
||||||
this.plugin.noteMover.moveFocusedNoteToDestination();
|
this.plugin.noteMover.moveFocusedNoteToDestination();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -72,7 +76,10 @@ export class CommandHandler {
|
||||||
this.plugin.addCommand({
|
this.plugin.addCommand({
|
||||||
id: 'preview-note-movement',
|
id: 'preview-note-movement',
|
||||||
name: 'Preview active note movement',
|
name: 'Preview active note movement',
|
||||||
editorCallback: async (editor: Editor, view: MarkdownView) => {
|
editorCallback: async (
|
||||||
|
_editor: Editor,
|
||||||
|
_ctx: MarkdownView | MarkdownFileInfo
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const preview =
|
const preview =
|
||||||
await this.plugin.noteMover.generateActiveNotePreview();
|
await this.plugin.noteMover.generateActiveNotePreview();
|
||||||
|
|
@ -94,9 +101,12 @@ export class CommandHandler {
|
||||||
this.plugin.addCommand({
|
this.plugin.addCommand({
|
||||||
id: 'add-current-file-to-blacklist',
|
id: 'add-current-file-to-blacklist',
|
||||||
name: '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 {
|
try {
|
||||||
const fileName = view.file?.name;
|
const fileName = ctx.file?.name;
|
||||||
if (!fileName) {
|
if (!fileName) {
|
||||||
NoticeManager.warning('No active file to add to blacklist.');
|
NoticeManager.warning('No active file to add to blacklist.');
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
39
src/infrastructure/cache/plugin-vault-index-cache.test.ts
vendored
Normal file
39
src/infrastructure/cache/plugin-vault-index-cache.test.ts
vendored
Normal file
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
363
src/infrastructure/cache/plugin-vault-index-cache.ts
vendored
Normal file
363
src/infrastructure/cache/plugin-vault-index-cache.ts
vendored
Normal file
|
|
@ -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<string> | null = null;
|
||||||
|
private tagsBuiltForGeneration = -1;
|
||||||
|
|
||||||
|
private readonly propertyValuesByName = new Map<
|
||||||
|
string,
|
||||||
|
{ generation: number; values: Set<string> }
|
||||||
|
>();
|
||||||
|
|
||||||
|
private propertySuggestBundle: {
|
||||||
|
generation: number;
|
||||||
|
keys: Set<string>;
|
||||||
|
valuesByKey: Map<string, Set<string>>;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
private metadataDebounceTimer: ReturnType<typeof setTimeout> | 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<T>(
|
||||||
|
name: string,
|
||||||
|
fn: () => T,
|
||||||
|
meta?: Record<string, unknown>
|
||||||
|
): 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<string> {
|
||||||
|
return this.traceSync('PluginVaultIndexCache.getAllTagsCached', () => {
|
||||||
|
if (!this.cacheEnabledResolver()) {
|
||||||
|
const tags = new Set<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string> {
|
||||||
|
return this.traceSync(
|
||||||
|
'PluginVaultIndexCache.getPropertyValueSetCached',
|
||||||
|
() => {
|
||||||
|
if (!this.cacheEnabledResolver()) {
|
||||||
|
const values = new Set<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string>,
|
||||||
|
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<string>;
|
||||||
|
valuesByKey: Map<string, Set<string>>;
|
||||||
|
} {
|
||||||
|
return this.traceSync(
|
||||||
|
'PluginVaultIndexCache.getPropertyKeysAndValuesCached',
|
||||||
|
() => {
|
||||||
|
const build = (): {
|
||||||
|
keys: Set<string>;
|
||||||
|
valuesByKey: Map<string, Set<string>>;
|
||||||
|
} => {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
const valuesByKey = new Map<string, Set<string>>();
|
||||||
|
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();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/infrastructure/debug/performance-trace.ts
Normal file
110
src/infrastructure/debug/performance-trace.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<T>(name: string, fn: () => T, meta?: Record<string, unknown>): 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<T>(
|
||||||
|
name: string,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
meta?: Record<string, unknown>
|
||||||
|
): Promise<T> {
|
||||||
|
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<string, unknown>
|
||||||
|
): 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<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
286
src/infrastructure/persistence/plugin-settings-controller.ts
Normal file
286
src/infrastructure/persistence/plugin-settings-controller.ts
Normal file
|
|
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/infrastructure/persistence/plugin-settings-schema.ts
Normal file
25
src/infrastructure/persistence/plugin-settings-schema.ts
Normal file
|
|
@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/infrastructure/plugin/vault-event-hooks.ts
Normal file
74
src/infrastructure/plugin/vault-event-hooks.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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<void>;
|
|
||||||
onDismiss: () => void;
|
|
||||||
onDismissPermanently: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Setting, App, TFile, Notice } from 'obsidian';
|
import { Setting, App, TFile } from 'obsidian';
|
||||||
import { MovePreview, PreviewEntry } from '../types/MovePreview';
|
import { MovePreview, PreviewEntry } from '../types/MovePreview';
|
||||||
import NoteMoverShortcutPlugin from 'main';
|
import NoteMoverShortcutPlugin from 'main';
|
||||||
import { NoticeManager } from '../utils/NoticeManager';
|
import { NoticeManager } from '../utils/NoticeManager';
|
||||||
|
|
@ -9,6 +9,7 @@ import { BaseModal, BaseModalOptions } from './BaseModal';
|
||||||
|
|
||||||
export class PreviewModal extends BaseModal {
|
export class PreviewModal extends BaseModal {
|
||||||
private movePreview: MovePreview;
|
private movePreview: MovePreview;
|
||||||
|
private actionFooterEl: HTMLElement | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
app: App,
|
app: App,
|
||||||
|
|
@ -158,6 +159,7 @@ export class PreviewModal extends BaseModal {
|
||||||
? 'noteMover-modal-footer noteMover-modal-footer-mobile'
|
? 'noteMover-modal-footer noteMover-modal-footer-mobile'
|
||||||
: 'noteMover-modal-footer',
|
: 'noteMover-modal-footer',
|
||||||
});
|
});
|
||||||
|
this.actionFooterEl = footer;
|
||||||
|
|
||||||
const buttonContainer = this.createButtonContainer(footer);
|
const buttonContainer = this.createButtonContainer(footer);
|
||||||
|
|
||||||
|
|
@ -217,17 +219,36 @@ export class PreviewModal extends BaseModal {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async executeMoves() {
|
private async executeMoves() {
|
||||||
this.close();
|
|
||||||
|
|
||||||
const successfulEntries = this.movePreview.successfulMoves;
|
const successfulEntries = this.movePreview.successfulMoves;
|
||||||
let movedCount = 0;
|
let movedCount = 0;
|
||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
|
const abortCtl = new AbortController();
|
||||||
|
|
||||||
const bulkOperationId =
|
if (this.actionFooterEl) {
|
||||||
this.plugin.historyManager.startBulkOperation('bulk');
|
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 {
|
try {
|
||||||
for (const entry of successfulEntries) {
|
for (let i = 0; i < successfulEntries.length; i++) {
|
||||||
|
const entry = successfulEntries[i];
|
||||||
|
if (abortCtl.signal.aborted) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const file = this.app.vault.getAbstractFileByPath(entry.currentPath);
|
const file = this.app.vault.getAbstractFileByPath(entry.currentPath);
|
||||||
if (!(file instanceof TFile) || !entry.targetPath) continue;
|
if (!(file instanceof TFile) || !entry.targetPath) continue;
|
||||||
|
|
@ -258,12 +279,35 @@ export class PreviewModal extends BaseModal {
|
||||||
handleError(error, `Error moving file ${entry.fileName}`, false);
|
handleError(error, `Error moving file ${entry.fileName}`, false);
|
||||||
errorCount++;
|
errorCount++;
|
||||||
}
|
}
|
||||||
|
if ((i + 1) % 50 === 0 && i + 1 < successfulEntries.length) {
|
||||||
|
await new Promise<void>(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 {
|
} finally {
|
||||||
this.plugin.historyManager.endBulkOperation();
|
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!`);
|
NoticeManager.success(`Successfully moved ${movedCount} files!`);
|
||||||
} else {
|
} else {
|
||||||
NoticeManager.warning(
|
NoticeManager.warning(
|
||||||
|
|
|
||||||
|
|
@ -12,30 +12,22 @@ import { TagSuggest } from '../settings/suggesters/TagSuggest';
|
||||||
import { PropertySuggest } from '../settings/suggesters/PropertySuggest';
|
import { PropertySuggest } from '../settings/suggesters/PropertySuggest';
|
||||||
import { PropertyValueSuggest } from '../settings/suggesters/PropertyValueSuggest';
|
import { PropertyValueSuggest } from '../settings/suggesters/PropertyValueSuggest';
|
||||||
import { DragDropManager } from '../utils/DragDropManager';
|
import { DragDropManager } from '../utils/DragDropManager';
|
||||||
import { MobileUtils } from '../utils/MobileUtils';
|
|
||||||
import {
|
import {
|
||||||
getOperatorsForCriteriaType,
|
getOperatorsForCriteriaType,
|
||||||
getOperatorsForPropertyType,
|
getOperatorsForPropertyType,
|
||||||
getDefaultOperatorForCriteriaType,
|
getDefaultOperatorForCriteriaType,
|
||||||
getAvailablePropertyTypes,
|
|
||||||
isRegexOperator,
|
isRegexOperator,
|
||||||
operatorRequiresValue,
|
operatorRequiresValue,
|
||||||
getPropertyTypeFromVault,
|
getPropertyTypeFromVault,
|
||||||
} from '../utils/OperatorMapping';
|
} from '../utils/OperatorMapping';
|
||||||
import {
|
import type { PluginVaultIndexCache } from '../infrastructure/cache/plugin-vault-index-cache';
|
||||||
MobileModalInput,
|
|
||||||
MobileModalToggle,
|
|
||||||
MobileModalButton,
|
|
||||||
MobileModalSection,
|
|
||||||
MobileTriggerCard,
|
|
||||||
MobileTriggerCardCallbacks,
|
|
||||||
} from './mobile';
|
|
||||||
|
|
||||||
interface RuleEditorModalOptions {
|
interface RuleEditorModalOptions {
|
||||||
rule: RuleV2;
|
rule: RuleV2;
|
||||||
isEditMode: boolean;
|
isEditMode: boolean;
|
||||||
onSave: (rule: RuleV2) => Promise<void>;
|
onSave: (rule: RuleV2) => Promise<void>;
|
||||||
onDelete?: () => Promise<void>;
|
onDelete?: () => Promise<void>;
|
||||||
|
vaultIndexCache?: PluginVaultIndexCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RuleEditorModal extends BaseModal {
|
export class RuleEditorModal extends BaseModal {
|
||||||
|
|
@ -43,7 +35,6 @@ export class RuleEditorModal extends BaseModal {
|
||||||
private workingRule: RuleV2;
|
private workingRule: RuleV2;
|
||||||
private dragDropManager: DragDropManager | null = null;
|
private dragDropManager: DragDropManager | null = null;
|
||||||
private triggersContainer: HTMLElement | null = null;
|
private triggersContainer: HTMLElement | null = null;
|
||||||
private mobileTriggerCards: MobileTriggerCard[] = [];
|
|
||||||
|
|
||||||
constructor(app: App, options: RuleEditorModalOptions) {
|
constructor(app: App, options: RuleEditorModalOptions) {
|
||||||
super(app, {
|
super(app, {
|
||||||
|
|
@ -59,55 +50,7 @@ export class RuleEditorModal extends BaseModal {
|
||||||
|
|
||||||
protected createContent(): void {
|
protected createContent(): void {
|
||||||
const { contentEl } = this;
|
const { contentEl } = this;
|
||||||
|
this.createDesktopContent(contentEl);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private createDesktopContent(container: HTMLElement): void {
|
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 {
|
private createMatchConditionsSelector(container: HTMLElement): void {
|
||||||
const setting = new Setting(container).setName('Match Conditions');
|
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 {
|
private createDestinationInput(container: HTMLElement): void {
|
||||||
let destinationInputEl: any = null;
|
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 {
|
private createConditionsSection(container: HTMLElement): void {
|
||||||
const section = container.createDiv({
|
const section = container.createDiv({
|
||||||
cls: 'noteMover-rule-conditions-section',
|
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 {
|
private renderTriggers(): void {
|
||||||
if (!this.triggersContainer) return;
|
if (!this.triggersContainer) return;
|
||||||
|
|
||||||
|
|
@ -541,7 +313,8 @@ export class RuleEditorModal extends BaseModal {
|
||||||
// Automatische Typ-Erkennung
|
// Automatische Typ-Erkennung
|
||||||
const detectedType = getPropertyTypeFromVault(
|
const detectedType = getPropertyTypeFromVault(
|
||||||
this.app,
|
this.app,
|
||||||
trigger.propertyName
|
trigger.propertyName,
|
||||||
|
this.ruleOptions.vaultIndexCache
|
||||||
);
|
);
|
||||||
if (detectedType) {
|
if (detectedType) {
|
||||||
trigger.propertyType = detectedType;
|
trigger.propertyType = detectedType;
|
||||||
|
|
@ -565,7 +338,7 @@ export class RuleEditorModal extends BaseModal {
|
||||||
|
|
||||||
// Add suggesters based on criteriaType
|
// Add suggesters based on criteriaType
|
||||||
if (trigger.criteriaType === 'tag') {
|
if (trigger.criteriaType === 'tag') {
|
||||||
new TagSuggest(this.app, valueInput);
|
new TagSuggest(this.app, valueInput, this.ruleOptions.vaultIndexCache);
|
||||||
} else if (trigger.criteriaType === 'folder') {
|
} else if (trigger.criteriaType === 'folder') {
|
||||||
new FolderSuggest(this.app, valueInput);
|
new FolderSuggest(this.app, valueInput);
|
||||||
} else if (
|
} else if (
|
||||||
|
|
@ -578,7 +351,8 @@ export class RuleEditorModal extends BaseModal {
|
||||||
this.app,
|
this.app,
|
||||||
valueInput,
|
valueInput,
|
||||||
trigger.propertyName,
|
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 {
|
private createFooterActions(container: HTMLElement): void {
|
||||||
const footer = container.createDiv({
|
const footer = container.createDiv({
|
||||||
cls: 'noteMover-rule-editor-footer',
|
cls: 'noteMover-rule-editor-footer',
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,3 @@ export { HistoryModal } from './HistoryModal';
|
||||||
export { PreviewModal } from './PreviewModal';
|
export { PreviewModal } from './PreviewModal';
|
||||||
export { UpdateModal } from './UpdateModal';
|
export { UpdateModal } from './UpdateModal';
|
||||||
export { RuleEditorModal } from './RuleEditorModal';
|
export { RuleEditorModal } from './RuleEditorModal';
|
||||||
export { LegacyMigrationModal } from './LegacyMigrationModal';
|
|
||||||
export type { LegacyMigrationModalOptions } from './LegacyMigrationModal';
|
|
||||||
|
|
|
||||||
|
|
@ -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<void>,
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<void>
|
|
||||||
) {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<void>
|
|
||||||
) {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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';
|
|
||||||
|
|
@ -1,45 +1,15 @@
|
||||||
import NoteMoverShortcutPlugin from 'main';
|
import NoteMoverShortcutPlugin from 'main';
|
||||||
import { PluginSettingTab } from 'obsidian';
|
import { PluginSettingTab } from 'obsidian';
|
||||||
import {
|
|
||||||
HistoryEntry,
|
|
||||||
BulkOperation,
|
|
||||||
RetentionPolicy,
|
|
||||||
} from '../types/HistoryEntry';
|
|
||||||
import { SETTINGS_CONSTANTS } from '../config/constants';
|
|
||||||
import {
|
import {
|
||||||
PeriodicMovementSettingsSection,
|
PeriodicMovementSettingsSection,
|
||||||
FilterSettingsSection,
|
FilterSettingsSection,
|
||||||
RulesSettingsSection,
|
RulesSettingsSection,
|
||||||
HistorySettingsSection,
|
HistorySettingsSection,
|
||||||
ImportExportSettingsSection,
|
ImportExportSettingsSection,
|
||||||
LegacySettingsSection,
|
PerformanceDebugSettingsSection,
|
||||||
} from './sections';
|
} from './sections';
|
||||||
import { DebounceManager } from '../utils/DebounceManager';
|
import { DebounceManager } from '../utils/DebounceManager';
|
||||||
import { MobileUtils } from '../utils/MobileUtils';
|
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 {
|
export class NoteMoverShortcutSettingsTab extends PluginSettingTab {
|
||||||
private periodicMovementSettings: PeriodicMovementSettingsSection;
|
private periodicMovementSettings: PeriodicMovementSettingsSection;
|
||||||
|
|
@ -47,7 +17,7 @@ export class NoteMoverShortcutSettingsTab extends PluginSettingTab {
|
||||||
private rulesSettings: RulesSettingsSection;
|
private rulesSettings: RulesSettingsSection;
|
||||||
private historySettings: HistorySettingsSection;
|
private historySettings: HistorySettingsSection;
|
||||||
private importExportSettings: ImportExportSettingsSection;
|
private importExportSettings: ImportExportSettingsSection;
|
||||||
private legacySettings: LegacySettingsSection;
|
private performanceDebugSettings: PerformanceDebugSettingsSection;
|
||||||
private debounceManager: DebounceManager;
|
private debounceManager: DebounceManager;
|
||||||
|
|
||||||
constructor(private plugin: NoteMoverShortcutPlugin) {
|
constructor(private plugin: NoteMoverShortcutPlugin) {
|
||||||
|
|
@ -85,7 +55,7 @@ export class NoteMoverShortcutSettingsTab extends PluginSettingTab {
|
||||||
this.containerEl,
|
this.containerEl,
|
||||||
debouncedDisplay
|
debouncedDisplay
|
||||||
);
|
);
|
||||||
this.legacySettings = new LegacySettingsSection(
|
this.performanceDebugSettings = new PerformanceDebugSettingsSection(
|
||||||
plugin,
|
plugin,
|
||||||
this.containerEl,
|
this.containerEl,
|
||||||
debouncedDisplay
|
debouncedDisplay
|
||||||
|
|
@ -98,17 +68,6 @@ export class NoteMoverShortcutSettingsTab extends PluginSettingTab {
|
||||||
// Add mobile-specific classes to container
|
// Add mobile-specific classes to container
|
||||||
MobileUtils.addMobileClass(this.containerEl);
|
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
|
// Clean up existing section instances before creating new ones
|
||||||
this.cleanupExistingSections();
|
this.cleanupExistingSections();
|
||||||
|
|
||||||
|
|
@ -147,7 +106,7 @@ export class NoteMoverShortcutSettingsTab extends PluginSettingTab {
|
||||||
this.containerEl,
|
this.containerEl,
|
||||||
debouncedDisplay
|
debouncedDisplay
|
||||||
);
|
);
|
||||||
this.legacySettings = new LegacySettingsSection(
|
this.performanceDebugSettings = new PerformanceDebugSettingsSection(
|
||||||
this.plugin,
|
this.plugin,
|
||||||
this.containerEl,
|
this.containerEl,
|
||||||
debouncedDisplay
|
debouncedDisplay
|
||||||
|
|
@ -165,21 +124,13 @@ export class NoteMoverShortcutSettingsTab extends PluginSettingTab {
|
||||||
|
|
||||||
this.importExportSettings.addImportExportSettings();
|
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 {
|
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)) {
|
if (!Array.isArray(this.plugin.settings.settings?.filters?.filter)) {
|
||||||
(this.plugin.settings as any).settings =
|
(this.plugin.settings as any).settings =
|
||||||
this.plugin.settings.settings || ({} as any);
|
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
|
* Clean up existing section instances to prevent memory leaks
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<void>,
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<void>
|
|
||||||
) {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<void>;
|
|
||||||
onEdit?: () => void;
|
|
||||||
onClone?: () => void | Promise<void>;
|
|
||||||
onDelete?: () => void | Promise<void>;
|
|
||||||
onMoveUp?: () => void | Promise<void>;
|
|
||||||
onMoveDown?: () => void | Promise<void>;
|
|
||||||
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<void>;
|
|
||||||
onPathChange?: (value: string) => void | Promise<void>;
|
|
||||||
onDelete?: () => void | Promise<void>;
|
|
||||||
onMoveUp?: () => void | Promise<void>;
|
|
||||||
onMoveDown?: () => void | Promise<void>;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<void>
|
|
||||||
) {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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}`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<void> {
|
|
||||||
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<void> {
|
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<MobileRuleItemV2 | MobileRuleItemV1> = [];
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -75,7 +75,11 @@ export class FilterSettingsSection {
|
||||||
const s = new Setting(filtersContainer)
|
const s = new Setting(filtersContainer)
|
||||||
.addSearch(cb => {
|
.addSearch(cb => {
|
||||||
// AdvancedSuggest instead of TagSuggest
|
// 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
|
// Track the instance for cleanup
|
||||||
this.advancedSuggestInstances.push(advancedSuggest);
|
this.advancedSuggestInstances.push(advancedSuggest);
|
||||||
cb.setPlaceholder(SETTINGS_CONSTANTS.PLACEHOLDER_TEXTS.FILTER)
|
cb.setPlaceholder(SETTINGS_CONSTANTS.PLACEHOLDER_TEXTS.FILTER)
|
||||||
|
|
|
||||||
|
|
@ -104,8 +104,9 @@ export class ImportExportSettingsSection {
|
||||||
// Show success message
|
// Show success message
|
||||||
NoticeManager.info(SETTINGS_CONSTANTS.UI_TEXTS.EXPORT_SUCCESS);
|
NoticeManager.info(SETTINGS_CONSTANTS.UI_TEXTS.EXPORT_SUCCESS);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
handleError(
|
handleError(
|
||||||
createError(`Export failed: ${error.message}`),
|
createError(`Export failed: ${msg}`),
|
||||||
'Export settings',
|
'Export settings',
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
|
@ -216,7 +217,7 @@ export class ImportExportSettingsSection {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(s.rules)) {
|
if (Array.isArray(s.rules)) {
|
||||||
current.settings.rules = s.rules;
|
(current.settings as any).rules = s.rules;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (s.retentionPolicy) {
|
if (s.retentionPolicy) {
|
||||||
|
|
@ -232,17 +233,6 @@ export class ImportExportSettingsSection {
|
||||||
current.schemaVersion = importedSettings.schemaVersion;
|
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
|
// Import RuleV2 if present
|
||||||
if (s.rulesV2 !== undefined) {
|
if (s.rulesV2 !== undefined) {
|
||||||
current.settings.rulesV2 = s.rulesV2;
|
current.settings.rulesV2 = s.rulesV2;
|
||||||
|
|
@ -272,7 +262,8 @@ export class ImportExportSettingsSection {
|
||||||
.map((v: string) => ({ value: v }));
|
.map((v: string) => ({ value: v }));
|
||||||
}
|
}
|
||||||
if (Array.isArray(sanitizedSettings.rules)) {
|
if (Array.isArray(sanitizedSettings.rules)) {
|
||||||
current.settings.rules = sanitizedSettings.rules;
|
(current.settings as { rules?: unknown[] }).rules =
|
||||||
|
sanitizedSettings.rules;
|
||||||
}
|
}
|
||||||
if (sanitizedSettings.retentionPolicy) {
|
if (sanitizedSettings.retentionPolicy) {
|
||||||
current.settings.retentionPolicy =
|
current.settings.retentionPolicy =
|
||||||
|
|
@ -283,22 +274,21 @@ export class ImportExportSettingsSection {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate V1 rules to V2 when not in legacy mode
|
const legacyRules = (current.settings as any).rules;
|
||||||
if (!current.settings.enableLegacyRules) {
|
if (
|
||||||
if (
|
Array.isArray(legacyRules) &&
|
||||||
RuleMigrationService.shouldMigrate(
|
RuleMigrationService.shouldMigrate(
|
||||||
current.settings.rules,
|
legacyRules,
|
||||||
current.settings.rulesV2
|
current.settings.rulesV2 ?? []
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
console.log('Migrating imported Rule V1 to Rule V2...');
|
console.log('Migrating imported Rule V1 to Rule V2...');
|
||||||
current.settings.rulesV2 = RuleMigrationService.migrateRules(
|
current.settings.rulesV2 =
|
||||||
current.settings.rules
|
RuleMigrationService.migrateRules(legacyRules);
|
||||||
);
|
(current.settings as any).rules = [];
|
||||||
console.log(
|
console.log(
|
||||||
`Migrated ${current.settings.rulesV2.length} imported rules to V2 format`
|
`Migrated ${(current.settings.rulesV2 ?? []).length} imported rules to V2 format`
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save settings
|
// Save settings
|
||||||
|
|
@ -314,9 +304,10 @@ export class ImportExportSettingsSection {
|
||||||
NoticeManager.success(SETTINGS_CONSTANTS.UI_TEXTS.IMPORT_SUCCESS);
|
NoticeManager.success(SETTINGS_CONSTANTS.UI_TEXTS.IMPORT_SUCCESS);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message !== 'File selection cancelled') {
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
if (msg !== 'File selection cancelled') {
|
||||||
handleError(
|
handleError(
|
||||||
createError(`Import failed: ${error.message}`),
|
createError(`Import failed: ${msg}`),
|
||||||
'Import settings',
|
'Import settings',
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
88
src/settings/sections/PerformanceDebugSettingsSection.ts
Normal file
88
src/settings/sections/PerformanceDebugSettingsSection.ts
Normal file
|
|
@ -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)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -89,12 +89,12 @@ export class TriggerSettingsSection {
|
||||||
);
|
);
|
||||||
|
|
||||||
const cacheSetting = new Setting(this.containerEl)
|
const cacheSetting = new Setting(this.containerEl)
|
||||||
.setName('Enable rule evaluation cache (beta)')
|
.setName('Enable rule evaluation cache')
|
||||||
.setDesc(cacheDesc)
|
.setDesc(cacheDesc)
|
||||||
.addToggle(toggle =>
|
.addToggle(toggle =>
|
||||||
toggle
|
toggle
|
||||||
.setValue(
|
.setValue(
|
||||||
this.plugin.settings.settings.enableRuleEvaluationCache ?? false
|
this.plugin.settings.settings.enableRuleEvaluationCache ?? true
|
||||||
)
|
)
|
||||||
.onChange(async value => {
|
.onChange(async value => {
|
||||||
this.plugin.settings.settings.enableRuleEvaluationCache = value;
|
this.plugin.settings.settings.enableRuleEvaluationCache = value;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import NoteMoverShortcutPlugin from 'main';
|
import NoteMoverShortcutPlugin from 'main';
|
||||||
import { App, Setting } from 'obsidian';
|
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 { SETTINGS_CONSTANTS } from '../../config/constants';
|
||||||
import { DragDropManager } from '../../utils/DragDropManager';
|
import { DragDropManager } from '../../utils/DragDropManager';
|
||||||
import { RuleEditorModal } from '../../modals/RuleEditorModal';
|
import { RuleEditorModal } from '../../modals/RuleEditorModal';
|
||||||
|
|
@ -11,7 +8,6 @@ import { MobileUtils } from '../../utils/MobileUtils';
|
||||||
|
|
||||||
export class RulesSettingsSection {
|
export class RulesSettingsSection {
|
||||||
private dragDropManager: DragDropManager | null = null;
|
private dragDropManager: DragDropManager | null = null;
|
||||||
private advancedSuggestInstances: AdvancedSuggest[] = [];
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private plugin: NoteMoverShortcutPlugin,
|
private plugin: NoteMoverShortcutPlugin,
|
||||||
|
|
@ -33,29 +29,6 @@ export class RulesSettingsSection {
|
||||||
}
|
}
|
||||||
|
|
||||||
addAddRuleButtonSetting(): void {
|
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 =>
|
new Setting(this.containerEl).addButton(btn =>
|
||||||
btn
|
btn
|
||||||
.setButtonText('+ Add Rule')
|
.setButtonText('+ Add Rule')
|
||||||
|
|
@ -67,170 +40,17 @@ export class RulesSettingsSection {
|
||||||
}
|
}
|
||||||
|
|
||||||
addRulesArray(): void {
|
addRulesArray(): void {
|
||||||
if (this.plugin.settings.settings.enableLegacyRules) {
|
this.addRulesV2Array();
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private get app(): App {
|
private get app(): App {
|
||||||
return this.plugin.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 method to be called when the section is destroyed
|
||||||
*/
|
*/
|
||||||
public cleanup(): void {
|
public cleanup(): void {
|
||||||
this.cleanupAdvancedSuggestInstances();
|
|
||||||
if (this.dragDropManager) {
|
if (this.dragDropManager) {
|
||||||
this.dragDropManager.destroy();
|
this.dragDropManager.destroy();
|
||||||
this.dragDropManager = null;
|
this.dragDropManager = null;
|
||||||
|
|
@ -458,6 +278,7 @@ export class RulesSettingsSection {
|
||||||
this.refreshDisplay();
|
this.refreshDisplay();
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
vaultIndexCache: this.plugin.vaultIndexCache,
|
||||||
});
|
});
|
||||||
|
|
||||||
modal.open();
|
modal.open();
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,4 @@ export { FilterSettingsSection } from './FilterSettingsSection';
|
||||||
export { RulesSettingsSection } from './RulesSettingsSection';
|
export { RulesSettingsSection } from './RulesSettingsSection';
|
||||||
export { HistorySettingsSection } from './HistorySettingsSection';
|
export { HistorySettingsSection } from './HistorySettingsSection';
|
||||||
export { ImportExportSettingsSection } from './ImportExportSettingsSection';
|
export { ImportExportSettingsSection } from './ImportExportSettingsSection';
|
||||||
export { LegacySettingsSection } from './LegacySettingsSection';
|
export { PerformanceDebugSettingsSection } from './PerformanceDebugSettingsSection';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { App, AbstractInputSuggest, TFolder, TAbstractFile } from 'obsidian';
|
import { App, AbstractInputSuggest, TFolder, TAbstractFile } from 'obsidian';
|
||||||
import { MetadataExtractor } from '../../core/MetadataExtractor';
|
import { MetadataExtractor } from '../../core/MetadataExtractor';
|
||||||
|
import type { PluginVaultIndexCache } from '../../infrastructure/cache/plugin-vault-index-cache';
|
||||||
|
|
||||||
export type SuggestType =
|
export type SuggestType =
|
||||||
| 'tag'
|
| 'tag'
|
||||||
|
|
@ -36,7 +37,8 @@ export class AdvancedSuggest extends AbstractInputSuggest<string> {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public app: App,
|
public app: App,
|
||||||
private inputEl: HTMLInputElement
|
private inputEl: HTMLInputElement,
|
||||||
|
private vaultIndexCache?: PluginVaultIndexCache
|
||||||
) {
|
) {
|
||||||
super(app, inputEl);
|
super(app, inputEl);
|
||||||
this.metadataExtractor = new MetadataExtractor(app);
|
this.metadataExtractor = new MetadataExtractor(app);
|
||||||
|
|
@ -57,7 +59,11 @@ export class AdvancedSuggest extends AbstractInputSuggest<string> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadTags(): void {
|
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 {
|
private loadFolders(): void {
|
||||||
|
|
@ -66,7 +72,9 @@ export class AdvancedSuggest extends AbstractInputSuggest<string> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadFileNames(): void {
|
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);
|
this.fileNames = files.map(f => f.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,6 +93,17 @@ export class AdvancedSuggest extends AbstractInputSuggest<string> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadPropertyKeysAndValues(): void {
|
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();
|
const files = this.app.vault.getMarkdownFiles();
|
||||||
files.forEach(file => {
|
files.forEach(file => {
|
||||||
const cachedMetadata = this.app.metadataCache.getFileCache(file);
|
const cachedMetadata = this.app.metadataCache.getFileCache(file);
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
import { App, AbstractInputSuggest } from 'obsidian';
|
import { App, AbstractInputSuggest } from 'obsidian';
|
||||||
|
import type { PluginVaultIndexCache } from '../../infrastructure/cache/plugin-vault-index-cache';
|
||||||
|
|
||||||
export class PropertyValueSuggest extends AbstractInputSuggest<string> {
|
export class PropertyValueSuggest extends AbstractInputSuggest<string> {
|
||||||
private propertyName: string;
|
private propertyName: string;
|
||||||
private propertyType: 'text' | 'number' | 'checkbox' | 'date' | 'list';
|
private propertyType: 'text' | 'number' | 'checkbox' | 'date' | 'list';
|
||||||
private availableValues: Set<string>;
|
private availableValues = new Set<string>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
app: App,
|
app: App,
|
||||||
private inputEl: HTMLInputElement,
|
private inputEl: HTMLInputElement,
|
||||||
propertyName: string,
|
propertyName: string,
|
||||||
propertyType: 'text' | 'number' | 'checkbox' | 'date' | 'list'
|
propertyType: 'text' | 'number' | 'checkbox' | 'date' | 'list',
|
||||||
|
private vaultIndexCache?: PluginVaultIndexCache
|
||||||
) {
|
) {
|
||||||
super(app, inputEl);
|
super(app, inputEl);
|
||||||
this.propertyName = propertyName;
|
this.propertyName = propertyName;
|
||||||
|
|
@ -25,6 +27,15 @@ export class PropertyValueSuggest extends AbstractInputSuggest<string> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.vaultIndexCache) {
|
||||||
|
this.availableValues = this.vaultIndexCache.getPropertyValueSetCached(
|
||||||
|
this.app,
|
||||||
|
this.propertyName,
|
||||||
|
this.propertyType
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const files = this.app.vault.getMarkdownFiles();
|
const files = this.app.vault.getMarkdownFiles();
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { App, AbstractInputSuggest } from 'obsidian';
|
import { App, AbstractInputSuggest } from 'obsidian';
|
||||||
import { MetadataExtractor } from '../../core/MetadataExtractor';
|
import { MetadataExtractor } from '../../core/MetadataExtractor';
|
||||||
|
import type { PluginVaultIndexCache } from '../../infrastructure/cache/plugin-vault-index-cache';
|
||||||
|
|
||||||
export class TagSuggest extends AbstractInputSuggest<string> {
|
export class TagSuggest extends AbstractInputSuggest<string> {
|
||||||
private tags: Set<string>;
|
private tags: Set<string>;
|
||||||
|
|
@ -7,7 +8,8 @@ export class TagSuggest extends AbstractInputSuggest<string> {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
app: App,
|
app: App,
|
||||||
private inputEl: HTMLInputElement
|
private inputEl: HTMLInputElement,
|
||||||
|
private vaultIndexCache?: PluginVaultIndexCache
|
||||||
) {
|
) {
|
||||||
super(app, inputEl);
|
super(app, inputEl);
|
||||||
this.tags = new Set();
|
this.tags = new Set();
|
||||||
|
|
@ -16,7 +18,11 @@ export class TagSuggest extends AbstractInputSuggest<string> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadTags(): void {
|
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[] {
|
getSuggestions(inputStr: string): string[] {
|
||||||
|
|
|
||||||
14
src/test-utils/obsidian-vitest-stub.ts
Normal file
14
src/test-utils/obsidian-vitest-stub.ts
Normal file
|
|
@ -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, unknown>): string[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { BulkOperation, HistoryEntry, RetentionPolicy } from './HistoryEntry';
|
import { BulkOperation, HistoryEntry, RetentionPolicy } from './HistoryEntry';
|
||||||
import { Rule } from './Rule';
|
|
||||||
import { RuleV2 } from './RuleV2';
|
import { RuleV2 } from './RuleV2';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -15,12 +14,13 @@ export interface PluginData {
|
||||||
export interface SettingsData {
|
export interface SettingsData {
|
||||||
triggers: TriggerSettings;
|
triggers: TriggerSettings;
|
||||||
filters: FilterSettings;
|
filters: FilterSettings;
|
||||||
rules: Rule[];
|
|
||||||
retentionPolicy: RetentionPolicy;
|
retentionPolicy: RetentionPolicy;
|
||||||
enableLegacyRules?: boolean;
|
|
||||||
legacyMigrationDismissed?: boolean;
|
|
||||||
rulesV2?: RuleV2[];
|
rulesV2?: RuleV2[];
|
||||||
enableRuleEvaluationCache?: boolean;
|
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 {
|
export interface HistoryData {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -12,6 +12,10 @@ import {
|
||||||
CheckboxPropertyOperator,
|
CheckboxPropertyOperator,
|
||||||
} from '../types/RuleV2';
|
} from '../types/RuleV2';
|
||||||
import { App } from 'obsidian';
|
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
|
* Utility functions for mapping operators to criteria types and property types
|
||||||
|
|
@ -335,16 +339,25 @@ export function operatorRequiresValue(operator: Operator): boolean {
|
||||||
*/
|
*/
|
||||||
export function getPropertyTypeFromVault(
|
export function getPropertyTypeFromVault(
|
||||||
app: App,
|
app: App,
|
||||||
propertyName: string
|
propertyName: string,
|
||||||
|
vaultIndexCache?: PluginVaultIndexCache | null
|
||||||
): 'text' | 'number' | 'checkbox' | 'date' | 'list' | 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 valueSamples: any[] = [];
|
||||||
|
const maxSamples = vaultIndexCache
|
||||||
|
? PROPERTY_TYPE_INFERENCE_MAX_SAMPLES
|
||||||
|
: Infinity;
|
||||||
|
|
||||||
// Collect samples of the property value from multiple files
|
// Collect samples of the property value from multiple files
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const cache = app.metadataCache.getFileCache(file);
|
const cache = app.metadataCache.getFileCache(file);
|
||||||
if (cache?.frontmatter?.[propertyName] !== undefined) {
|
if (cache?.frontmatter?.[propertyName] !== undefined) {
|
||||||
valueSamples.push(cache.frontmatter[propertyName]);
|
valueSamples.push(cache.frontmatter[propertyName]);
|
||||||
|
if (valueSamples.length >= maxSamples) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { NoteMoverShortcutSettings } from '../settings/Settings';
|
|
||||||
import { HistoryEntry, BulkOperation } from '../types/HistoryEntry';
|
import { HistoryEntry, BulkOperation } from '../types/HistoryEntry';
|
||||||
import { PluginData } from '../types/PluginData';
|
import { PluginData } from '../types/PluginData';
|
||||||
import { RuleV2, Trigger, CriteriaType, Operator } from '../types/RuleV2';
|
import { RuleV2, Trigger, CriteriaType, Operator } from '../types/RuleV2';
|
||||||
import { validateDestinationTemplate } from './DestinationTemplate';
|
import { validateDestinationTemplate } from '../domain/templates/DestinationTemplate';
|
||||||
import {
|
import {
|
||||||
getOperatorsForCriteriaType,
|
getOperatorsForCriteriaType,
|
||||||
getOperatorsForPropertyType,
|
getOperatorsForPropertyType,
|
||||||
|
|
@ -18,14 +17,6 @@ export interface ValidationResult {
|
||||||
warnings: string[];
|
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 {
|
export class SettingsValidator {
|
||||||
/**
|
/**
|
||||||
* Validates a complete settings object
|
* Validates a complete settings object
|
||||||
|
|
@ -49,7 +40,7 @@ export class SettingsValidator {
|
||||||
return this.validatePluginData(settings as PluginData);
|
return this.validatePluginData(settings as PluginData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise treat as legacy NoteMoverShortcutSettings
|
// Flat legacy JSON (pre–PluginData export shape)
|
||||||
return this.validateLegacySettings(settings);
|
return this.validateLegacySettings(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,14 +61,14 @@ export class SettingsValidator {
|
||||||
return (
|
return (
|
||||||
('triggers' in s && typeof s.triggers === 'object') ||
|
('triggers' in s && typeof s.triggers === 'object') ||
|
||||||
('filters' in s && typeof s.filters === 'object') ||
|
('filters' in s && typeof s.filters === 'object') ||
|
||||||
Array.isArray(s.rules)
|
Array.isArray(s.rulesV2)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate legacy NoteMoverShortcutSettings
|
* Validate flat legacy settings JSON (historical export format without nested `settings`).
|
||||||
*/
|
*/
|
||||||
private static validateLegacySettings(settings: any): ValidationResult {
|
private static validateLegacySettings(settings: any): ValidationResult {
|
||||||
const result: ValidationResult = {
|
const result: ValidationResult = {
|
||||||
|
|
@ -394,8 +385,7 @@ export class SettingsValidator {
|
||||||
result: ValidationResult
|
result: ValidationResult
|
||||||
): boolean {
|
): boolean {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
result.errors.push('Field "rules" is required');
|
return true;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
|
|
@ -506,7 +496,7 @@ export class SettingsValidator {
|
||||||
/**
|
/**
|
||||||
* Creates a clean settings object with only valid fields
|
* Creates a clean settings object with only valid fields
|
||||||
*/
|
*/
|
||||||
static sanitizeSettings(settings: any): Partial<NoteMoverShortcutSettings> {
|
static sanitizeSettings(settings: any): Record<string, unknown> {
|
||||||
const sanitized: any = {};
|
const sanitized: any = {};
|
||||||
|
|
||||||
// Copy valid fields
|
// Copy valid fields
|
||||||
|
|
|
||||||
11
styles.css
11
styles.css
|
|
@ -3122,3 +3122,14 @@
|
||||||
padding-top: 16px;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,19 @@
|
||||||
"inlineSourceMap": true,
|
"inlineSourceMap": true,
|
||||||
"inlineSources": true,
|
"inlineSources": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"target": "ES6",
|
"target": "ES2022",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"noImplicitAny": true,
|
"strict": true,
|
||||||
"moduleResolution": "node",
|
"strictPropertyInitialization": false,
|
||||||
|
"noUncheckedIndexedAccess": false,
|
||||||
|
"verbatimModuleSyntax": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"strictNullChecks": true,
|
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"lib": ["DOM", "ES5", "ES6", "ES7"]
|
"lib": ["DOM", "ES2022"],
|
||||||
|
"types": ["node", "vitest/globals"]
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts"]
|
"include": ["**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,5 +26,7 @@
|
||||||
"0.6.0": "1.5.0",
|
"0.6.0": "1.5.0",
|
||||||
"0.7.0": "1.5.0",
|
"0.7.0": "1.5.0",
|
||||||
"0.7.1": "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"
|
||||||
}
|
}
|
||||||
24
vitest.config.mjs
Normal file
24
vitest.config.mjs
Normal file
|
|
@ -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'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue