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
|
||||
|
||||
## [1.0.0](https://github.com/bueckerlars/obsidian-note-mover-shortcut/compare/0.7.2...1.0.0)
|
||||
|
||||
### Breaking
|
||||
|
||||
- **Legacy Rule V1 removed**: V1 rule types, legacy settings toggles, and duplicate mobile-only settings/modal implementations were dropped. Existing V1 rules are still migrated once on load (with backup) into **Rule V2**.
|
||||
|
||||
### Features
|
||||
|
||||
- **Preview bulk cancel**: While executing moves from the preview modal, you can **Stop** the remaining renames; completed moves stay in history.
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Rule evaluation cache on by default** for new installs and unset data; existing `false` stays respected.
|
||||
- **Lazy `vault.read` for filters**: If a blacklist line uses `content:`, file body is read for that evaluation path only.
|
||||
- **Regex warmup**: Rule V2 regex patterns are precompiled when rules are loaded.
|
||||
- **Bulk throughput**: Chunked vault moves and preview execution use `requestIdleCallback` when available instead of only `setTimeout(0)`.
|
||||
- **Application services**: Thin `MoveFileService`, `PreviewService`, and `HistoryService` facades on the plugin (`appServices`) for clearer layering.
|
||||
- **README**: Documented blacklist-only filters and Rule V2-first configuration (removed outdated whitelist / V1 criteria wording).
|
||||
|
||||
## [0.7.1](https://github.com/bueckerlars/obsidian-note-mover-shortcut/compare/0.7.0...0.7.1)
|
||||
|
||||
### Features
|
||||
|
|
|
|||
33
README.md
33
README.md
|
|
@ -32,10 +32,8 @@ Notes:
|
|||
|
||||

|
||||
|
||||
- **Toggle blacklist/whitelist**: Choose between:
|
||||
- **Blacklist**: Move all files EXCEPT those matching the specified criteria
|
||||
- **Whitelist**: Move ONLY files matching the specified criteria
|
||||
- **Filter criteria**: Add criteria to include/exclude from movement. Supported types:
|
||||
- **Blacklist only**: Filters always act as a **blacklist** — files matching any filter line are **excluded** from automatic moves (manual commands still respect the same rules + filters).
|
||||
- **Filter criteria**: Add criteria to exclude from movement. Supported types:
|
||||
- **Tags**: `tag: tagname` - Match files with specific tags (e.g., `tag: #inbox`, `tag: work/project`)
|
||||
- **Properties**: Property-based criteria from frontmatter:
|
||||
- `property: key` - Match files that have the specified property key
|
||||
|
|
@ -47,32 +45,13 @@ Notes:
|
|||
- **Creation Date**: `created_at: date` - Match files based on creation date
|
||||
- **Update Date**: `updated_at: date` - Match files based on last modification date
|
||||
|
||||
#### Rules
|
||||
#### Rules (Rule V2)
|
||||
|
||||

|
||||
|
||||
- **Rules description**: Define custom rules for moving files based on various criteria. Rules are always active.
|
||||
- **Rule configuration**: For each rule, specify:
|
||||
- **Criteria**: The criteria that triggers the rule. Supported types:
|
||||
- **Tags**: `tag: tagname` - Trigger for files with specific tags
|
||||
- Supports subtags (e.g., `tag: work/project` matches both `#work/project` and `#work`)
|
||||
- Example: `tag: meeting`, `tag: #inbox`
|
||||
- **Properties**: `property: key` or `property: key:value` - Trigger based on frontmatter properties
|
||||
- `property: key` - Trigger for files that have the specified property key
|
||||
- `property: key:value` - Trigger for files where the property key has the exact value
|
||||
- Example: `property: type:meeting`, `property: urgent`
|
||||
- **File Names**: `fileName: pattern` - Trigger for files matching filename patterns
|
||||
- Supports wildcards (e.g., `fileName: *.json`, `fileName: Daily*`)
|
||||
- Example: `fileName: Meeting`, `fileName: *.md`
|
||||
- **Content**: `content: text` - Trigger for files containing specific text
|
||||
- Example: `content: TODO`, `content: urgent`
|
||||
- **Path**: `path: folder/path` - Trigger for files in specific locations
|
||||
- Example: `path: Inbox/`, `path: Templates/`
|
||||
- **Creation Date**: `created_at: date` - Trigger based on when the file was created
|
||||
- **Update Date**: `updated_at: date` - Trigger based on when the file was last modified
|
||||
- **Path**: The destination folder for files matching this criteria
|
||||
- Note: If a file matches multiple rules, the first matching rule will be applied.
|
||||
- Note: Destination folders will be created automatically if they don't exist.
|
||||
- **Rules**: Define **Rule V2** entries with named rules, **match aggregation** (`all` / `any` / `none`), typed **triggers** (tag, fileName, folder, properties, dates, links, embeds, headings, extension), and a **destination** (plain path or template).
|
||||
- **Order**: If multiple rules match, the **first** matching rule in the list wins.
|
||||
- **Destinations**: Folders are created when needed. Template placeholders such as `{{tag.x}}` / `{{property.y}}` are supported (see below).
|
||||
|
||||
##### Template Rules for Dynamic Destinations (Rule V2)
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ const context = await esbuild.context({
|
|||
...builtins,
|
||||
],
|
||||
format: 'cjs',
|
||||
target: 'es2018',
|
||||
target: 'es2022',
|
||||
logLevel: 'info',
|
||||
sourcemap: prod ? false : 'inline',
|
||||
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 { CommandHandler } from 'src/handlers/CommandHandler';
|
||||
import { NoteMoverShortcutSettingsTab } from 'src/settings/Settings';
|
||||
|
|
@ -6,471 +6,90 @@ import { HistoryManager } from 'src/core/HistoryManager';
|
|||
import { TriggerEventHandler } from 'src/core/TriggerEventHandler';
|
||||
import { UpdateManager } from 'src/core/UpdateManager';
|
||||
import { RuleEvaluationCache } from 'src/core/RuleEvaluationCache';
|
||||
import type { PluginData } from 'src/types/PluginData';
|
||||
import {
|
||||
PluginData,
|
||||
SettingsData,
|
||||
HistoryData,
|
||||
Filter,
|
||||
} from 'src/types/PluginData';
|
||||
import { SETTINGS_CONSTANTS } from 'src/config/constants';
|
||||
import { SettingsValidator } from 'src/utils/SettingsValidator';
|
||||
import { RuleMigrationService } from 'src/core/RuleMigrationService';
|
||||
import { LegacyMigrationModal } from 'src/modals/LegacyMigrationModal';
|
||||
loadPersistedSettings,
|
||||
savePersistedSettings,
|
||||
} from 'src/infrastructure/persistence/plugin-settings-controller';
|
||||
import {
|
||||
registerVaultEventHooks,
|
||||
registerVaultIndexMetadataListeners,
|
||||
} from 'src/infrastructure/plugin/vault-event-hooks';
|
||||
import { PluginVaultIndexCache } from 'src/infrastructure/cache/plugin-vault-index-cache';
|
||||
import { PerformanceTraceRecorder } from 'src/infrastructure/debug/performance-trace';
|
||||
import { createPluginApplicationServices } from 'src/application/plugin-application-services';
|
||||
import type { PluginApplicationServices } from 'src/application/plugin-application-services';
|
||||
|
||||
export default class NoteMoverShortcutPlugin extends Plugin {
|
||||
public settings: PluginData;
|
||||
public noteMover: NoteMoverShortcut;
|
||||
public command_handler: CommandHandler;
|
||||
public historyManager: HistoryManager;
|
||||
public updateManager: UpdateManager;
|
||||
public triggerHandler: TriggerEventHandler;
|
||||
public ruleCache: RuleEvaluationCache;
|
||||
private settingTab: NoteMoverShortcutSettingsTab;
|
||||
public settings!: PluginData;
|
||||
public noteMover!: NoteMoverShortcut;
|
||||
public command_handler!: CommandHandler;
|
||||
public historyManager!: HistoryManager;
|
||||
public updateManager!: UpdateManager;
|
||||
public triggerHandler!: TriggerEventHandler;
|
||||
public ruleCache!: RuleEvaluationCache;
|
||||
public vaultIndexCache!: PluginVaultIndexCache;
|
||||
public performanceTrace!: PerformanceTraceRecorder;
|
||||
private settingTab!: NoteMoverShortcutSettingsTab;
|
||||
/** Application-layer facades (use-cases); core logic remains on `noteMover`. */
|
||||
public appServices!: PluginApplicationServices;
|
||||
|
||||
async onload() {
|
||||
await this.load_settings();
|
||||
await loadPersistedSettings(this);
|
||||
|
||||
this.ruleCache = new RuleEvaluationCache();
|
||||
this.vaultIndexCache = new PluginVaultIndexCache();
|
||||
this.performanceTrace = new PerformanceTraceRecorder(
|
||||
() => this.settings.settings.enablePerformanceDebug === true
|
||||
);
|
||||
this.vaultIndexCache.setCacheEnabledResolver(
|
||||
() => this.settings.settings.enableVaultIndexCache !== false
|
||||
);
|
||||
this.vaultIndexCache.setPerformanceRecorder(this.performanceTrace);
|
||||
this.historyManager = new HistoryManager(this);
|
||||
this.historyManager.loadHistoryFromSettings();
|
||||
this.updateManager = new UpdateManager(this);
|
||||
this.noteMover = new NoteMoverShortcut(this);
|
||||
this.appServices = createPluginApplicationServices(this);
|
||||
this.triggerHandler = new TriggerEventHandler(this);
|
||||
this.command_handler = new CommandHandler(this);
|
||||
this.command_handler.setup();
|
||||
|
||||
this.addRibbonIcon('book-plus', 'NoteMover', (evt: MouseEvent) => {
|
||||
this.addRibbonIcon('book-plus', 'NoteMover', () => {
|
||||
this.noteMover.moveFocusedNoteToDestination();
|
||||
});
|
||||
|
||||
this.settingTab = new NoteMoverShortcutSettingsTab(this);
|
||||
this.addSettingTab(this.settingTab);
|
||||
|
||||
// Initialize triggers
|
||||
this.triggerHandler.togglePeriodic();
|
||||
this.triggerHandler.toggleOnEditListener();
|
||||
|
||||
// Event listeners for history and cache dirty-tracking
|
||||
this.setupVaultEventListeners();
|
||||
registerVaultEventHooks(this);
|
||||
registerVaultIndexMetadataListeners(this);
|
||||
|
||||
// Seed the rules hash so the cache knows the current config
|
||||
this.syncRuleCacheHash();
|
||||
|
||||
// Show legacy migration modal when user has V1 rules and has not dismissed the prompt
|
||||
if (
|
||||
this.settings.settings.enableLegacyRules === true &&
|
||||
!this.settings.settings.legacyMigrationDismissed &&
|
||||
this.settings.settings.rules.length > 0
|
||||
) {
|
||||
setTimeout(() => this.showLegacyMigrationModal(), 500);
|
||||
}
|
||||
|
||||
// Check for updates after complete loading
|
||||
this.app.workspace.onLayoutReady(() => {
|
||||
this.updateManager.checkForUpdates();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the legacy migration modal with options to migrate to V2, ask later, or don't ask again.
|
||||
*/
|
||||
private showLegacyMigrationModal(): void {
|
||||
const modal = new LegacyMigrationModal(this.app, {
|
||||
onMigrate: async () => {
|
||||
this.settings.settings.rulesV2 = RuleMigrationService.migrateRules(
|
||||
this.settings.settings.rules
|
||||
);
|
||||
this.settings.settings.enableLegacyRules = false;
|
||||
await this.save_settings();
|
||||
this.noteMover.updateRuleManager();
|
||||
this.syncRuleCacheHash();
|
||||
},
|
||||
onDismiss: () => {},
|
||||
onDismissPermanently: async () => {
|
||||
this.settings.settings.legacyMigrationDismissed = true;
|
||||
await this.save_settings();
|
||||
},
|
||||
});
|
||||
modal.open();
|
||||
}
|
||||
|
||||
onunload() {
|
||||
// Cleanup settings tab debounce manager
|
||||
if (this.settingTab) {
|
||||
this.settingTab.cleanup();
|
||||
}
|
||||
// Cleanup trigger handler debounce manager
|
||||
if (this.triggerHandler) {
|
||||
this.triggerHandler.cleanup();
|
||||
}
|
||||
// Event listeners are automatically removed when the plugin is unloaded
|
||||
this.settingTab?.cleanup();
|
||||
this.triggerHandler?.cleanup();
|
||||
}
|
||||
|
||||
/** Sync the rule-evaluation cache hash with the current settings. */
|
||||
public syncRuleCacheHash(): void {
|
||||
const s = this.settings.settings;
|
||||
this.ruleCache.updateRulesHash(
|
||||
s.rules,
|
||||
s.rulesV2 ?? [],
|
||||
s.filters.filter,
|
||||
!!s.enableLegacyRules
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup of event listeners for automatic history creation and cache
|
||||
* dirty-tracking.
|
||||
*/
|
||||
private setupVaultEventListeners(): void {
|
||||
// Monitor file renaming/moving (history + cache)
|
||||
this.registerEvent(
|
||||
this.app.vault.on('rename', (file, oldPath) => {
|
||||
if (file instanceof TFile && file.extension === 'md') {
|
||||
this.historyManager.addEntryFromVaultEvent(
|
||||
oldPath,
|
||||
file.path,
|
||||
file.name
|
||||
);
|
||||
this.ruleCache.handleRename(oldPath, file.path);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Mark modified files as dirty so the next periodic run re-evaluates them
|
||||
this.registerEvent(
|
||||
this.app.vault.on('modify', (file: TAbstractFile) => {
|
||||
if (file instanceof TFile && file.extension === 'md') {
|
||||
this.ruleCache.markDirty(file.path);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Newly created files need evaluation
|
||||
this.registerEvent(
|
||||
this.app.vault.on('create', (file: TAbstractFile) => {
|
||||
if (file instanceof TFile && file.extension === 'md') {
|
||||
this.ruleCache.markDirty(file.path);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Remove deleted files from the cache
|
||||
this.registerEvent(
|
||||
this.app.vault.on('delete', (file: TAbstractFile) => {
|
||||
if (file instanceof TFile && file.extension === 'md') {
|
||||
this.ruleCache.handleDelete(file.path);
|
||||
}
|
||||
})
|
||||
);
|
||||
this.ruleCache.updateRulesHash(s.rulesV2 ?? [], s.filters.filter);
|
||||
}
|
||||
|
||||
async save_settings(): Promise<void> {
|
||||
// Ensure history in settings is kept in sync and robust against corruption
|
||||
const settingsAny = this.settings as any;
|
||||
const currentHistory = settingsAny.history;
|
||||
|
||||
if (
|
||||
!currentHistory ||
|
||||
typeof currentHistory !== 'object' ||
|
||||
Array.isArray(currentHistory)
|
||||
) {
|
||||
settingsAny.history = { history: [], bulkOperations: [] } as HistoryData;
|
||||
}
|
||||
if (!Array.isArray(settingsAny.history.history)) {
|
||||
settingsAny.history.history = [];
|
||||
}
|
||||
if (!Array.isArray(settingsAny.history.bulkOperations)) {
|
||||
settingsAny.history.bulkOperations = [];
|
||||
}
|
||||
|
||||
// Ensure rule flags are always defined so they are serialized (JSON.stringify omits undefined)
|
||||
const s = this.settings.settings;
|
||||
if (s.enableLegacyRules === undefined) {
|
||||
s.enableLegacyRules = false;
|
||||
}
|
||||
if (s.legacyMigrationDismissed === undefined) {
|
||||
s.legacyMigrationDismissed = false;
|
||||
}
|
||||
|
||||
await this.saveData(this.settings);
|
||||
await savePersistedSettings(this);
|
||||
}
|
||||
|
||||
async load_settings(): Promise<void> {
|
||||
const savedData: any = await this.loadData();
|
||||
|
||||
if (this.isPluginData(savedData)) {
|
||||
// Already in new format
|
||||
this.settings = savedData as PluginData;
|
||||
} else {
|
||||
// Migrate from old NoteMoverShortcutSettings format
|
||||
await this.backupLegacyDataJson();
|
||||
this.settings = this.migrateFromLegacy(savedData || {});
|
||||
await this.save_settings();
|
||||
}
|
||||
|
||||
// Validate settings after loading to clean up any invalid data
|
||||
await this.validateSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate settings to prevent data loss
|
||||
*/
|
||||
private async validateSettings(): Promise<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
|
||||
}
|
||||
await loadPersistedSettings(this);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "note-mover-shortcut",
|
||||
"name": "NoteMover shortcut",
|
||||
"version": "0.7.2",
|
||||
"version": "1.0.0",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Quickly and easily move notes to a predefined folder. Perfect for organizing your notes.",
|
||||
"author": "bueckerlars",
|
||||
|
|
|
|||
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",
|
||||
"version": "0.7.2",
|
||||
"version": "1.0.0",
|
||||
"description": "Quickly and easily move notes to a predefined folder. Perfect for organizing your notes.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
@ -13,24 +13,32 @@
|
|||
"format:check": "prettier --check .",
|
||||
"lint": "eslint . --ext .ts",
|
||||
"lint:fix": "eslint . --ext .ts --fix",
|
||||
"test": "vitest run --config vitest.config.mjs",
|
||||
"test:watch": "vitest --config vitest.config.mjs",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "bueckerlars",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.6",
|
||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||
"@typescript-eslint/parser": "5.29.0",
|
||||
"@types/node": "^20.19.39",
|
||||
"@typescript-eslint/eslint-plugin": "^8.58.2",
|
||||
"@typescript-eslint/parser": "^8.58.2",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "^0.25.9",
|
||||
"eslint": "^8.57.1",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^25.0.1",
|
||||
"lint-staged": "^16.2.1",
|
||||
"obsidian": "latest",
|
||||
"prettier": "^3.6.2",
|
||||
"tslib": "2.4.0",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "4.7.4"
|
||||
"typescript": "~5.6.3",
|
||||
"valibot": "^1.3.1",
|
||||
"vite": "^6.4.2",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.8"
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ function parseChangelog(content: string): ChangelogEntry[] {
|
|||
// Recognize version header (e.g. "## [0.2.1]" or "## [0.1.6]")
|
||||
const versionMatch = line.match(/^##\s*\[?(\d+\.\d+\.\d+)\]?/);
|
||||
if (versionMatch) {
|
||||
const nextVersion = versionMatch[1];
|
||||
if (!nextVersion) continue;
|
||||
// Save previous version if exists
|
||||
if (currentVersion) {
|
||||
entries.push({
|
||||
|
|
@ -41,7 +43,7 @@ function parseChangelog(content: string): ChangelogEntry[] {
|
|||
}
|
||||
|
||||
// Start new version
|
||||
currentVersion = versionMatch[1];
|
||||
currentVersion = nextVersion;
|
||||
currentChanges = {};
|
||||
currentSection = null;
|
||||
continue;
|
||||
|
|
|
|||
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 { FileMetadata } from '../types/Common';
|
||||
import { handleError } from '../utils/Error';
|
||||
import { parseListProperty as parseListPropertyDomain } from '../domain/property/parseListProperty';
|
||||
import { isListProperty as isListPropertyDomain } from '../domain/property/isListProperty';
|
||||
|
||||
/**
|
||||
* Extracts and provides standardized access to file metadata
|
||||
|
|
@ -144,30 +146,7 @@ export class MetadataExtractor {
|
|||
* @returns Array of individual values
|
||||
*/
|
||||
public parseListProperty(value: any): string[] {
|
||||
if (value === null || value === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// If it's already an array, return it as strings
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(item => String(item).trim())
|
||||
.filter(item => item.length > 0);
|
||||
}
|
||||
|
||||
// If it's a string, try to parse it as comma-separated values
|
||||
if (typeof value === 'string') {
|
||||
// Handle both comma-separated and newline-separated values
|
||||
const values = value
|
||||
.split(/[,\n]/)
|
||||
.map(item => item.trim())
|
||||
.filter(item => item.length > 0);
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
// For other types, convert to string
|
||||
return [String(value).trim()].filter(item => item.length > 0);
|
||||
return parseListPropertyDomain(value);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -177,20 +156,7 @@ export class MetadataExtractor {
|
|||
* @returns True if the value represents multiple items
|
||||
*/
|
||||
public isListProperty(value: any): boolean {
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 1;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
// Check if it contains separators (comma or newline)
|
||||
return /[,\n]/.test(value) && value.split(/[,\n]/).length > 1;
|
||||
}
|
||||
|
||||
return false;
|
||||
return isListPropertyDomain(value);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -198,9 +164,13 @@ export class MetadataExtractor {
|
|||
* Includes additional fields: extension, links, embeds, headings
|
||||
*
|
||||
* @param file - TFile to extract metadata from
|
||||
* @param needsContent - When true, reads file body (for blacklist `content:` filters).
|
||||
* @returns Complete FileMetadata with V2 fields
|
||||
*/
|
||||
public async extractFileMetadataV2(file: TFile): Promise<FileMetadata> {
|
||||
public async extractFileMetadataV2(
|
||||
file: TFile,
|
||||
needsContent = false
|
||||
): Promise<FileMetadata> {
|
||||
try {
|
||||
const fileName = file.name;
|
||||
const filePath = file.path;
|
||||
|
|
@ -241,12 +211,21 @@ export class MetadataExtractor {
|
|||
}
|
||||
}
|
||||
|
||||
let fileContent = '';
|
||||
if (needsContent) {
|
||||
try {
|
||||
fileContent = await this.app.vault.read(file);
|
||||
} catch {
|
||||
// optional
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fileName,
|
||||
filePath,
|
||||
tags,
|
||||
properties,
|
||||
fileContent: '',
|
||||
fileContent,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
extension,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { TFile } from 'obsidian';
|
||||
import { NoticeManager } from '../utils/NoticeManager';
|
||||
import { RuleManager } from './RuleManager';
|
||||
import { RuleManagerV2 } from './RuleManagerV2';
|
||||
import { createError, handleError } from '../utils/Error';
|
||||
import { combinePath, ensureFolderExists } from '../utils/PathUtils';
|
||||
|
|
@ -15,33 +14,26 @@ import {
|
|||
const BULK_CHUNK_SIZE = 50;
|
||||
|
||||
export class NoteMoverShortcut {
|
||||
private ruleManager: RuleManager;
|
||||
private ruleManagerV2: RuleManagerV2;
|
||||
|
||||
constructor(private plugin: NoteMoverShortcutPlugin) {
|
||||
this.ruleManager = new RuleManager(plugin.app, '/');
|
||||
this.ruleManagerV2 = new RuleManagerV2(plugin.app, '/');
|
||||
this.ruleManagerV2 = new RuleManagerV2(
|
||||
plugin.app,
|
||||
'/',
|
||||
plugin.performanceTrace
|
||||
);
|
||||
this.updateRuleManager();
|
||||
}
|
||||
|
||||
public updateRuleManager(): void {
|
||||
// Use V1 (legacy) only when legacy mode is enabled; otherwise V2 is default
|
||||
if (this.plugin.settings.settings.enableLegacyRules === true) {
|
||||
this.ruleManager.setRules(this.plugin.settings.settings.rules);
|
||||
this.ruleManager.setFilter(
|
||||
this.plugin.settings.settings.filters.filter.map(f => f.value)
|
||||
);
|
||||
} else {
|
||||
const rulesV2 = Array.isArray(this.plugin.settings.settings.rulesV2)
|
||||
? this.plugin.settings.settings.rulesV2
|
||||
: [];
|
||||
this.ruleManagerV2.setRules(rulesV2);
|
||||
this.ruleManagerV2.setFilter(
|
||||
this.plugin.settings.settings.filters.filter.map(f => f.value)
|
||||
);
|
||||
}
|
||||
const rulesV2 = Array.isArray(this.plugin.settings.settings.rulesV2)
|
||||
? this.plugin.settings.settings.rulesV2
|
||||
: [];
|
||||
this.ruleManagerV2.setRules(rulesV2);
|
||||
this.ruleManagerV2.setFilter(
|
||||
this.plugin.settings.settings.filters.filter.map(f => f.value)
|
||||
);
|
||||
|
||||
// Re-hash the rules config; invalidates the cache when anything changed
|
||||
this.plugin.syncRuleCacheHash();
|
||||
}
|
||||
|
||||
|
|
@ -96,169 +88,201 @@ export class NoteMoverShortcut {
|
|||
defaultFolder: string,
|
||||
skipFilter = false
|
||||
): Promise<boolean> {
|
||||
const { app } = this.plugin;
|
||||
const cache = this.plugin.ruleCache;
|
||||
const cacheEnabled =
|
||||
this.plugin.settings.settings.enableRuleEvaluationCache === true;
|
||||
const originalPath = file.path;
|
||||
const mtime = file.stat?.mtime ?? 0;
|
||||
return this.plugin.performanceTrace.recordAsync(
|
||||
'NoteMoverShortcut.moveFileBasedOnTags',
|
||||
async () => {
|
||||
const { app } = this.plugin;
|
||||
const cache = this.plugin.ruleCache;
|
||||
const cacheEnabled =
|
||||
this.plugin.settings.settings.enableRuleEvaluationCache !== false;
|
||||
const originalPath = file.path;
|
||||
const mtime = file.stat?.mtime ?? 0;
|
||||
|
||||
// Fast path: if the cache is enabled and already knows the result for
|
||||
// this file, skip the expensive rule evaluation entirely.
|
||||
if (cacheEnabled && !cache.needsEvaluation(originalPath, mtime)) {
|
||||
const cachedDest = cache.getCachedDestination(originalPath);
|
||||
if (cachedDest === null || cachedDest === undefined) {
|
||||
return false; // No rule matched last time and nothing changed
|
||||
}
|
||||
const cachedPath = combinePath(cachedDest, file.name);
|
||||
if (originalPath === cachedPath) {
|
||||
return false; // Already in correct folder
|
||||
}
|
||||
}
|
||||
// Fast path: if the cache is enabled and already knows the result for
|
||||
// this file, skip the expensive rule evaluation entirely.
|
||||
if (cacheEnabled && !cache.needsEvaluation(originalPath, mtime)) {
|
||||
const cachedDest = cache.getCachedDestination(originalPath);
|
||||
if (cachedDest === null || cachedDest === undefined) {
|
||||
return false; // No rule matched last time and nothing changed
|
||||
}
|
||||
const cachedPath = combinePath(cachedDest, file.name);
|
||||
if (originalPath === cachedPath) {
|
||||
return false; // Already in correct folder
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let targetFolder = defaultFolder;
|
||||
try {
|
||||
let targetFolder = defaultFolder;
|
||||
|
||||
let result: string | null = null;
|
||||
let result: string | null = null;
|
||||
|
||||
if (this.plugin.settings.settings.enableLegacyRules === true) {
|
||||
result = await this.ruleManager.moveFileBasedOnTags(file, skipFilter);
|
||||
} else {
|
||||
result = await this.ruleManagerV2.moveFileBasedOnTags(file, skipFilter);
|
||||
}
|
||||
result = await this.ruleManagerV2.moveFileBasedOnTags(
|
||||
file,
|
||||
skipFilter
|
||||
);
|
||||
|
||||
if (cacheEnabled) {
|
||||
cache.store(originalPath, mtime, result);
|
||||
}
|
||||
if (cacheEnabled) {
|
||||
cache.store(originalPath, mtime, result);
|
||||
}
|
||||
|
||||
if (result === null) {
|
||||
return false;
|
||||
}
|
||||
targetFolder = result;
|
||||
if (result === null) {
|
||||
return false;
|
||||
}
|
||||
targetFolder = result;
|
||||
|
||||
const newPath = combinePath(targetFolder, file.name);
|
||||
const newPath = combinePath(targetFolder, file.name);
|
||||
|
||||
if (originalPath === newPath) {
|
||||
return false;
|
||||
}
|
||||
if (originalPath === newPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!(await ensureFolderExists(app, targetFolder))) {
|
||||
throw createError(`Failed to create target folder: ${targetFolder}`);
|
||||
}
|
||||
if (!(await ensureFolderExists(app, targetFolder))) {
|
||||
throw createError(
|
||||
`Failed to create target folder: ${targetFolder}`
|
||||
);
|
||||
}
|
||||
|
||||
this.plugin.historyManager.markPluginMoveStart();
|
||||
this.plugin.historyManager.markPluginMoveStart();
|
||||
|
||||
try {
|
||||
await app.fileManager.renameFile(file, newPath);
|
||||
try {
|
||||
await app.fileManager.renameFile(file, newPath);
|
||||
|
||||
this.plugin.historyManager.addEntry({
|
||||
sourcePath: originalPath,
|
||||
destinationPath: newPath,
|
||||
fileName: file.name,
|
||||
});
|
||||
} finally {
|
||||
this.plugin.historyManager.markPluginMoveEnd();
|
||||
}
|
||||
this.plugin.historyManager.addEntry({
|
||||
sourcePath: originalPath,
|
||||
destinationPath: newPath,
|
||||
fileName: file.name,
|
||||
});
|
||||
} finally {
|
||||
this.plugin.historyManager.markPluginMoveEnd();
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, `Error moving file '${file.path}'`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, `Error moving file '${file.path}'`);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ path: file.path, skipFilter }
|
||||
);
|
||||
}
|
||||
|
||||
private async moveAllFiles(options: {
|
||||
createFolders: boolean;
|
||||
showNotifications: boolean;
|
||||
operationType: OperationType;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<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 (options.showNotifications && options.operationType === 'bulk') {
|
||||
NoticeManager.info('No files found in vault');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Start bulk operation
|
||||
const bulkOperationId = this.plugin.historyManager.startBulkOperation(
|
||||
options.operationType
|
||||
);
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
try {
|
||||
const wasMoved = await this.moveFileBasedOnTags(files[i], '/');
|
||||
if (wasMoved) {
|
||||
successCount++;
|
||||
if (files.length === 0) {
|
||||
if (options.showNotifications && options.operationType === 'bulk') {
|
||||
NoticeManager.info('No markdown files found in vault');
|
||||
}
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
const errorMessage =
|
||||
options.operationType === 'periodic'
|
||||
? `Error moving file '${files[i].path}' during periodic movement`
|
||||
: `Error moving file '${files[i].path}'`;
|
||||
handleError(error, errorMessage, false);
|
||||
return;
|
||||
}
|
||||
// Yield to the UI thread periodically to prevent freezing
|
||||
if ((i + 1) % BULK_CHUNK_SIZE === 0 && i + 1 < files.length) {
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
// Show completion notice
|
||||
if (successCount > 0 && options.showNotifications) {
|
||||
if (options.operationType === 'bulk') {
|
||||
const totalFiles = successCount + errorCount;
|
||||
const successMessage =
|
||||
errorCount > 0
|
||||
? `Moved ${successCount}/${totalFiles} files. ${errorCount} files had errors.`
|
||||
: `Successfully moved ${successCount} files`;
|
||||
// Start bulk operation
|
||||
const bulkOperationId = this.plugin.historyManager.startBulkOperation(
|
||||
options.operationType
|
||||
);
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
NoticeManager.showWithUndo(
|
||||
'info',
|
||||
`Bulk Operation: ${successMessage}`,
|
||||
async () => {
|
||||
const success =
|
||||
await this.plugin.historyManager.undoBulkOperation(
|
||||
bulkOperationId
|
||||
);
|
||||
if (success) {
|
||||
NoticeManager.success(
|
||||
`Bulk operation undone: ${successCount} files moved back`,
|
||||
{ duration: NOTIFICATION_CONSTANTS.DURATION_OVERRIDE }
|
||||
);
|
||||
} else {
|
||||
NoticeManager.warning(
|
||||
`Could not undo all moves. Check individual files in history.`,
|
||||
{ duration: NOTIFICATION_CONSTANTS.DURATION_OVERRIDE }
|
||||
);
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
if (options.signal?.aborted) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const wasMoved = await this.moveFileBasedOnTags(files[i], '/');
|
||||
if (wasMoved) {
|
||||
successCount++;
|
||||
}
|
||||
},
|
||||
SETTINGS_CONSTANTS.UI_TEXTS.UNDO_ALL
|
||||
);
|
||||
} else {
|
||||
NoticeManager.info(
|
||||
`Periodic movement: Successfully moved ${successCount} files`
|
||||
);
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
const errorMessage =
|
||||
options.operationType === 'periodic'
|
||||
? `Error moving file '${files[i].path}' during periodic movement`
|
||||
: `Error moving file '${files[i].path}'`;
|
||||
handleError(error, errorMessage, false);
|
||||
}
|
||||
// Yield to the UI thread periodically to prevent freezing
|
||||
if ((i + 1) % BULK_CHUNK_SIZE === 0 && i + 1 < files.length) {
|
||||
await new Promise<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 {
|
||||
// End bulk operation
|
||||
this.plugin.historyManager.endBulkOperation();
|
||||
}
|
||||
},
|
||||
{ operationType: options.operationType }
|
||||
);
|
||||
}
|
||||
|
||||
async moveAllFilesInVault() {
|
||||
async moveAllFilesInVault(opts?: { signal?: AbortSignal }) {
|
||||
await this.moveAllFiles({
|
||||
createFolders: true,
|
||||
showNotifications: true,
|
||||
operationType: 'bulk',
|
||||
signal: opts?.signal,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -327,48 +351,40 @@ export class NoteMoverShortcut {
|
|||
* Generates a preview of moves for all files in the vault
|
||||
*/
|
||||
async generateVaultMovePreview(): Promise<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.ruleManager.generateMovePreview(
|
||||
files,
|
||||
true, // Rules are always enabled
|
||||
true // Filter is always enabled
|
||||
);
|
||||
} else {
|
||||
return await this.ruleManagerV2.generateMovePreview(
|
||||
files,
|
||||
true, // Rules are always enabled
|
||||
true // Filter is always enabled
|
||||
);
|
||||
}
|
||||
return await this.ruleManagerV2.generateMovePreview(files, true, true);
|
||||
},
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a preview for the currently active note
|
||||
*/
|
||||
async generateActiveNotePreview(): Promise<MovePreview | null> {
|
||||
const { app } = this.plugin;
|
||||
const activeFile = app.workspace.getActiveFile();
|
||||
return this.plugin.performanceTrace.recordAsync(
|
||||
'NoteMoverShortcut.generateActiveNotePreview',
|
||||
async () => {
|
||||
const { app } = this.plugin;
|
||||
const activeFile = app.workspace.getActiveFile();
|
||||
|
||||
if (!activeFile) {
|
||||
return null;
|
||||
}
|
||||
if (!activeFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.plugin.settings.settings.enableLegacyRules === true) {
|
||||
return await this.ruleManager.generateMovePreview(
|
||||
[activeFile],
|
||||
true,
|
||||
true
|
||||
);
|
||||
} else {
|
||||
return await this.ruleManagerV2.generateMovePreview(
|
||||
[activeFile],
|
||||
true,
|
||||
true
|
||||
);
|
||||
}
|
||||
return await this.ruleManagerV2.generateMovePreview(
|
||||
[activeFile],
|
||||
true,
|
||||
true
|
||||
);
|
||||
},
|
||||
{}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { Rule } from '../types/Rule';
|
||||
import { RuleV2 } from '../types/RuleV2';
|
||||
import { Filter } from '../types/PluginData';
|
||||
|
||||
|
|
@ -24,18 +23,8 @@ export class RuleEvaluationCache {
|
|||
* Recompute the rules hash from the current configuration.
|
||||
* Must be called whenever rules or filters change.
|
||||
*/
|
||||
public updateRulesHash(
|
||||
rules: Rule[],
|
||||
rulesV2: RuleV2[],
|
||||
filters: Filter[],
|
||||
enableLegacyRules: boolean
|
||||
): void {
|
||||
const newHash = this.computeRulesHash(
|
||||
rules,
|
||||
rulesV2,
|
||||
filters,
|
||||
enableLegacyRules
|
||||
);
|
||||
public updateRulesHash(rulesV2: RuleV2[], filters: Filter[]): void {
|
||||
const newHash = this.computeRulesHash(rulesV2, filters);
|
||||
if (newHash !== this.currentRulesHash) {
|
||||
this.currentRulesHash = newHash;
|
||||
this.invalidateAll();
|
||||
|
|
@ -103,14 +92,9 @@ export class RuleEvaluationCache {
|
|||
this.dirtyFiles.clear();
|
||||
}
|
||||
|
||||
private computeRulesHash(
|
||||
rules: Rule[],
|
||||
rulesV2: RuleV2[],
|
||||
filters: Filter[],
|
||||
enableLegacyRules: boolean
|
||||
): string {
|
||||
private computeRulesHash(rulesV2: RuleV2[], filters: Filter[]): string {
|
||||
try {
|
||||
return JSON.stringify({ enableLegacyRules, rules, rulesV2, filters });
|
||||
return JSON.stringify({ rulesV2, filters });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { MetadataExtractor } from './MetadataExtractor';
|
||||
import { RuleMatcherV2 } from './RuleMatcherV2';
|
||||
import { RuleMatcher } from './RuleMatcher';
|
||||
import { BlacklistFilterEngine } from '../domain/filters/blacklist-filter-engine';
|
||||
import { filtersNeedContent } from '../domain/filters/filter-needs-content';
|
||||
import {
|
||||
DestinationTemplateContext,
|
||||
renderDestinationTemplate,
|
||||
} from '../utils/DestinationTemplate';
|
||||
} from '../domain/templates/DestinationTemplate';
|
||||
import type { PerformanceTraceRecorder } from '../infrastructure/debug/performance-trace';
|
||||
|
||||
/**
|
||||
* RuleManager for Rule V2 system
|
||||
|
|
@ -25,15 +27,15 @@ export class RuleManagerV2 {
|
|||
private filter: string[] = []; // Filter remain V1-compatible
|
||||
private metadataExtractor: MetadataExtractor;
|
||||
private ruleMatcherV2: RuleMatcherV2;
|
||||
private ruleMatcherV1: RuleMatcher; // For filter evaluation
|
||||
private readonly filterEngine = new BlacklistFilterEngine();
|
||||
|
||||
constructor(
|
||||
private app: App,
|
||||
private defaultFolder: string
|
||||
private defaultFolder: string,
|
||||
private readonly perf: PerformanceTraceRecorder
|
||||
) {
|
||||
this.metadataExtractor = new MetadataExtractor(app);
|
||||
this.ruleMatcherV2 = new RuleMatcherV2(this.metadataExtractor);
|
||||
this.ruleMatcherV1 = new RuleMatcher(this.metadataExtractor);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -43,6 +45,7 @@ export class RuleManagerV2 {
|
|||
*/
|
||||
public setRules(rules: RuleV2[]): void {
|
||||
this.rules = rules;
|
||||
this.ruleMatcherV2.warmRegexCacheFromRules(rules);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -55,6 +58,10 @@ export class RuleManagerV2 {
|
|||
this.filter = filter;
|
||||
}
|
||||
|
||||
private filterNeedsContent(): boolean {
|
||||
return filtersNeedContent(this.filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a single file against rules and filters
|
||||
*
|
||||
|
|
@ -69,49 +76,56 @@ export class RuleManagerV2 {
|
|||
file: TFile,
|
||||
skipFilter = false
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
// Extract V2 metadata
|
||||
const metadata = await this.metadataExtractor.extractFileMetadataV2(file);
|
||||
return this.perf.recordAsync(
|
||||
'RuleManagerV2.moveFileBasedOnTags',
|
||||
async () => {
|
||||
try {
|
||||
const metadata = await this.metadataExtractor.extractFileMetadataV2(
|
||||
file,
|
||||
this.filterNeedsContent()
|
||||
);
|
||||
|
||||
// Check if file should be skipped based on filter (using V1 logic)
|
||||
if (
|
||||
!skipFilter &&
|
||||
!this.ruleMatcherV1.evaluateFilter(metadata, this.filter)
|
||||
) {
|
||||
return null; // File is blocked by filter
|
||||
}
|
||||
if (
|
||||
!skipFilter &&
|
||||
!this.filterEngine.evaluateFilter(metadata, this.filter)
|
||||
) {
|
||||
return null; // File is blocked by filter
|
||||
}
|
||||
|
||||
// Find matching rule using V2 logic
|
||||
const matchingRule = this.ruleMatcherV2.findMatchingRule(
|
||||
metadata,
|
||||
this.rules
|
||||
);
|
||||
// Find matching rule using V2 logic
|
||||
const matchingRule = this.ruleMatcherV2.findMatchingRule(
|
||||
metadata,
|
||||
this.rules
|
||||
);
|
||||
|
||||
if (matchingRule) {
|
||||
const targetFolder = this.resolveDestinationTemplate(
|
||||
matchingRule.destination,
|
||||
metadata
|
||||
);
|
||||
if (matchingRule) {
|
||||
const targetFolder = this.resolveDestinationTemplate(
|
||||
matchingRule.destination,
|
||||
metadata
|
||||
);
|
||||
|
||||
// If the destination template resolves to an empty value, treat it as
|
||||
// "no valid move target" so the file is not moved to the vault root.
|
||||
if (!targetFolder || targetFolder.trim() === '') {
|
||||
// If the destination template resolves to an empty value, treat it as
|
||||
// "no valid move target" so the file is not moved to the vault root.
|
||||
if (!targetFolder || targetFolder.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return targetFolder;
|
||||
}
|
||||
|
||||
// No rule matched - skip the file since only notes with rules should be moved
|
||||
return null;
|
||||
} catch (error) {
|
||||
handleError(
|
||||
error,
|
||||
`Error processing V2 rules for file '${file.path}'`,
|
||||
false
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return targetFolder;
|
||||
}
|
||||
|
||||
// No rule matched - skip the file since only notes with rules should be moved
|
||||
return null;
|
||||
} catch (error) {
|
||||
handleError(
|
||||
error,
|
||||
`Error processing V2 rules for file '${file.path}'`,
|
||||
false
|
||||
);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
{ path: file.path, skipFilter }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -126,13 +140,15 @@ export class RuleManagerV2 {
|
|||
skipFilter = false
|
||||
): Promise<PreviewEntry> {
|
||||
try {
|
||||
// Extract V2 metadata
|
||||
const metadata = await this.metadataExtractor.extractFileMetadataV2(file);
|
||||
const metadata = await this.metadataExtractor.extractFileMetadataV2(
|
||||
file,
|
||||
this.filterNeedsContent()
|
||||
);
|
||||
const { tags, fileName, filePath } = metadata;
|
||||
|
||||
// Check filters first (using V1 logic)
|
||||
if (!skipFilter) {
|
||||
const filterDetails = this.ruleMatcherV1.getFilterMatchDetails(
|
||||
const filterDetails = this.filterEngine.getFilterMatchDetails(
|
||||
metadata,
|
||||
this.filter
|
||||
);
|
||||
|
|
@ -241,24 +257,33 @@ export class RuleManagerV2 {
|
|||
enableRules: boolean,
|
||||
enableFilter: boolean
|
||||
): Promise<MovePreview> {
|
||||
const successfulMoves: PreviewEntry[] = [];
|
||||
return this.perf.recordAsync(
|
||||
'RuleManagerV2.generateMovePreview',
|
||||
async () => {
|
||||
const successfulMoves: PreviewEntry[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const preview = await this.generatePreviewForFile(file, !enableFilter);
|
||||
for (const file of files) {
|
||||
const preview = await this.generatePreviewForFile(
|
||||
file,
|
||||
!enableFilter
|
||||
);
|
||||
|
||||
if (preview.willBeMoved) {
|
||||
successfulMoves.push(preview);
|
||||
}
|
||||
// Only include files that will be moved - blocked files are ignored
|
||||
}
|
||||
if (preview.willBeMoved) {
|
||||
successfulMoves.push(preview);
|
||||
}
|
||||
// Only include files that will be moved - blocked files are ignored
|
||||
}
|
||||
|
||||
return {
|
||||
successfulMoves,
|
||||
totalFiles: files.length,
|
||||
settings: {
|
||||
isFilterWhitelist: false, // Always blacklist mode
|
||||
return {
|
||||
successfulMoves,
|
||||
totalFiles: files.length,
|
||||
settings: {
|
||||
isFilterWhitelist: false, // Always blacklist mode
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
{ fileCount: files.length, enableRules, enableFilter }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
RuleV2,
|
||||
Trigger,
|
||||
AggregationType,
|
||||
TextOperator,
|
||||
ListOperator,
|
||||
DateOperator,
|
||||
BasePropertyOperator,
|
||||
TextPropertyOperator,
|
||||
NumberPropertyOperator,
|
||||
ListPropertyOperator,
|
||||
DatePropertyOperator,
|
||||
CheckboxPropertyOperator,
|
||||
} from '../types/RuleV2';
|
||||
import { MetadataExtractor } from './MetadataExtractor';
|
||||
import type { FileMetadata } from '../types/Common';
|
||||
import type { RuleV2, Trigger } from '../types/RuleV2';
|
||||
import type { MetadataExtractor } from './MetadataExtractor';
|
||||
import { RuleMatcherV2Engine } from '../domain/rules/v2/RuleMatcherV2Engine';
|
||||
|
||||
/**
|
||||
* Handles all rule and filter matching logic for RuleV2 system
|
||||
*
|
||||
* Provides centralized matching for all CriteriaTypes and Operators with
|
||||
* type-safe evaluation and aggregation support.
|
||||
*
|
||||
* @since 0.5.0
|
||||
* Rule V2 matcher — thin adapter over {@link RuleMatcherV2Engine} (domain).
|
||||
*/
|
||||
export class RuleMatcherV2 {
|
||||
private metadataExtractor: MetadataExtractor;
|
||||
private regexCache: Map<string, RegExp>;
|
||||
private readonly engine = new RuleMatcherV2Engine();
|
||||
|
||||
constructor(metadataExtractor: MetadataExtractor) {
|
||||
this.metadataExtractor = metadataExtractor;
|
||||
this.regexCache = new Map();
|
||||
}
|
||||
/** @param _metadataExtractor unused; kept for call-site compatibility. */
|
||||
constructor(_metadataExtractor?: MetadataExtractor) {}
|
||||
|
||||
/**
|
||||
* Finds the first matching rule based on user-defined order priority
|
||||
*
|
||||
* @param metadata - File metadata object
|
||||
* @param rules - Array of RuleV2 rules to evaluate
|
||||
* @returns First matching rule by user order, null if no match
|
||||
*/
|
||||
public findMatchingRule(
|
||||
metadata: FileMetadata,
|
||||
rules: RuleV2[]
|
||||
): RuleV2 | null {
|
||||
for (const rule of rules) {
|
||||
if (!rule.active) {
|
||||
continue; // Skip inactive rules
|
||||
}
|
||||
|
||||
if (rule.triggers.length === 0) {
|
||||
continue; // Skip rules with no triggers
|
||||
}
|
||||
|
||||
// Evaluate all triggers for this rule
|
||||
const triggerResults: boolean[] = [];
|
||||
for (const trigger of rule.triggers) {
|
||||
const result = this.evaluateTrigger(metadata, trigger);
|
||||
triggerResults.push(result);
|
||||
}
|
||||
|
||||
// Apply aggregation logic
|
||||
if (this.evaluateAggregation(triggerResults, rule.aggregation)) {
|
||||
return rule; // First match wins
|
||||
}
|
||||
}
|
||||
|
||||
return null; // No rule matched
|
||||
return this.engine.findMatchingRule(metadata, rules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates a single trigger against file metadata
|
||||
*
|
||||
* @param metadata - File metadata object
|
||||
* @param trigger - Trigger to evaluate
|
||||
* @returns True if trigger matches metadata
|
||||
*/
|
||||
public evaluateTrigger(metadata: FileMetadata, trigger: Trigger): boolean {
|
||||
switch (trigger.criteriaType) {
|
||||
case 'fileName':
|
||||
return this.evaluateTextOperator(
|
||||
metadata.fileName,
|
||||
trigger.operator as TextOperator,
|
||||
trigger.value
|
||||
);
|
||||
|
||||
case 'folder':
|
||||
// Extract folder from filePath
|
||||
const folder =
|
||||
metadata.filePath.split('/').slice(0, -1).join('/') || '';
|
||||
return this.evaluateTextOperator(
|
||||
folder,
|
||||
trigger.operator as TextOperator,
|
||||
trigger.value
|
||||
);
|
||||
|
||||
case 'extension':
|
||||
const extension = metadata.extension || '';
|
||||
return this.evaluateTextOperator(
|
||||
extension,
|
||||
trigger.operator as TextOperator,
|
||||
trigger.value
|
||||
);
|
||||
|
||||
case 'headings':
|
||||
const headings = metadata.headings || [];
|
||||
return this.evaluateListOperator(
|
||||
headings,
|
||||
trigger.operator as ListOperator,
|
||||
trigger.value
|
||||
);
|
||||
|
||||
case 'tag':
|
||||
return this.evaluateListOperator(
|
||||
metadata.tags,
|
||||
trigger.operator as ListOperator,
|
||||
trigger.value
|
||||
);
|
||||
|
||||
case 'links':
|
||||
const links = metadata.links || [];
|
||||
return this.evaluateListOperator(
|
||||
links,
|
||||
trigger.operator as ListOperator,
|
||||
trigger.value
|
||||
);
|
||||
|
||||
case 'embeds':
|
||||
const embeds = metadata.embeds || [];
|
||||
return this.evaluateListOperator(
|
||||
embeds,
|
||||
trigger.operator as ListOperator,
|
||||
trigger.value
|
||||
);
|
||||
|
||||
case 'created_at':
|
||||
return this.evaluateDateOperator(
|
||||
metadata.createdAt,
|
||||
trigger.operator as DateOperator,
|
||||
trigger.value
|
||||
);
|
||||
|
||||
case 'modified_at':
|
||||
return this.evaluateDateOperator(
|
||||
metadata.updatedAt,
|
||||
trigger.operator as DateOperator,
|
||||
trigger.value
|
||||
);
|
||||
|
||||
case 'properties':
|
||||
return this.evaluatePropertyOperator(metadata.properties, trigger);
|
||||
|
||||
default:
|
||||
return false; // Unknown criteria type
|
||||
}
|
||||
return this.engine.evaluateTrigger(metadata, trigger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates aggregation logic for trigger results
|
||||
*
|
||||
* @param results - Array of boolean results from triggers
|
||||
* @param aggregation - Aggregation type (all, any, none)
|
||||
* @returns True if aggregation condition is met
|
||||
*/
|
||||
private evaluateAggregation(
|
||||
results: boolean[],
|
||||
aggregation: AggregationType
|
||||
): boolean {
|
||||
if (results.length === 0) {
|
||||
return false; // No triggers to evaluate
|
||||
}
|
||||
|
||||
switch (aggregation) {
|
||||
case 'all':
|
||||
return results.every(result => result === true);
|
||||
|
||||
case 'any':
|
||||
return results.some(result => result === true);
|
||||
|
||||
case 'none':
|
||||
return results.every(result => result === false);
|
||||
|
||||
default:
|
||||
return false; // Unknown aggregation type
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates text-based operators (fileName, folder, extension, headings)
|
||||
*
|
||||
* @param value - Actual value to test
|
||||
* @param operator - Text operator to apply
|
||||
* @param expected - Expected value/pattern
|
||||
* @returns True if operator condition is met
|
||||
*/
|
||||
private evaluateTextOperator(
|
||||
value: string,
|
||||
operator: TextOperator,
|
||||
expected: string
|
||||
): boolean {
|
||||
const valueLower = value.toLowerCase();
|
||||
const expectedLower = expected.toLowerCase();
|
||||
|
||||
switch (operator) {
|
||||
case 'is':
|
||||
return valueLower === expectedLower;
|
||||
|
||||
case 'is not':
|
||||
return valueLower !== expectedLower;
|
||||
|
||||
case 'contains':
|
||||
return valueLower.includes(expectedLower);
|
||||
|
||||
case 'does not contain':
|
||||
return !valueLower.includes(expectedLower);
|
||||
|
||||
case 'starts with':
|
||||
return valueLower.startsWith(expectedLower);
|
||||
|
||||
case 'does not starts with':
|
||||
return !valueLower.startsWith(expectedLower);
|
||||
|
||||
case 'ends with':
|
||||
return valueLower.endsWith(expectedLower);
|
||||
|
||||
case 'does not ends with':
|
||||
return !valueLower.endsWith(expectedLower);
|
||||
|
||||
case 'match regex':
|
||||
const regex = this.getRegex(expected);
|
||||
return regex !== null ? regex.test(value) : false;
|
||||
|
||||
case 'does not match regex':
|
||||
const regexNot = this.getRegex(expected);
|
||||
return regexNot !== null ? !regexNot.test(value) : true;
|
||||
|
||||
default:
|
||||
return false; // Unknown operator
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates list-based operators (tag, links, embeds)
|
||||
*
|
||||
* @param items - Array of items to test
|
||||
* @param operator - List operator to apply
|
||||
* @param expected - Expected value/pattern
|
||||
* @returns True if operator condition is met
|
||||
*/
|
||||
private evaluateListOperator(
|
||||
items: string[],
|
||||
operator: ListOperator,
|
||||
expected: string
|
||||
): boolean {
|
||||
const expectedLower = expected.toLowerCase();
|
||||
|
||||
switch (operator) {
|
||||
case 'includes item':
|
||||
return items.some(item => item.toLowerCase() === expectedLower);
|
||||
|
||||
case 'does not include item':
|
||||
return !items.some(item => item.toLowerCase() === expectedLower);
|
||||
|
||||
case 'all are':
|
||||
return (
|
||||
items.length > 0 &&
|
||||
items.every(item => item.toLowerCase() === expectedLower)
|
||||
);
|
||||
|
||||
case 'all start with':
|
||||
return (
|
||||
items.length > 0 &&
|
||||
items.every(item => item.toLowerCase().startsWith(expectedLower))
|
||||
);
|
||||
|
||||
case 'all end with':
|
||||
return (
|
||||
items.length > 0 &&
|
||||
items.every(item => item.toLowerCase().endsWith(expectedLower))
|
||||
);
|
||||
|
||||
case 'all match regex':
|
||||
const regexAll = this.getRegex(expected);
|
||||
return (
|
||||
regexAll !== null &&
|
||||
items.length > 0 &&
|
||||
items.every(item => regexAll.test(item))
|
||||
);
|
||||
|
||||
case 'any contain':
|
||||
return items.some(item => item.toLowerCase().includes(expectedLower));
|
||||
|
||||
case 'any end with':
|
||||
return items.some(item => item.toLowerCase().endsWith(expectedLower));
|
||||
|
||||
case 'any match regex':
|
||||
const regexAny = this.getRegex(expected);
|
||||
return regexAny !== null
|
||||
? items.some(item => regexAny.test(item))
|
||||
: false;
|
||||
|
||||
case 'none contain':
|
||||
return !items.some(item => item.toLowerCase().includes(expectedLower));
|
||||
|
||||
case 'none start with':
|
||||
return !items.some(item =>
|
||||
item.toLowerCase().startsWith(expectedLower)
|
||||
);
|
||||
|
||||
case 'none end with':
|
||||
return !items.some(item => item.toLowerCase().endsWith(expectedLower));
|
||||
|
||||
case 'count is':
|
||||
const count = this.parseNumericValue(expected);
|
||||
return count !== null && items.length === count;
|
||||
|
||||
case 'count is not':
|
||||
const countNot = this.parseNumericValue(expected);
|
||||
return countNot !== null && items.length !== countNot;
|
||||
|
||||
case 'count is less than':
|
||||
const countLess = this.parseNumericValue(expected);
|
||||
return countLess !== null && items.length < countLess;
|
||||
|
||||
case 'count is more than':
|
||||
const countMore = this.parseNumericValue(expected);
|
||||
return countMore !== null && items.length > countMore;
|
||||
|
||||
default:
|
||||
return false; // Unknown operator
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates date-based operators (created_at, modified_at)
|
||||
*
|
||||
* @param date - Date to test (can be null)
|
||||
* @param operator - Date operator to apply
|
||||
* @param expected - Expected value/pattern
|
||||
* @returns True if operator condition is met
|
||||
*/
|
||||
private evaluateDateOperator(
|
||||
date: Date | null,
|
||||
operator: DateOperator,
|
||||
expected: string
|
||||
): boolean {
|
||||
if (!date) {
|
||||
return false; // No date available
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const expectedDate = this.parseDate(expected);
|
||||
|
||||
switch (operator) {
|
||||
case 'is':
|
||||
return expectedDate ? date.getTime() === expectedDate.getTime() : false;
|
||||
|
||||
case 'is before':
|
||||
return expectedDate ? date < expectedDate : false;
|
||||
|
||||
case 'is after':
|
||||
return expectedDate ? date > expectedDate : false;
|
||||
|
||||
case 'time is before':
|
||||
return expectedDate ? date.getTime() < expectedDate.getTime() : false;
|
||||
|
||||
case 'time is after':
|
||||
return expectedDate ? date.getTime() > expectedDate.getTime() : false;
|
||||
|
||||
case 'time is before now':
|
||||
return date < now;
|
||||
|
||||
case 'time is after now':
|
||||
return date > now;
|
||||
|
||||
case 'date is':
|
||||
return expectedDate ? this.isSameDate(date, expectedDate) : false;
|
||||
|
||||
case 'date is not':
|
||||
return expectedDate ? !this.isSameDate(date, expectedDate) : true;
|
||||
|
||||
case 'date is before':
|
||||
return expectedDate ? this.isDateBefore(date, expectedDate) : false;
|
||||
|
||||
case 'date is after':
|
||||
return expectedDate ? this.isDateAfter(date, expectedDate) : false;
|
||||
|
||||
case 'date is today':
|
||||
return this.isSameDate(date, now);
|
||||
|
||||
case 'date is not today':
|
||||
return !this.isSameDate(date, now);
|
||||
|
||||
case 'is under X days ago':
|
||||
const daysUnder = this.parseNumericValue(expected);
|
||||
return daysUnder !== null
|
||||
? this.isDaysAgo(date, daysUnder, 'under')
|
||||
: false;
|
||||
|
||||
case 'is over X days ago':
|
||||
const daysOver = this.parseNumericValue(expected);
|
||||
return daysOver !== null
|
||||
? this.isDaysAgo(date, daysOver, 'over')
|
||||
: false;
|
||||
|
||||
case 'day of week is':
|
||||
return this.isDayOfWeek(date, expected);
|
||||
|
||||
case 'day of week is not':
|
||||
return !this.isDayOfWeek(date, expected);
|
||||
|
||||
case 'day of week is before':
|
||||
return this.isDayOfWeekBefore(date, expected);
|
||||
|
||||
case 'day of week is after':
|
||||
return this.isDayOfWeekAfter(date, expected);
|
||||
|
||||
case 'day of month is':
|
||||
const dayOfMonth = this.parseNumericValue(expected);
|
||||
return dayOfMonth !== null && date.getDate() === dayOfMonth;
|
||||
|
||||
case 'day of month is not':
|
||||
const dayOfMonthNot = this.parseNumericValue(expected);
|
||||
return dayOfMonthNot !== null && date.getDate() !== dayOfMonthNot;
|
||||
|
||||
case 'day of month is before':
|
||||
const dayOfMonthBefore = this.parseNumericValue(expected);
|
||||
return dayOfMonthBefore !== null && date.getDate() < dayOfMonthBefore;
|
||||
|
||||
case 'day of month is after':
|
||||
const dayOfMonthAfter = this.parseNumericValue(expected);
|
||||
return dayOfMonthAfter !== null && date.getDate() > dayOfMonthAfter;
|
||||
|
||||
case 'month is':
|
||||
const month = this.parseNumericValue(expected);
|
||||
return month !== null && date.getMonth() + 1 === month; // getMonth() is 0-based
|
||||
|
||||
case 'month is not':
|
||||
const monthNot = this.parseNumericValue(expected);
|
||||
return monthNot !== null && date.getMonth() + 1 !== monthNot;
|
||||
|
||||
case 'month is before':
|
||||
const monthBefore = this.parseNumericValue(expected);
|
||||
return monthBefore !== null && date.getMonth() + 1 < monthBefore;
|
||||
|
||||
case 'month is after':
|
||||
const monthAfter = this.parseNumericValue(expected);
|
||||
return monthAfter !== null && date.getMonth() + 1 > monthAfter;
|
||||
|
||||
case 'year is':
|
||||
const year = this.parseNumericValue(expected);
|
||||
return year !== null && date.getFullYear() === year;
|
||||
|
||||
case 'year is not':
|
||||
const yearNot = this.parseNumericValue(expected);
|
||||
return yearNot !== null && date.getFullYear() !== yearNot;
|
||||
|
||||
case 'year is before':
|
||||
const yearBefore = this.parseNumericValue(expected);
|
||||
return yearBefore !== null && date.getFullYear() < yearBefore;
|
||||
|
||||
case 'year is after':
|
||||
const yearAfter = this.parseNumericValue(expected);
|
||||
return yearAfter !== null && date.getFullYear() > yearAfter;
|
||||
|
||||
default:
|
||||
return false; // Unknown operator
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates property-based operators with runtime type checking
|
||||
*
|
||||
* @param properties - Properties object from metadata
|
||||
* @param trigger - Trigger with property information
|
||||
* @returns True if property condition is met
|
||||
*/
|
||||
private evaluatePropertyOperator(
|
||||
properties: Record<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;
|
||||
public warmRegexCacheFromRules(rules: RuleV2[]): void {
|
||||
this.engine.warmRegexCacheFromRules(rules);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { Rule } from '../types/Rule';
|
||||
import {
|
||||
RuleV2,
|
||||
Trigger,
|
||||
|
|
@ -6,6 +5,12 @@ import {
|
|||
Operator,
|
||||
TextOperator,
|
||||
} from '../types/RuleV2';
|
||||
|
||||
/** Persisted Rule V1 row (migration input only; never part of the public settings model after load). */
|
||||
export interface LegacyRuleV1 {
|
||||
criteria: string;
|
||||
path: string;
|
||||
}
|
||||
import {
|
||||
getDefaultOperatorForCriteriaType,
|
||||
getDefaultOperatorForPropertyType,
|
||||
|
|
@ -23,7 +28,7 @@ export class RuleMigrationService {
|
|||
* @param rulesV1 - Array of V1 rules to migrate
|
||||
* @returns Array of RuleV2 objects
|
||||
*/
|
||||
public static migrateRules(rulesV1: Rule[]): RuleV2[] {
|
||||
public static migrateRules(rulesV1: LegacyRuleV1[]): RuleV2[] {
|
||||
if (!Array.isArray(rulesV1) || rulesV1.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -40,7 +45,10 @@ export class RuleMigrationService {
|
|||
* @param index - Rule index for naming
|
||||
* @returns RuleV2 object or null if migration fails
|
||||
*/
|
||||
private static migrateRule(ruleV1: Rule, index: number): RuleV2 | null {
|
||||
private static migrateRule(
|
||||
ruleV1: LegacyRuleV1,
|
||||
index: number
|
||||
): RuleV2 | null {
|
||||
if (!ruleV1 || !ruleV1.criteria || !ruleV1.path) {
|
||||
console.warn(
|
||||
`[RuleMigration] Rule ${index + 1}: Skipping invalid rule (missing criteria or path)`
|
||||
|
|
@ -316,7 +324,10 @@ export class RuleMigrationService {
|
|||
* @param rulesV2 - V2 rules array
|
||||
* @returns true if migration should be performed
|
||||
*/
|
||||
public static shouldMigrate(rulesV1: Rule[], rulesV2?: RuleV2[]): boolean {
|
||||
public static shouldMigrate(
|
||||
rulesV1: LegacyRuleV1[],
|
||||
rulesV2?: RuleV2[]
|
||||
): boolean {
|
||||
// Migrate if V1 has rules and V2 is empty or undefined
|
||||
return (
|
||||
Array.isArray(rulesV1) &&
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ export class TriggerEventHandler {
|
|||
* @param file - The specific TFile that was modified
|
||||
*/
|
||||
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.
|
||||
// The vault event listener in main.ts does the same, but listener
|
||||
// ordering is not guaranteed.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
import { TFile } from 'obsidian';
|
||||
import NoteMoverShortcutPlugin from 'main';
|
||||
import { UpdateModal } from '../modals/UpdateModal';
|
||||
import { NoticeManager } from '../utils/NoticeManager';
|
||||
import { RuleMatcher } from './RuleMatcher';
|
||||
import { FileMovementService } from './FileMovementService';
|
||||
import { BaseModal } from 'src/modals/BaseModal';
|
||||
import { MetadataExtractor } from './MetadataExtractor';
|
||||
import { ChangelogEntry, CHANGELOG_ENTRIES } from '../generated/changelog';
|
||||
|
||||
export class UpdateManager {
|
||||
|
|
|
|||
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 { Editor, MarkdownView, Notice } from 'obsidian';
|
||||
import type { MarkdownFileInfo } from 'obsidian';
|
||||
import { Editor, MarkdownView } from 'obsidian';
|
||||
import { HistoryModal } from '../modals/HistoryModal';
|
||||
import { UpdateModal } from '../modals/UpdateModal';
|
||||
import { PreviewModal } from '../modals/PreviewModal';
|
||||
|
|
@ -14,7 +15,10 @@ export class CommandHandler {
|
|||
this.plugin.addCommand({
|
||||
id: 'trigger-note-movement',
|
||||
name: 'Move active note to note folder',
|
||||
editorCallback: (editor: Editor, view: MarkdownView) => {
|
||||
editorCallback: (
|
||||
_editor: Editor,
|
||||
_ctx: MarkdownView | MarkdownFileInfo
|
||||
) => {
|
||||
this.plugin.noteMover.moveFocusedNoteToDestination();
|
||||
},
|
||||
});
|
||||
|
|
@ -72,7 +76,10 @@ export class CommandHandler {
|
|||
this.plugin.addCommand({
|
||||
id: 'preview-note-movement',
|
||||
name: 'Preview active note movement',
|
||||
editorCallback: async (editor: Editor, view: MarkdownView) => {
|
||||
editorCallback: async (
|
||||
_editor: Editor,
|
||||
_ctx: MarkdownView | MarkdownFileInfo
|
||||
) => {
|
||||
try {
|
||||
const preview =
|
||||
await this.plugin.noteMover.generateActiveNotePreview();
|
||||
|
|
@ -94,9 +101,12 @@ export class CommandHandler {
|
|||
this.plugin.addCommand({
|
||||
id: 'add-current-file-to-blacklist',
|
||||
name: 'Add current file to blacklist',
|
||||
editorCallback: async (editor: Editor, view: MarkdownView) => {
|
||||
editorCallback: async (
|
||||
_editor: Editor,
|
||||
ctx: MarkdownView | MarkdownFileInfo
|
||||
) => {
|
||||
try {
|
||||
const fileName = view.file?.name;
|
||||
const fileName = ctx.file?.name;
|
||||
if (!fileName) {
|
||||
NoticeManager.warning('No active file to add to blacklist.');
|
||||
return;
|
||||
|
|
|
|||
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 NoteMoverShortcutPlugin from 'main';
|
||||
import { NoticeManager } from '../utils/NoticeManager';
|
||||
|
|
@ -9,6 +9,7 @@ import { BaseModal, BaseModalOptions } from './BaseModal';
|
|||
|
||||
export class PreviewModal extends BaseModal {
|
||||
private movePreview: MovePreview;
|
||||
private actionFooterEl: HTMLElement | null = null;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
|
|
@ -158,6 +159,7 @@ export class PreviewModal extends BaseModal {
|
|||
? 'noteMover-modal-footer noteMover-modal-footer-mobile'
|
||||
: 'noteMover-modal-footer',
|
||||
});
|
||||
this.actionFooterEl = footer;
|
||||
|
||||
const buttonContainer = this.createButtonContainer(footer);
|
||||
|
||||
|
|
@ -217,17 +219,36 @@ export class PreviewModal extends BaseModal {
|
|||
}
|
||||
|
||||
private async executeMoves() {
|
||||
this.close();
|
||||
|
||||
const successfulEntries = this.movePreview.successfulMoves;
|
||||
let movedCount = 0;
|
||||
let errorCount = 0;
|
||||
const abortCtl = new AbortController();
|
||||
|
||||
const bulkOperationId =
|
||||
this.plugin.historyManager.startBulkOperation('bulk');
|
||||
if (this.actionFooterEl) {
|
||||
this.actionFooterEl.empty();
|
||||
const status = this.actionFooterEl.createDiv({
|
||||
cls: 'noteMover-preview-bulk-status',
|
||||
text: `Moving files… (${successfulEntries.length} planned)`,
|
||||
});
|
||||
const row = this.actionFooterEl.createDiv({
|
||||
cls: 'noteMover-preview-bulk-actions',
|
||||
});
|
||||
new Setting(row).addButton(btn =>
|
||||
btn.setButtonText('Stop').onClick(() => {
|
||||
abortCtl.abort();
|
||||
status.setText('Stopping after current file…');
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.plugin.historyManager.startBulkOperation('bulk');
|
||||
|
||||
try {
|
||||
for (const entry of successfulEntries) {
|
||||
for (let i = 0; i < successfulEntries.length; i++) {
|
||||
const entry = successfulEntries[i];
|
||||
if (abortCtl.signal.aborted) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const file = this.app.vault.getAbstractFileByPath(entry.currentPath);
|
||||
if (!(file instanceof TFile) || !entry.targetPath) continue;
|
||||
|
|
@ -258,12 +279,35 @@ export class PreviewModal extends BaseModal {
|
|||
handleError(error, `Error moving file ${entry.fileName}`, false);
|
||||
errorCount++;
|
||||
}
|
||||
if ((i + 1) % 50 === 0 && i + 1 < successfulEntries.length) {
|
||||
await new Promise<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 {
|
||||
this.plugin.historyManager.endBulkOperation();
|
||||
}
|
||||
|
||||
if (errorCount === 0) {
|
||||
this.close();
|
||||
|
||||
if (abortCtl.signal.aborted) {
|
||||
NoticeManager.info(
|
||||
`Bulk move stopped. ${movedCount} file(s) moved, ${errorCount} error(s).`
|
||||
);
|
||||
} else if (errorCount === 0) {
|
||||
NoticeManager.success(`Successfully moved ${movedCount} files!`);
|
||||
} else {
|
||||
NoticeManager.warning(
|
||||
|
|
|
|||
|
|
@ -12,30 +12,22 @@ import { TagSuggest } from '../settings/suggesters/TagSuggest';
|
|||
import { PropertySuggest } from '../settings/suggesters/PropertySuggest';
|
||||
import { PropertyValueSuggest } from '../settings/suggesters/PropertyValueSuggest';
|
||||
import { DragDropManager } from '../utils/DragDropManager';
|
||||
import { MobileUtils } from '../utils/MobileUtils';
|
||||
import {
|
||||
getOperatorsForCriteriaType,
|
||||
getOperatorsForPropertyType,
|
||||
getDefaultOperatorForCriteriaType,
|
||||
getAvailablePropertyTypes,
|
||||
isRegexOperator,
|
||||
operatorRequiresValue,
|
||||
getPropertyTypeFromVault,
|
||||
} from '../utils/OperatorMapping';
|
||||
import {
|
||||
MobileModalInput,
|
||||
MobileModalToggle,
|
||||
MobileModalButton,
|
||||
MobileModalSection,
|
||||
MobileTriggerCard,
|
||||
MobileTriggerCardCallbacks,
|
||||
} from './mobile';
|
||||
import type { PluginVaultIndexCache } from '../infrastructure/cache/plugin-vault-index-cache';
|
||||
|
||||
interface RuleEditorModalOptions {
|
||||
rule: RuleV2;
|
||||
isEditMode: boolean;
|
||||
onSave: (rule: RuleV2) => Promise<void>;
|
||||
onDelete?: () => Promise<void>;
|
||||
vaultIndexCache?: PluginVaultIndexCache;
|
||||
}
|
||||
|
||||
export class RuleEditorModal extends BaseModal {
|
||||
|
|
@ -43,7 +35,6 @@ export class RuleEditorModal extends BaseModal {
|
|||
private workingRule: RuleV2;
|
||||
private dragDropManager: DragDropManager | null = null;
|
||||
private triggersContainer: HTMLElement | null = null;
|
||||
private mobileTriggerCards: MobileTriggerCard[] = [];
|
||||
|
||||
constructor(app: App, options: RuleEditorModalOptions) {
|
||||
super(app, {
|
||||
|
|
@ -59,55 +50,7 @@ export class RuleEditorModal extends BaseModal {
|
|||
|
||||
protected createContent(): void {
|
||||
const { contentEl } = this;
|
||||
|
||||
if (MobileUtils.isMobile()) {
|
||||
this.createMobileContent(contentEl);
|
||||
} else {
|
||||
this.createDesktopContent(contentEl);
|
||||
}
|
||||
}
|
||||
|
||||
private createMobileContent(container: HTMLElement): void {
|
||||
// Name Card
|
||||
const nameInput = new MobileModalInput(
|
||||
container,
|
||||
'Name',
|
||||
undefined,
|
||||
'Enter rule name',
|
||||
this.workingRule.name,
|
||||
async value => {
|
||||
this.workingRule.name = value;
|
||||
}
|
||||
);
|
||||
|
||||
// Active Toggle Card
|
||||
new MobileModalToggle(
|
||||
container,
|
||||
'Active',
|
||||
'Enable or disable this rule',
|
||||
this.workingRule.active,
|
||||
async value => {
|
||||
this.workingRule.active = value;
|
||||
}
|
||||
);
|
||||
|
||||
// Match Conditions Card
|
||||
this.createMobileMatchConditions(container);
|
||||
|
||||
// Destination Card
|
||||
this.createMobileDestination(container);
|
||||
|
||||
// Separator
|
||||
container.createEl('hr', { cls: 'noteMover-rule-editor-separator' });
|
||||
|
||||
// Conditions Section
|
||||
this.createMobileConditionsSection(container);
|
||||
|
||||
// Separator
|
||||
container.createEl('hr', { cls: 'noteMover-rule-editor-separator' });
|
||||
|
||||
// Footer Actions
|
||||
this.createMobileFooterActions(container);
|
||||
this.createDesktopContent(contentEl);
|
||||
}
|
||||
|
||||
private createDesktopContent(container: HTMLElement): void {
|
||||
|
|
@ -164,47 +107,6 @@ export class RuleEditorModal extends BaseModal {
|
|||
}
|
||||
}
|
||||
|
||||
private createMobileMatchConditions(container: HTMLElement): void {
|
||||
const card = container.createDiv({ cls: 'noteMover-mobile-modal-card' });
|
||||
const header = card.createDiv({
|
||||
cls: 'noteMover-mobile-modal-card-header',
|
||||
});
|
||||
header.createDiv({
|
||||
cls: 'noteMover-mobile-modal-card-title',
|
||||
text: 'Match Conditions',
|
||||
});
|
||||
const content = card.createDiv({
|
||||
cls: 'noteMover-mobile-modal-card-content',
|
||||
});
|
||||
|
||||
const buttonContainer = content.createDiv({
|
||||
cls: 'noteMover-rule-aggregation-buttons-mobile',
|
||||
});
|
||||
|
||||
const aggregations: AggregationType[] = ['all', 'any', 'none'];
|
||||
aggregations.forEach(agg => {
|
||||
const button = buttonContainer.createEl('button', {
|
||||
text: agg.charAt(0).toUpperCase() + agg.slice(1),
|
||||
cls: 'noteMover-rule-aggregation-button',
|
||||
});
|
||||
|
||||
if (this.workingRule.aggregation === agg) {
|
||||
button.addClass('is-active');
|
||||
}
|
||||
|
||||
button.onclick = () => {
|
||||
this.workingRule.aggregation = agg;
|
||||
// Update button states
|
||||
buttonContainer
|
||||
.querySelectorAll('.noteMover-rule-aggregation-button')
|
||||
.forEach(btn => {
|
||||
btn.removeClass('is-active');
|
||||
});
|
||||
button.addClass('is-active');
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private createMatchConditionsSelector(container: HTMLElement): void {
|
||||
const setting = new Setting(container).setName('Match Conditions');
|
||||
|
||||
|
|
@ -236,45 +138,6 @@ export class RuleEditorModal extends BaseModal {
|
|||
});
|
||||
}
|
||||
|
||||
private createMobileDestination(container: HTMLElement): void {
|
||||
const card = container.createDiv({ cls: 'noteMover-mobile-modal-card' });
|
||||
const header = card.createDiv({
|
||||
cls: 'noteMover-mobile-modal-card-header',
|
||||
});
|
||||
header.createDiv({
|
||||
cls: 'noteMover-mobile-modal-card-title',
|
||||
text: 'Destination',
|
||||
});
|
||||
card.createDiv({
|
||||
cls: 'noteMover-mobile-modal-card-description',
|
||||
text: 'Folder or template where files matching this rule will be moved',
|
||||
});
|
||||
card.createDiv({
|
||||
cls: 'noteMover-mobile-modal-card-description',
|
||||
text: 'Supports templates like {{tag.tasks/personal}} or {{property.status}}',
|
||||
});
|
||||
const content = card.createDiv({
|
||||
cls: 'noteMover-mobile-modal-card-content',
|
||||
});
|
||||
|
||||
const searchContainer = content.createDiv({
|
||||
cls: 'search-input-container',
|
||||
});
|
||||
const input = searchContainer.createEl('input', {
|
||||
type: 'text',
|
||||
cls: 'noteMover-mobile-text-input',
|
||||
placeholder:
|
||||
'Example: folder1/folder2 or Personal/Tasks/{{property.status}}',
|
||||
value: this.workingRule.destination,
|
||||
});
|
||||
|
||||
new FolderSuggest(this.app, input);
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
this.workingRule.destination = input.value;
|
||||
});
|
||||
}
|
||||
|
||||
private createDestinationInput(container: HTMLElement): void {
|
||||
let destinationInputEl: any = null;
|
||||
|
||||
|
|
@ -310,35 +173,6 @@ export class RuleEditorModal extends BaseModal {
|
|||
}
|
||||
}
|
||||
|
||||
private createMobileConditionsSection(container: HTMLElement): void {
|
||||
const section = new MobileModalSection(container, 'Conditions');
|
||||
const sectionEl = section.getSectionElement();
|
||||
|
||||
// Triggers container
|
||||
this.triggersContainer = sectionEl.createDiv({
|
||||
cls: 'noteMover-rule-triggers-container',
|
||||
});
|
||||
|
||||
this.renderMobileTriggers();
|
||||
|
||||
// Add Condition Button
|
||||
new MobileModalButton(
|
||||
sectionEl,
|
||||
'',
|
||||
undefined,
|
||||
'+ Add Condition',
|
||||
() => {
|
||||
this.workingRule.triggers.push({
|
||||
criteriaType: 'tag',
|
||||
operator: 'includes item',
|
||||
value: '',
|
||||
});
|
||||
this.renderMobileTriggers();
|
||||
},
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
private createConditionsSection(container: HTMLElement): void {
|
||||
const section = container.createDiv({
|
||||
cls: 'noteMover-rule-conditions-section',
|
||||
|
|
@ -369,68 +203,6 @@ export class RuleEditorModal extends BaseModal {
|
|||
);
|
||||
}
|
||||
|
||||
private renderMobileTriggers(): void {
|
||||
if (!this.triggersContainer) return;
|
||||
|
||||
this.triggersContainer.empty();
|
||||
this.mobileTriggerCards = [];
|
||||
|
||||
this.workingRule.triggers.forEach((trigger, index) => {
|
||||
const callbacks: MobileTriggerCardCallbacks = {
|
||||
onCriteriaTypeChange: () => {
|
||||
this.renderMobileTriggers();
|
||||
},
|
||||
onOperatorChange: () => {
|
||||
this.renderMobileTriggers();
|
||||
},
|
||||
onPropertyNameChange: () => {
|
||||
this.renderMobileTriggers();
|
||||
},
|
||||
onValueChange: () => {
|
||||
// Value change doesn't require re-render
|
||||
},
|
||||
onMoveUp: () => {
|
||||
if (index > 0) {
|
||||
const [movedTrigger] = this.workingRule.triggers.splice(index, 1);
|
||||
this.workingRule.triggers.splice(index - 1, 0, movedTrigger);
|
||||
this.renderMobileTriggers();
|
||||
}
|
||||
},
|
||||
onMoveDown: () => {
|
||||
if (index < this.workingRule.triggers.length - 1) {
|
||||
const [movedTrigger] = this.workingRule.triggers.splice(index, 1);
|
||||
this.workingRule.triggers.splice(index + 1, 0, movedTrigger);
|
||||
this.renderMobileTriggers();
|
||||
}
|
||||
},
|
||||
onDelete: () => {
|
||||
this.workingRule.triggers.splice(index, 1);
|
||||
if (this.workingRule.triggers.length === 0) {
|
||||
this.workingRule.triggers.push({
|
||||
criteriaType: 'tag',
|
||||
operator: 'includes item',
|
||||
value: '',
|
||||
});
|
||||
}
|
||||
this.renderMobileTriggers();
|
||||
},
|
||||
canMoveUp: index > 0,
|
||||
canMoveDown: index < this.workingRule.triggers.length - 1,
|
||||
onRender: () => {
|
||||
this.renderMobileTriggers();
|
||||
},
|
||||
};
|
||||
|
||||
const triggerCard = new MobileTriggerCard(
|
||||
this.triggersContainer!,
|
||||
trigger,
|
||||
callbacks,
|
||||
this.app
|
||||
);
|
||||
this.mobileTriggerCards.push(triggerCard);
|
||||
});
|
||||
}
|
||||
|
||||
private renderTriggers(): void {
|
||||
if (!this.triggersContainer) return;
|
||||
|
||||
|
|
@ -541,7 +313,8 @@ export class RuleEditorModal extends BaseModal {
|
|||
// Automatische Typ-Erkennung
|
||||
const detectedType = getPropertyTypeFromVault(
|
||||
this.app,
|
||||
trigger.propertyName
|
||||
trigger.propertyName,
|
||||
this.ruleOptions.vaultIndexCache
|
||||
);
|
||||
if (detectedType) {
|
||||
trigger.propertyType = detectedType;
|
||||
|
|
@ -565,7 +338,7 @@ export class RuleEditorModal extends BaseModal {
|
|||
|
||||
// Add suggesters based on criteriaType
|
||||
if (trigger.criteriaType === 'tag') {
|
||||
new TagSuggest(this.app, valueInput);
|
||||
new TagSuggest(this.app, valueInput, this.ruleOptions.vaultIndexCache);
|
||||
} else if (trigger.criteriaType === 'folder') {
|
||||
new FolderSuggest(this.app, valueInput);
|
||||
} else if (
|
||||
|
|
@ -578,7 +351,8 @@ export class RuleEditorModal extends BaseModal {
|
|||
this.app,
|
||||
valueInput,
|
||||
trigger.propertyName,
|
||||
trigger.propertyType
|
||||
trigger.propertyType,
|
||||
this.ruleOptions.vaultIndexCache
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -679,59 +453,6 @@ export class RuleEditorModal extends BaseModal {
|
|||
});
|
||||
}
|
||||
|
||||
private createMobileFooterActions(container: HTMLElement): void {
|
||||
const footer = container.createDiv({
|
||||
cls: 'noteMover-rule-editor-footer-mobile',
|
||||
});
|
||||
|
||||
// Remove Rule button (only in edit mode)
|
||||
if (this.ruleOptions.isEditMode && this.ruleOptions.onDelete) {
|
||||
new MobileModalButton(
|
||||
footer,
|
||||
'',
|
||||
undefined,
|
||||
'Remove Rule',
|
||||
async () => {
|
||||
const confirmed = confirm(
|
||||
`Are you sure you want to delete the rule "${this.workingRule.name}"?\n\nThis action cannot be undone.`
|
||||
);
|
||||
if (confirmed && this.ruleOptions.onDelete) {
|
||||
await this.ruleOptions.onDelete();
|
||||
this.close();
|
||||
}
|
||||
},
|
||||
{ isDanger: true }
|
||||
);
|
||||
}
|
||||
|
||||
// Cancel button
|
||||
new MobileModalButton(
|
||||
footer,
|
||||
'',
|
||||
undefined,
|
||||
'Cancel',
|
||||
() => {
|
||||
this.close();
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
// Save button
|
||||
new MobileModalButton(
|
||||
footer,
|
||||
'',
|
||||
undefined,
|
||||
'Save',
|
||||
async () => {
|
||||
if (this.validateRule()) {
|
||||
await this.ruleOptions.onSave(this.workingRule);
|
||||
this.close();
|
||||
}
|
||||
},
|
||||
{ isPrimary: true }
|
||||
);
|
||||
}
|
||||
|
||||
private createFooterActions(container: HTMLElement): void {
|
||||
const footer = container.createDiv({
|
||||
cls: 'noteMover-rule-editor-footer',
|
||||
|
|
|
|||
|
|
@ -6,5 +6,3 @@ export { HistoryModal } from './HistoryModal';
|
|||
export { PreviewModal } from './PreviewModal';
|
||||
export { UpdateModal } from './UpdateModal';
|
||||
export { RuleEditorModal } from './RuleEditorModal';
|
||||
export { LegacyMigrationModal } from './LegacyMigrationModal';
|
||||
export type { LegacyMigrationModalOptions } from './LegacyMigrationModal';
|
||||
|
|
|
|||
|
|
@ -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 { PluginSettingTab } from 'obsidian';
|
||||
import {
|
||||
HistoryEntry,
|
||||
BulkOperation,
|
||||
RetentionPolicy,
|
||||
} from '../types/HistoryEntry';
|
||||
import { SETTINGS_CONSTANTS } from '../config/constants';
|
||||
import {
|
||||
PeriodicMovementSettingsSection,
|
||||
FilterSettingsSection,
|
||||
RulesSettingsSection,
|
||||
HistorySettingsSection,
|
||||
ImportExportSettingsSection,
|
||||
LegacySettingsSection,
|
||||
PerformanceDebugSettingsSection,
|
||||
} from './sections';
|
||||
import { DebounceManager } from '../utils/DebounceManager';
|
||||
import { MobileUtils } from '../utils/MobileUtils';
|
||||
import { MobileSettingsRenderer } from './mobile/MobileSettingsRenderer';
|
||||
|
||||
interface Rule {
|
||||
criteria: string; // Format: "type: value" (e.g. "tag: #project", "fileName: notes.md")
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use PluginData instead
|
||||
*/
|
||||
export interface NoteMoverShortcutSettings {
|
||||
enablePeriodicMovement: boolean;
|
||||
periodicMovementInterval: number;
|
||||
enableOnEditTrigger?: boolean;
|
||||
filter: string[];
|
||||
rules: Rule[];
|
||||
history?: HistoryEntry[];
|
||||
bulkOperations?: BulkOperation[];
|
||||
lastSeenVersion?: string;
|
||||
retentionPolicy?: RetentionPolicy;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: NoteMoverShortcutSettings =
|
||||
SETTINGS_CONSTANTS.DEFAULT_SETTINGS;
|
||||
|
||||
export class NoteMoverShortcutSettingsTab extends PluginSettingTab {
|
||||
private periodicMovementSettings: PeriodicMovementSettingsSection;
|
||||
|
|
@ -47,7 +17,7 @@ export class NoteMoverShortcutSettingsTab extends PluginSettingTab {
|
|||
private rulesSettings: RulesSettingsSection;
|
||||
private historySettings: HistorySettingsSection;
|
||||
private importExportSettings: ImportExportSettingsSection;
|
||||
private legacySettings: LegacySettingsSection;
|
||||
private performanceDebugSettings: PerformanceDebugSettingsSection;
|
||||
private debounceManager: DebounceManager;
|
||||
|
||||
constructor(private plugin: NoteMoverShortcutPlugin) {
|
||||
|
|
@ -85,7 +55,7 @@ export class NoteMoverShortcutSettingsTab extends PluginSettingTab {
|
|||
this.containerEl,
|
||||
debouncedDisplay
|
||||
);
|
||||
this.legacySettings = new LegacySettingsSection(
|
||||
this.performanceDebugSettings = new PerformanceDebugSettingsSection(
|
||||
plugin,
|
||||
this.containerEl,
|
||||
debouncedDisplay
|
||||
|
|
@ -98,17 +68,6 @@ export class NoteMoverShortcutSettingsTab extends PluginSettingTab {
|
|||
// Add mobile-specific classes to container
|
||||
MobileUtils.addMobileClass(this.containerEl);
|
||||
|
||||
// Use mobile renderer if on mobile, otherwise use desktop implementation
|
||||
if (MobileUtils.isMobile()) {
|
||||
const mobileRenderer = new MobileSettingsRenderer(
|
||||
this.plugin,
|
||||
this.containerEl
|
||||
);
|
||||
mobileRenderer.render();
|
||||
return;
|
||||
}
|
||||
|
||||
// Desktop implementation (existing code)
|
||||
// Clean up existing section instances before creating new ones
|
||||
this.cleanupExistingSections();
|
||||
|
||||
|
|
@ -147,7 +106,7 @@ export class NoteMoverShortcutSettingsTab extends PluginSettingTab {
|
|||
this.containerEl,
|
||||
debouncedDisplay
|
||||
);
|
||||
this.legacySettings = new LegacySettingsSection(
|
||||
this.performanceDebugSettings = new PerformanceDebugSettingsSection(
|
||||
this.plugin,
|
||||
this.containerEl,
|
||||
debouncedDisplay
|
||||
|
|
@ -165,21 +124,13 @@ export class NoteMoverShortcutSettingsTab extends PluginSettingTab {
|
|||
|
||||
this.importExportSettings.addImportExportSettings();
|
||||
|
||||
this.legacySettings.addLegacySettings();
|
||||
this.performanceDebugSettings.addPerformanceDebugSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure arrays exist without removing empty rules during display
|
||||
* Ensure filter array exists without removing empty rules during display
|
||||
*/
|
||||
private ensureArraysExist(): void {
|
||||
// Ensure rules array exists and is valid
|
||||
if (!Array.isArray(this.plugin.settings.settings?.rules)) {
|
||||
(this.plugin.settings as any).settings =
|
||||
this.plugin.settings.settings || ({} as any);
|
||||
(this.plugin.settings.settings as any).rules = [];
|
||||
}
|
||||
|
||||
// Ensure filter array exists and is valid
|
||||
if (!Array.isArray(this.plugin.settings.settings?.filters?.filter)) {
|
||||
(this.plugin.settings as any).settings =
|
||||
this.plugin.settings.settings || ({} as any);
|
||||
|
|
@ -189,35 +140,6 @@ export class NoteMoverShortcutSettingsTab extends PluginSettingTab {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate settings to prevent data loss during rendering
|
||||
* This method should only be called when explicitly validating settings
|
||||
*/
|
||||
private validateSettings(): void {
|
||||
// Ensure arrays exist first
|
||||
this.ensureArraysExist();
|
||||
|
||||
// Remove any invalid rules (empty criteria or path)
|
||||
this.plugin.settings.settings.rules =
|
||||
this.plugin.settings.settings.rules.filter(
|
||||
(rule: any) =>
|
||||
rule &&
|
||||
rule.criteria &&
|
||||
rule.path &&
|
||||
rule.criteria.trim() !== '' &&
|
||||
rule.path.trim() !== ''
|
||||
);
|
||||
|
||||
// Remove any invalid filters (empty strings)
|
||||
this.plugin.settings.settings.filters.filter =
|
||||
this.plugin.settings.settings.filters.filter.filter(
|
||||
(filter: any) =>
|
||||
filter &&
|
||||
typeof filter.value === 'string' &&
|
||||
filter.value.trim() !== ''
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up existing section instances to prevent memory leaks
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
.addSearch(cb => {
|
||||
// AdvancedSuggest instead of TagSuggest
|
||||
const advancedSuggest = new AdvancedSuggest(this.app, cb.inputEl);
|
||||
const advancedSuggest = new AdvancedSuggest(
|
||||
this.app,
|
||||
cb.inputEl,
|
||||
this.plugin.vaultIndexCache
|
||||
);
|
||||
// Track the instance for cleanup
|
||||
this.advancedSuggestInstances.push(advancedSuggest);
|
||||
cb.setPlaceholder(SETTINGS_CONSTANTS.PLACEHOLDER_TEXTS.FILTER)
|
||||
|
|
|
|||
|
|
@ -104,8 +104,9 @@ export class ImportExportSettingsSection {
|
|||
// Show success message
|
||||
NoticeManager.info(SETTINGS_CONSTANTS.UI_TEXTS.EXPORT_SUCCESS);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
handleError(
|
||||
createError(`Export failed: ${error.message}`),
|
||||
createError(`Export failed: ${msg}`),
|
||||
'Export settings',
|
||||
false
|
||||
);
|
||||
|
|
@ -216,7 +217,7 @@ export class ImportExportSettingsSection {
|
|||
}
|
||||
|
||||
if (Array.isArray(s.rules)) {
|
||||
current.settings.rules = s.rules;
|
||||
(current.settings as any).rules = s.rules;
|
||||
}
|
||||
|
||||
if (s.retentionPolicy) {
|
||||
|
|
@ -232,17 +233,6 @@ export class ImportExportSettingsSection {
|
|||
current.schemaVersion = importedSettings.schemaVersion;
|
||||
}
|
||||
|
||||
// Import enableLegacyRules (or migrate from old enableRuleV2)
|
||||
if (s.enableLegacyRules !== undefined) {
|
||||
current.settings.enableLegacyRules = !!s.enableLegacyRules;
|
||||
} else if ((s as any).enableRuleV2 !== undefined) {
|
||||
current.settings.enableLegacyRules = !(s as any).enableRuleV2;
|
||||
}
|
||||
if (s.legacyMigrationDismissed !== undefined) {
|
||||
current.settings.legacyMigrationDismissed =
|
||||
!!s.legacyMigrationDismissed;
|
||||
}
|
||||
|
||||
// Import RuleV2 if present
|
||||
if (s.rulesV2 !== undefined) {
|
||||
current.settings.rulesV2 = s.rulesV2;
|
||||
|
|
@ -272,7 +262,8 @@ export class ImportExportSettingsSection {
|
|||
.map((v: string) => ({ value: v }));
|
||||
}
|
||||
if (Array.isArray(sanitizedSettings.rules)) {
|
||||
current.settings.rules = sanitizedSettings.rules;
|
||||
(current.settings as { rules?: unknown[] }).rules =
|
||||
sanitizedSettings.rules;
|
||||
}
|
||||
if (sanitizedSettings.retentionPolicy) {
|
||||
current.settings.retentionPolicy =
|
||||
|
|
@ -283,22 +274,21 @@ export class ImportExportSettingsSection {
|
|||
}
|
||||
}
|
||||
|
||||
// Migrate V1 rules to V2 when not in legacy mode
|
||||
if (!current.settings.enableLegacyRules) {
|
||||
if (
|
||||
RuleMigrationService.shouldMigrate(
|
||||
current.settings.rules,
|
||||
current.settings.rulesV2
|
||||
)
|
||||
) {
|
||||
console.log('Migrating imported Rule V1 to Rule V2...');
|
||||
current.settings.rulesV2 = RuleMigrationService.migrateRules(
|
||||
current.settings.rules
|
||||
);
|
||||
console.log(
|
||||
`Migrated ${current.settings.rulesV2.length} imported rules to V2 format`
|
||||
);
|
||||
}
|
||||
const legacyRules = (current.settings as any).rules;
|
||||
if (
|
||||
Array.isArray(legacyRules) &&
|
||||
RuleMigrationService.shouldMigrate(
|
||||
legacyRules,
|
||||
current.settings.rulesV2 ?? []
|
||||
)
|
||||
) {
|
||||
console.log('Migrating imported Rule V1 to Rule V2...');
|
||||
current.settings.rulesV2 =
|
||||
RuleMigrationService.migrateRules(legacyRules);
|
||||
(current.settings as any).rules = [];
|
||||
console.log(
|
||||
`Migrated ${(current.settings.rulesV2 ?? []).length} imported rules to V2 format`
|
||||
);
|
||||
}
|
||||
|
||||
// Save settings
|
||||
|
|
@ -314,9 +304,10 @@ export class ImportExportSettingsSection {
|
|||
NoticeManager.success(SETTINGS_CONSTANTS.UI_TEXTS.IMPORT_SUCCESS);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message !== 'File selection cancelled') {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
if (msg !== 'File selection cancelled') {
|
||||
handleError(
|
||||
createError(`Import failed: ${error.message}`),
|
||||
createError(`Import failed: ${msg}`),
|
||||
'Import settings',
|
||||
false
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
.setName('Enable rule evaluation cache (beta)')
|
||||
.setName('Enable rule evaluation cache')
|
||||
.setDesc(cacheDesc)
|
||||
.addToggle(toggle =>
|
||||
toggle
|
||||
.setValue(
|
||||
this.plugin.settings.settings.enableRuleEvaluationCache ?? false
|
||||
this.plugin.settings.settings.enableRuleEvaluationCache ?? true
|
||||
)
|
||||
.onChange(async value => {
|
||||
this.plugin.settings.settings.enableRuleEvaluationCache = value;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import NoteMoverShortcutPlugin from 'main';
|
||||
import { App, Setting } from 'obsidian';
|
||||
import { FolderSuggest } from '../suggesters/FolderSuggest';
|
||||
import { AdvancedSuggest } from '../suggesters/AdvancedSuggest';
|
||||
import { createError, handleError } from '../../utils/Error';
|
||||
import { SETTINGS_CONSTANTS } from '../../config/constants';
|
||||
import { DragDropManager } from '../../utils/DragDropManager';
|
||||
import { RuleEditorModal } from '../../modals/RuleEditorModal';
|
||||
|
|
@ -11,7 +8,6 @@ import { MobileUtils } from '../../utils/MobileUtils';
|
|||
|
||||
export class RulesSettingsSection {
|
||||
private dragDropManager: DragDropManager | null = null;
|
||||
private advancedSuggestInstances: AdvancedSuggest[] = [];
|
||||
|
||||
constructor(
|
||||
private plugin: NoteMoverShortcutPlugin,
|
||||
|
|
@ -33,29 +29,6 @@ export class RulesSettingsSection {
|
|||
}
|
||||
|
||||
addAddRuleButtonSetting(): void {
|
||||
if (this.plugin.settings.settings.enableLegacyRules) {
|
||||
this.addAddRuleV1ButtonSetting();
|
||||
} else {
|
||||
this.addAddRuleV2ButtonSetting();
|
||||
}
|
||||
}
|
||||
|
||||
private addAddRuleV1ButtonSetting(): void {
|
||||
new Setting(this.containerEl).addButton(btn =>
|
||||
btn
|
||||
.setButtonText('Add new rule')
|
||||
.setCta()
|
||||
.onClick(async () => {
|
||||
this.plugin.settings.settings.rules.push({ criteria: '', path: '' });
|
||||
await this.plugin.save_settings();
|
||||
// Update RuleManager
|
||||
this.plugin.noteMover.updateRuleManager();
|
||||
this.refreshDisplay();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private addAddRuleV2ButtonSetting(): void {
|
||||
new Setting(this.containerEl).addButton(btn =>
|
||||
btn
|
||||
.setButtonText('+ Add Rule')
|
||||
|
|
@ -67,170 +40,17 @@ export class RulesSettingsSection {
|
|||
}
|
||||
|
||||
addRulesArray(): void {
|
||||
if (this.plugin.settings.settings.enableLegacyRules) {
|
||||
this.addRulesV1Array();
|
||||
} else {
|
||||
this.addRulesV2Array();
|
||||
}
|
||||
}
|
||||
|
||||
private addRulesV1Array(): void {
|
||||
// Clean up existing AdvancedSuggest instances before creating new ones
|
||||
this.cleanupAdvancedSuggestInstances();
|
||||
|
||||
const isMobile = MobileUtils.isMobile();
|
||||
|
||||
// Create a container for rules with drag & drop
|
||||
const rulesContainer = document.createElement('div');
|
||||
rulesContainer.className = 'noteMover-rules-container';
|
||||
if (isMobile) {
|
||||
rulesContainer.addClass('noteMover-mobile-rules-container');
|
||||
}
|
||||
this.containerEl.appendChild(rulesContainer);
|
||||
|
||||
// Setup drag & drop manager
|
||||
this.setupDragDropManager(rulesContainer);
|
||||
|
||||
this.plugin.settings.settings.rules.forEach((rule, index) => {
|
||||
const s = new Setting(rulesContainer)
|
||||
.addSearch(cb => {
|
||||
// AdvancedSuggest instead of TagSuggest
|
||||
const advancedSuggest = new AdvancedSuggest(this.app, cb.inputEl);
|
||||
// Track the instance for cleanup
|
||||
this.advancedSuggestInstances.push(advancedSuggest);
|
||||
cb.setPlaceholder(SETTINGS_CONSTANTS.PLACEHOLDER_TEXTS.CRITERIA)
|
||||
.setValue(rule.criteria)
|
||||
.onChange(async value => {
|
||||
if (
|
||||
value &&
|
||||
this.plugin.settings.settings.rules.some(
|
||||
rule => rule.criteria === value
|
||||
)
|
||||
) {
|
||||
handleError(
|
||||
createError(
|
||||
'This criteria already has a folder associated with it'
|
||||
),
|
||||
'Rules setting',
|
||||
false
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save Setting
|
||||
this.plugin.settings.settings.rules[index].criteria = value;
|
||||
await this.plugin.save_settings();
|
||||
// Update RuleManager
|
||||
this.plugin.noteMover.updateRuleManager();
|
||||
});
|
||||
// @ts-ignore
|
||||
cb.containerEl.addClass('noteMover-search');
|
||||
})
|
||||
.addSearch(cb => {
|
||||
new FolderSuggest(this.app, cb.inputEl);
|
||||
cb.setPlaceholder(SETTINGS_CONSTANTS.PLACEHOLDER_TEXTS.PATH)
|
||||
.setValue(rule.path)
|
||||
.onChange(async value => {
|
||||
this.plugin.settings.settings.rules[index].path = value;
|
||||
await this.plugin.save_settings();
|
||||
});
|
||||
// @ts-ignore
|
||||
cb.containerEl.addClass('noteMover-search');
|
||||
})
|
||||
.addExtraButton(btn =>
|
||||
btn.setIcon('cross').onClick(async () => {
|
||||
this.plugin.settings.settings.rules.splice(index, 1);
|
||||
await this.plugin.save_settings();
|
||||
// Update RuleManager
|
||||
this.plugin.noteMover.updateRuleManager();
|
||||
this.refreshDisplay();
|
||||
})
|
||||
);
|
||||
|
||||
// Add drag handle to the setting
|
||||
this.addDragHandle(s.settingEl, index);
|
||||
s.infoEl.remove();
|
||||
|
||||
// Add mobile optimization classes
|
||||
if (isMobile) {
|
||||
s.settingEl.addClass('noteMover-mobile-rule-item');
|
||||
const controlEl = s.settingEl.querySelector('.setting-item-control');
|
||||
if (controlEl) {
|
||||
(controlEl as HTMLElement).addClass('noteMover-mobile-rule-controls');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
moveRule(index: number, direction: number) {
|
||||
const rules = this.plugin.settings.settings.rules;
|
||||
const newIndex = Math.max(0, Math.min(rules.length - 1, index + direction));
|
||||
[rules[index], rules[newIndex]] = [rules[newIndex], rules[index]];
|
||||
this.plugin.save_settings();
|
||||
this.refreshDisplay();
|
||||
}
|
||||
|
||||
private setupDragDropManager(container: HTMLElement): void {
|
||||
// Clean up existing manager
|
||||
if (this.dragDropManager) {
|
||||
this.dragDropManager.destroy();
|
||||
}
|
||||
|
||||
this.dragDropManager = new DragDropManager(container, {
|
||||
onReorder: (fromIndex: number, toIndex: number) => {
|
||||
this.reorderRules(fromIndex, toIndex);
|
||||
},
|
||||
onSave: async () => {
|
||||
await this.plugin.save_settings();
|
||||
this.plugin.noteMover.updateRuleManager();
|
||||
// Refresh display after settings are fully saved
|
||||
this.refreshDisplay();
|
||||
},
|
||||
itemSelector: '.setting-item',
|
||||
handleSelector: '.noteMover-drag-handle',
|
||||
});
|
||||
}
|
||||
|
||||
private addDragHandle(settingEl: HTMLElement, index: number): void {
|
||||
const handle = DragDropManager.createDragHandle();
|
||||
const handleContainer = document.createElement('div');
|
||||
handleContainer.className = 'noteMover-drag-handle-container';
|
||||
handleContainer.appendChild(handle);
|
||||
|
||||
// Insert handle at the beginning of the setting
|
||||
settingEl.insertBefore(handleContainer, settingEl.firstChild);
|
||||
settingEl.classList.add('noteMover-with-drag-handle');
|
||||
}
|
||||
|
||||
private reorderRules(fromIndex: number, toIndex: number): void {
|
||||
if (fromIndex === toIndex) return;
|
||||
|
||||
const rules = this.plugin.settings.settings.rules;
|
||||
const [movedRule] = rules.splice(fromIndex, 1);
|
||||
rules.splice(toIndex, 0, movedRule);
|
||||
|
||||
// Note: refreshDisplay() will be called by the onSave callback after settings are saved
|
||||
this.addRulesV2Array();
|
||||
}
|
||||
|
||||
private get app(): App {
|
||||
return this.plugin.app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up AdvancedSuggest instances to prevent memory leaks
|
||||
*/
|
||||
private cleanupAdvancedSuggestInstances(): void {
|
||||
this.advancedSuggestInstances.forEach(instance => {
|
||||
instance.destroy();
|
||||
});
|
||||
this.advancedSuggestInstances = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Public cleanup method to be called when the section is destroyed
|
||||
*/
|
||||
public cleanup(): void {
|
||||
this.cleanupAdvancedSuggestInstances();
|
||||
if (this.dragDropManager) {
|
||||
this.dragDropManager.destroy();
|
||||
this.dragDropManager = null;
|
||||
|
|
@ -458,6 +278,7 @@ export class RulesSettingsSection {
|
|||
this.refreshDisplay();
|
||||
}
|
||||
: undefined,
|
||||
vaultIndexCache: this.plugin.vaultIndexCache,
|
||||
});
|
||||
|
||||
modal.open();
|
||||
|
|
|
|||
|
|
@ -3,4 +3,4 @@ export { FilterSettingsSection } from './FilterSettingsSection';
|
|||
export { RulesSettingsSection } from './RulesSettingsSection';
|
||||
export { HistorySettingsSection } from './HistorySettingsSection';
|
||||
export { ImportExportSettingsSection } from './ImportExportSettingsSection';
|
||||
export { LegacySettingsSection } from './LegacySettingsSection';
|
||||
export { PerformanceDebugSettingsSection } from './PerformanceDebugSettingsSection';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { App, AbstractInputSuggest, TFolder, TAbstractFile } from 'obsidian';
|
||||
import { MetadataExtractor } from '../../core/MetadataExtractor';
|
||||
import type { PluginVaultIndexCache } from '../../infrastructure/cache/plugin-vault-index-cache';
|
||||
|
||||
export type SuggestType =
|
||||
| 'tag'
|
||||
|
|
@ -36,7 +37,8 @@ export class AdvancedSuggest extends AbstractInputSuggest<string> {
|
|||
|
||||
constructor(
|
||||
public app: App,
|
||||
private inputEl: HTMLInputElement
|
||||
private inputEl: HTMLInputElement,
|
||||
private vaultIndexCache?: PluginVaultIndexCache
|
||||
) {
|
||||
super(app, inputEl);
|
||||
this.metadataExtractor = new MetadataExtractor(app);
|
||||
|
|
@ -57,7 +59,11 @@ export class AdvancedSuggest extends AbstractInputSuggest<string> {
|
|||
}
|
||||
|
||||
private loadTags(): void {
|
||||
this.tags = this.metadataExtractor.extractAllTags();
|
||||
if (this.vaultIndexCache) {
|
||||
this.tags = this.vaultIndexCache.getAllTagsCached(this.app);
|
||||
} else {
|
||||
this.tags = this.metadataExtractor.extractAllTags();
|
||||
}
|
||||
}
|
||||
|
||||
private loadFolders(): void {
|
||||
|
|
@ -66,7 +72,9 @@ export class AdvancedSuggest extends AbstractInputSuggest<string> {
|
|||
}
|
||||
|
||||
private loadFileNames(): void {
|
||||
const files = this.app.vault.getMarkdownFiles();
|
||||
const files = this.vaultIndexCache
|
||||
? this.vaultIndexCache.getMarkdownFilesCached(this.app)
|
||||
: this.app.vault.getMarkdownFiles();
|
||||
this.fileNames = files.map(f => f.name);
|
||||
}
|
||||
|
||||
|
|
@ -85,6 +93,17 @@ export class AdvancedSuggest extends AbstractInputSuggest<string> {
|
|||
}
|
||||
|
||||
private loadPropertyKeysAndValues(): void {
|
||||
if (this.vaultIndexCache) {
|
||||
const { keys, valuesByKey } =
|
||||
this.vaultIndexCache.getPropertyKeysAndValuesCached(this.app);
|
||||
this.propertyKeys = new Set(keys);
|
||||
this.propertyValues = new Map();
|
||||
for (const [key, set] of valuesByKey) {
|
||||
this.propertyValues.set(key, new Set(set));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const files = this.app.vault.getMarkdownFiles();
|
||||
files.forEach(file => {
|
||||
const cachedMetadata = this.app.metadataCache.getFileCache(file);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import { App, AbstractInputSuggest } from 'obsidian';
|
||||
import type { PluginVaultIndexCache } from '../../infrastructure/cache/plugin-vault-index-cache';
|
||||
|
||||
export class PropertyValueSuggest extends AbstractInputSuggest<string> {
|
||||
private propertyName: string;
|
||||
private propertyType: 'text' | 'number' | 'checkbox' | 'date' | 'list';
|
||||
private availableValues: Set<string>;
|
||||
private availableValues = new Set<string>();
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
private inputEl: HTMLInputElement,
|
||||
propertyName: string,
|
||||
propertyType: 'text' | 'number' | 'checkbox' | 'date' | 'list'
|
||||
propertyType: 'text' | 'number' | 'checkbox' | 'date' | 'list',
|
||||
private vaultIndexCache?: PluginVaultIndexCache
|
||||
) {
|
||||
super(app, inputEl);
|
||||
this.propertyName = propertyName;
|
||||
|
|
@ -25,6 +27,15 @@ export class PropertyValueSuggest extends AbstractInputSuggest<string> {
|
|||
return;
|
||||
}
|
||||
|
||||
if (this.vaultIndexCache) {
|
||||
this.availableValues = this.vaultIndexCache.getPropertyValueSetCached(
|
||||
this.app,
|
||||
this.propertyName,
|
||||
this.propertyType
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const files = this.app.vault.getMarkdownFiles();
|
||||
|
||||
for (const file of files) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { App, AbstractInputSuggest } from 'obsidian';
|
||||
import { MetadataExtractor } from '../../core/MetadataExtractor';
|
||||
import type { PluginVaultIndexCache } from '../../infrastructure/cache/plugin-vault-index-cache';
|
||||
|
||||
export class TagSuggest extends AbstractInputSuggest<string> {
|
||||
private tags: Set<string>;
|
||||
|
|
@ -7,7 +8,8 @@ export class TagSuggest extends AbstractInputSuggest<string> {
|
|||
|
||||
constructor(
|
||||
app: App,
|
||||
private inputEl: HTMLInputElement
|
||||
private inputEl: HTMLInputElement,
|
||||
private vaultIndexCache?: PluginVaultIndexCache
|
||||
) {
|
||||
super(app, inputEl);
|
||||
this.tags = new Set();
|
||||
|
|
@ -16,7 +18,11 @@ export class TagSuggest extends AbstractInputSuggest<string> {
|
|||
}
|
||||
|
||||
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[] {
|
||||
|
|
|
|||
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 { Rule } from './Rule';
|
||||
import { RuleV2 } from './RuleV2';
|
||||
|
||||
/**
|
||||
|
|
@ -15,12 +14,13 @@ export interface PluginData {
|
|||
export interface SettingsData {
|
||||
triggers: TriggerSettings;
|
||||
filters: FilterSettings;
|
||||
rules: Rule[];
|
||||
retentionPolicy: RetentionPolicy;
|
||||
enableLegacyRules?: boolean;
|
||||
legacyMigrationDismissed?: boolean;
|
||||
rulesV2?: RuleV2[];
|
||||
enableRuleEvaluationCache?: boolean;
|
||||
/** When false, vault markdown list / tag / property index cache is bypassed (always fresh scans). Default true. */
|
||||
enableVaultIndexCache?: boolean;
|
||||
/** When true, records timing spans and logs `[NoteMover perf]` to the console. */
|
||||
enablePerformanceDebug?: boolean;
|
||||
}
|
||||
|
||||
export interface HistoryData {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
} from '../types/RuleV2';
|
||||
import { App } from 'obsidian';
|
||||
import type { PluginVaultIndexCache } from '../infrastructure/cache/plugin-vault-index-cache';
|
||||
|
||||
/** When using vault index cache, cap how many distinct property samples we scan for type inference. */
|
||||
const PROPERTY_TYPE_INFERENCE_MAX_SAMPLES = 80;
|
||||
|
||||
/**
|
||||
* Utility functions for mapping operators to criteria types and property types
|
||||
|
|
@ -335,16 +339,25 @@ export function operatorRequiresValue(operator: Operator): boolean {
|
|||
*/
|
||||
export function getPropertyTypeFromVault(
|
||||
app: App,
|
||||
propertyName: string
|
||||
propertyName: string,
|
||||
vaultIndexCache?: PluginVaultIndexCache | null
|
||||
): 'text' | 'number' | 'checkbox' | 'date' | 'list' | null {
|
||||
const files = app.vault.getMarkdownFiles();
|
||||
const files = vaultIndexCache
|
||||
? vaultIndexCache.getMarkdownFilesCached(app)
|
||||
: app.vault.getMarkdownFiles();
|
||||
const valueSamples: any[] = [];
|
||||
const maxSamples = vaultIndexCache
|
||||
? PROPERTY_TYPE_INFERENCE_MAX_SAMPLES
|
||||
: Infinity;
|
||||
|
||||
// Collect samples of the property value from multiple files
|
||||
for (const file of files) {
|
||||
const cache = app.metadataCache.getFileCache(file);
|
||||
if (cache?.frontmatter?.[propertyName] !== undefined) {
|
||||
valueSamples.push(cache.frontmatter[propertyName]);
|
||||
if (valueSamples.length >= maxSamples) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { NoteMoverShortcutSettings } from '../settings/Settings';
|
||||
import { HistoryEntry, BulkOperation } from '../types/HistoryEntry';
|
||||
import { PluginData } from '../types/PluginData';
|
||||
import { RuleV2, Trigger, CriteriaType, Operator } from '../types/RuleV2';
|
||||
import { validateDestinationTemplate } from './DestinationTemplate';
|
||||
import { validateDestinationTemplate } from '../domain/templates/DestinationTemplate';
|
||||
import {
|
||||
getOperatorsForCriteriaType,
|
||||
getOperatorsForPropertyType,
|
||||
|
|
@ -18,14 +17,6 @@ export interface ValidationResult {
|
|||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use RuleV2 from '../types/RuleV2' instead. This will be removed in a future version.
|
||||
*/
|
||||
export interface Rule {
|
||||
criteria: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export class SettingsValidator {
|
||||
/**
|
||||
* Validates a complete settings object
|
||||
|
|
@ -49,7 +40,7 @@ export class SettingsValidator {
|
|||
return this.validatePluginData(settings as PluginData);
|
||||
}
|
||||
|
||||
// Otherwise treat as legacy NoteMoverShortcutSettings
|
||||
// Flat legacy JSON (pre–PluginData export shape)
|
||||
return this.validateLegacySettings(settings);
|
||||
}
|
||||
|
||||
|
|
@ -70,14 +61,14 @@ export class SettingsValidator {
|
|||
return (
|
||||
('triggers' in s && typeof s.triggers === 'object') ||
|
||||
('filters' in s && typeof s.filters === 'object') ||
|
||||
Array.isArray(s.rules)
|
||||
Array.isArray(s.rulesV2)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate legacy NoteMoverShortcutSettings
|
||||
* Validate flat legacy settings JSON (historical export format without nested `settings`).
|
||||
*/
|
||||
private static validateLegacySettings(settings: any): ValidationResult {
|
||||
const result: ValidationResult = {
|
||||
|
|
@ -394,8 +385,7 @@ export class SettingsValidator {
|
|||
result: ValidationResult
|
||||
): boolean {
|
||||
if (value === undefined || value === null) {
|
||||
result.errors.push('Field "rules" is required');
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!Array.isArray(value)) {
|
||||
|
|
@ -506,7 +496,7 @@ export class SettingsValidator {
|
|||
/**
|
||||
* Creates a clean settings object with only valid fields
|
||||
*/
|
||||
static sanitizeSettings(settings: any): Partial<NoteMoverShortcutSettings> {
|
||||
static sanitizeSettings(settings: any): Record<string, unknown> {
|
||||
const sanitized: any = {};
|
||||
|
||||
// Copy valid fields
|
||||
|
|
|
|||
11
styles.css
11
styles.css
|
|
@ -3122,3 +3122,14 @@
|
|||
padding-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.noteMover-preview-bulk-status {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.noteMover-preview-bulk-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,15 +4,19 @@
|
|||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"target": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"verbatimModuleSyntax": false,
|
||||
"skipLibCheck": true,
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["DOM", "ES5", "ES6", "ES7"]
|
||||
"lib": ["DOM", "ES2022"],
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,5 +26,7 @@
|
|||
"0.6.0": "1.5.0",
|
||||
"0.7.0": "1.5.0",
|
||||
"0.7.1": "1.5.0",
|
||||
"0.7.2": "1.5.0"
|
||||
"0.7.2": "1.5.0",
|
||||
"0.8.0": "1.5.0",
|
||||
"1.0.0": "1.5.0"
|
||||
}
|
||||
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