Merge pull request #104 from bueckerlars/feat/optional-destination-folder-creation

feat: Add optional setting to skip moves when destination folder is m…
This commit is contained in:
Lars Bücker 2026-07-13 17:25:15 +02:00 committed by GitHub
commit 50bf56843d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 696 additions and 74 deletions

View file

@ -4,7 +4,13 @@
### Features
- **Conflict resolution when the destination already exists** ([#25](https://github.com/bueckerlars/obsidian-note-mover-shortcut/issues/25)): Moves that would collide with an existing file at the target path are handled by a configurable strategy — **Ask every time** (ConflictModal with skip, rename, or overwrite; optional “Always use this choice” persists the strategy), **Always skip**, **Always rename** (numeric suffix such as `note (1).md`), or **Always overwrite**. New **Settings → Conflict resolution** section selects the default. A skip cache remembers skipped source/target pairs so batch moves and vault re-evaluation do not re-prompt for the same conflict. Concurrent moves to the same target path are serialized with per-path locking. Notices report when a file was skipped, renamed to avoid a conflict, or overwrote an existing file.
- **Conflict resolution when the destination already exists** ([#25](https://github.com/bueckerlars/obsidian-note-mover-shortcut/issues/25)): Moves that would collide with an existing file at the target path are handled by a configurable strategy — **Ask every time** (ConflictModal with skip, rename, or overwrite; optional “Always use this choice” persists the strategy), **Always skip**, **Always rename** (numeric suffix such as `note (1).md`), or **Always overwrite**. New **Settings → Conflict resolution** section selects the default (**Always skip** for new installs and when no strategy was saved yet). A skip cache remembers skipped source/target pairs so batch moves and vault re-evaluation do not re-prompt for the same conflict. Concurrent moves to the same target path are serialized with per-path locking. Notices report when a file was skipped, renamed to avoid a conflict, or overwrote an existing file.
- **Optional destination folder creation** ([#103](https://github.com/bueckerlars/obsidian-note-mover-shortcut/issues/103)): New toggle under **Settings → Rules****Create missing destination folders** (on by default). When disabled, notes are not moved if the target folder does not exist; a warning notice explains why (on-edit and manual moves). Each rule can override the global default in the rule editor (**Use global default**, **Always create**, or **Never create**). Bulk, periodic, and preview completion notices summarize how many files were skipped because the destination folder was missing.
### Fixes
- **Bulk and periodic skip summaries**: Move-all and periodic runs now report skipped files in the completion notice — separately for missing destination folders and for conflict skips (including cached conflict skips) — even when no files were moved.
- **Preview vs runtime folder checks**: Rule preview uses the same `adapter.exists` folder check as move execution, so preview and runtime agree on whether a destination folder exists.
## [1.1.2](https://github.com/bueckerlars/obsidian-note-mover-shortcut/compare/1.1.1...1.1.2)

View file

@ -21,6 +21,12 @@ describe('isNoteMoveConflictSkipOutcome', () => {
});
it('returns false for successful moves', () => {
expect(isNoteMoveConflictSkipOutcome({ moved: true })).toBe(false);
expect(
isNoteMoveConflictSkipOutcome({
moved: true,
newPath: 'Dest/note.md',
targetFolder: 'Dest',
})
).toBe(false);
});
});

View file

@ -1,13 +1,7 @@
type NoteMoveConflictSkipCheckOutcome =
| { moved: true }
| { moved: false; reason: 'skip' | 'unchanged' | 'cached_skip' };
/** True when a move was not performed because of a naming conflict skip. */
export function isNoteMoveConflictSkipOutcome(
outcome: NoteMoveConflictSkipCheckOutcome
): boolean {
return (
!outcome.moved &&
(outcome.reason === 'skip' || outcome.reason === 'cached_skip')
);
}
export {
isNoteMoveConflictSkipOutcome,
isConflictSkipFileMoveOutcome,
isMissingFolderSkipOutcome,
formatMoveSkippedDetail,
fileMoveResultFromConflictOutcome,
} from './note-move-skip-outcome';

View file

@ -0,0 +1,98 @@
import { describe, it, expect } from 'vitest';
import {
formatMoveSkippedDetail,
fileMoveResultFromConflictOutcome,
isConflictSkipFileMoveOutcome,
isMissingFolderSkipOutcome,
isNoteMoveConflictSkipOutcome,
} from './note-move-skip-outcome';
describe('formatMoveSkippedDetail', () => {
it('returns empty string when nothing was skipped', () => {
expect(formatMoveSkippedDetail({ missingFolder: 0, conflict: 0 })).toBe('');
});
it('formats missing-folder skips only', () => {
expect(formatMoveSkippedDetail({ missingFolder: 3, conflict: 0 })).toBe(
' 3 skipped (destination folder missing).'
);
});
it('formats conflict skips only', () => {
expect(formatMoveSkippedDetail({ missingFolder: 0, conflict: 2 })).toBe(
' 2 skipped due to conflicts.'
);
});
it('formats combined skip counts', () => {
expect(formatMoveSkippedDetail({ missingFolder: 1, conflict: 4 })).toBe(
' 1 skipped (destination folder missing), 4 skipped due to conflicts.'
);
});
});
describe('isMissingFolderSkipOutcome', () => {
it('detects missing destination folder skips', () => {
expect(
isMissingFolderSkipOutcome({
moved: false,
skipReason: 'missing_destination_folder',
})
).toBe(true);
expect(isMissingFolderSkipOutcome({ moved: false })).toBe(false);
expect(isMissingFolderSkipOutcome({ moved: true, targetFolder: 'A' })).toBe(
false
);
});
});
describe('isConflictSkipFileMoveOutcome', () => {
it('detects conflict and cached conflict skips', () => {
expect(
isConflictSkipFileMoveOutcome({
moved: false,
skipReason: 'conflict_skip',
})
).toBe(true);
expect(
isConflictSkipFileMoveOutcome({
moved: false,
skipReason: 'cached_conflict_skip',
})
).toBe(true);
expect(
isConflictSkipFileMoveOutcome({
moved: false,
skipReason: 'missing_destination_folder',
})
).toBe(false);
});
});
describe('isNoteMoveConflictSkipOutcome', () => {
it('detects conflict skip reasons from execute outcome', () => {
expect(
isNoteMoveConflictSkipOutcome({ moved: false, reason: 'skip' })
).toBe(true);
expect(
isNoteMoveConflictSkipOutcome({ moved: false, reason: 'cached_skip' })
).toBe(true);
expect(
isNoteMoveConflictSkipOutcome({ moved: false, reason: 'unchanged' })
).toBe(false);
});
});
describe('fileMoveResultFromConflictOutcome', () => {
it('maps conflict skip reasons to FileMoveResult', () => {
expect(
fileMoveResultFromConflictOutcome({ moved: false, reason: 'skip' })
).toEqual({ moved: false, skipReason: 'conflict_skip' });
expect(
fileMoveResultFromConflictOutcome({ moved: false, reason: 'cached_skip' })
).toEqual({ moved: false, skipReason: 'cached_conflict_skip' });
expect(
fileMoveResultFromConflictOutcome({ moved: false, reason: 'unchanged' })
).toEqual({ moved: false });
});
});

View file

@ -0,0 +1,64 @@
import type { FileMoveResult } from '../types/Common';
import type { ExecuteNoteMoveWithConflictResult } from './execute-note-move-with-conflict';
export interface MoveSkippedCounts {
missingFolder: number;
conflict: number;
}
/** True when a move was skipped because the destination folder is missing. */
export function isMissingFolderSkipOutcome(result: FileMoveResult): boolean {
return !result.moved && result.skipReason === 'missing_destination_folder';
}
/** True when a move was skipped because of a naming conflict (or cached skip). */
export function isConflictSkipFileMoveOutcome(result: FileMoveResult): boolean {
return (
!result.moved &&
(result.skipReason === 'conflict_skip' ||
result.skipReason === 'cached_conflict_skip')
);
}
/** True when a move was not performed because of a naming conflict skip. */
export function isNoteMoveConflictSkipOutcome(
outcome: ExecuteNoteMoveWithConflictResult
): boolean {
return (
!outcome.moved &&
(outcome.reason === 'skip' || outcome.reason === 'cached_skip')
);
}
/** Maps a conflict-handling outcome to a {@link FileMoveResult} skip reason. */
export function fileMoveResultFromConflictOutcome(
outcome: ExecuteNoteMoveWithConflictResult
): FileMoveResult {
if (outcome.moved) {
return { moved: true, targetFolder: outcome.targetFolder };
}
if (outcome.reason === 'skip') {
return { moved: false, skipReason: 'conflict_skip' };
}
if (outcome.reason === 'cached_skip') {
return { moved: false, skipReason: 'cached_conflict_skip' };
}
return { moved: false };
}
/** Formats aggregated skip counts for bulk / preview completion notices. */
export function formatMoveSkippedDetail(counts: MoveSkippedCounts): string {
const total = counts.missingFolder + counts.conflict;
if (total === 0) {
return '';
}
const parts: string[] = [];
if (counts.missingFolder > 0) {
parts.push(`${counts.missingFolder} skipped (destination folder missing)`);
}
if (counts.conflict > 0) {
parts.push(`${counts.conflict} skipped due to conflicts`);
}
return ` ${parts.join(', ')}.`;
}

View file

@ -52,6 +52,7 @@ export const SETTINGS_CONSTANTS = {
filter: [] as string[],
rules: [] as never[],
retentionPolicy: HISTORY_CONSTANTS.DEFAULT_RETENTION_POLICY,
createMissingDestinationFolders: true,
},
PLACEHOLDER_TEXTS: {
@ -109,6 +110,16 @@ export const SETTINGS_CONSTANTS = {
RETENTION_POLICY_DAYS: 'Days',
RETENTION_POLICY_WEEKS: 'Weeks',
RETENTION_POLICY_MONTHS: 'Months',
// Destination folder creation texts
CREATE_MISSING_FOLDERS_NAME: 'Create missing destination folders',
CREATE_MISSING_FOLDERS_DESC:
'When enabled (default), destination folders are created automatically if they do not exist. When disabled, notes are skipped and not moved if the destination folder is missing. Individual rules can override this in the rule editor.',
RULE_CREATE_FOLDER_NAME: 'Create destination folder',
RULE_CREATE_FOLDER_DESC:
'Control whether a missing destination folder is created for this rule.',
RULE_CREATE_FOLDER_INHERIT: 'Use global default',
RULE_CREATE_FOLDER_ALWAYS: 'Always create',
RULE_CREATE_FOLDER_NEVER: 'Never create',
} as const,
} as const;

View file

@ -2,7 +2,11 @@ import { TFile } from 'obsidian';
import { NoticeManager } from '../utils/NoticeManager';
import { RuleManagerV2 } from './RuleManagerV2';
import { createError, handleError } from '../utils/Error';
import { combinePath, ensureFolderExists } from '../utils/PathUtils';
import {
combinePath,
ensureFolderExists,
folderExists,
} from '../utils/PathUtils';
import { deriveConflictInteractive } from '../utils/conflict-resolution-settings';
import {
filterConflictSkipEntriesByExpectedTarget,
@ -13,6 +17,12 @@ import { persistConflictResolutionStrategy } from '../application/persist-confli
import { getAttachmentMoveSettings } from '../utils/attachment-settings';
import AdvancedNoteMoverPlugin from 'main';
import { type FileMoveResult, type OperationType } from '../types/Common';
import {
fileMoveResultFromConflictOutcome,
formatMoveSkippedDetail,
isConflictSkipFileMoveOutcome,
isMissingFolderSkipOutcome,
} from '../application/note-move-skip-outcome';
import { showSingleFileMoveNotice } from '../utils/single-file-move-notice';
import { MovePreview } from '../types/MovePreview';
import { PreviewModal } from '../modals/PreviewModal';
@ -49,6 +59,9 @@ export class AdvancedNoteMover {
this.ruleManagerV2.setFilter(
this.plugin.pluginData.settings.filters.filter.map(f => f.value)
);
this.ruleManagerV2.setCreateMissingFolders(
this.plugin.pluginData.settings.createMissingDestinationFolders !== false
);
this.plugin.syncRuleCacheHash();
}
@ -76,13 +89,13 @@ export class AdvancedNoteMover {
continue;
}
const destination = await this.ruleManagerV2.moveFileBasedOnTags(
const moveResult = await this.ruleManagerV2.moveFileBasedOnTags(
file,
false
);
expectedTargetBySource.set(
sourcePath,
destination ? combinePath(destination, file.name) : null
moveResult ? combinePath(moveResult.destination, file.name) : null
);
}
@ -183,21 +196,19 @@ export class AdvancedNoteMover {
try {
let targetFolder = defaultFolder;
let result: string | null = null;
result = await this.ruleManagerV2.moveFileBasedOnTags(
const result = await this.ruleManagerV2.moveFileBasedOnTags(
file,
skipFilter
);
if (cacheEnabled) {
cache.store(originalPath, mtime, result);
cache.store(originalPath, mtime, result?.destination ?? null);
}
if (result === null) {
return this.finishFileMove(originalPath, file, { moved: false });
}
targetFolder = result;
targetFolder = result.destination;
const newPath = combinePath(targetFolder, file.name);
@ -205,6 +216,19 @@ export class AdvancedNoteMover {
return this.finishFileMove(originalPath, file, { moved: false });
}
// When folder auto-creation is disabled (globally or per rule), skip
// the move if the destination folder does not exist yet.
if (
!result.createFolder &&
!(await folderExists(app, targetFolder))
) {
this.maybeNotifyMissingFolder(file, targetFolder);
return this.finishFileMove(originalPath, file, {
moved: false,
skipReason: 'missing_destination_folder',
});
}
if (!(await ensureFolderExists(app, targetFolder))) {
throw createError(
`Failed to create target folder: ${targetFolder}`
@ -230,7 +254,11 @@ export class AdvancedNoteMover {
});
if (!moveOutcome.moved) {
return this.finishFileMove(originalPath, file, { moved: false });
return this.finishFileMove(
originalPath,
file,
fileMoveResultFromConflictOutcome(moveOutcome)
);
}
const moveResult = {
@ -274,6 +302,8 @@ export class AdvancedNoteMover {
);
let successCount = 0;
let errorCount = 0;
let missingFolderSkippedCount = 0;
let conflictSkippedCount = 0;
if (options.operationType === 'periodic') {
await this.plugin.conflictSkipCacheManager.prune(app);
@ -293,6 +323,10 @@ export class AdvancedNoteMover {
);
if (moveResult.moved) {
successCount++;
} else if (isMissingFolderSkipOutcome(moveResult)) {
missingFolderSkippedCount++;
} else if (isConflictSkipFileMoveOutcome(moveResult)) {
conflictSkippedCount++;
}
} catch (error) {
errorCount++;
@ -315,43 +349,63 @@ export class AdvancedNoteMover {
}
}
// 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`;
const skippedDetail = formatMoveSkippedDetail({
missingFolder: missingFolderSkippedCount,
conflict: conflictSkippedCount,
});
const hasSkipOrError =
missingFolderSkippedCount > 0 ||
conflictSkippedCount > 0 ||
errorCount > 0;
NoticeManager.showWithUndo(
'info',
`Bulk Operation: ${successMessage}`,
() => {
void (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
);
// Show completion notice when anything happened worth reporting
if (
options.showNotifications &&
(successCount > 0 || hasSkipOrError)
) {
if (options.operationType === 'bulk') {
if (successCount > 0) {
const totalAttempted = successCount + errorCount;
const successMessage =
errorCount > 0
? `Moved ${successCount}/${totalAttempted} files. ${errorCount} files had errors.`
: `Successfully moved ${successCount} files`;
NoticeManager.showWithUndo(
'info',
`Bulk Operation: ${successMessage}${skippedDetail}`,
() => {
void (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(
`Bulk Operation: No files moved.${skippedDetail}`
);
}
} else {
NoticeManager.info(
`Periodic movement: Successfully moved ${successCount} files`
);
const periodicMessage =
successCount > 0
? `Periodic movement: Successfully moved ${successCount} files${skippedDetail}`
: `Periodic movement: No files moved.${skippedDetail}`;
NoticeManager.info(periodicMessage);
}
}
} finally {
@ -424,6 +478,24 @@ export class AdvancedNoteMover {
return result;
}
private maybeNotifyMissingFolder(file: TFile, targetFolder: string): void {
// Suppress per-file notices during bulk operations to avoid spam; the bulk
// completion notice already summarizes how many files were moved.
if (this.plugin.historyManager.isBulkOperationInProgress()) {
return;
}
const now = Date.now();
const lastAt = this.lastMoveNoticeAtByFileName.get(file.name);
if (lastAt !== undefined && now - lastAt < MOVE_NOTICE_DEDUPE_MS) {
return;
}
this.lastMoveNoticeAtByFileName.set(file.name, now);
NoticeManager.warning(
`"${file.name}" was not moved: destination folder "${targetFolder}" does not exist (auto-create is disabled).`
);
}
private maybeNotifySingleFileMove(file: TFile, targetFolder: string): void {
if (this.plugin.historyManager.isBulkOperationInProgress()) {
return;

View file

@ -0,0 +1,113 @@
import { describe, it, expect } from 'vitest';
import type { App } from 'obsidian';
import { TFile } from 'obsidian';
import { RuleManagerV2 } from './RuleManagerV2';
import { PerformanceTraceRecorder } from '../infrastructure/debug/performance-trace';
import type { RuleV2 } from '../types/RuleV2';
function makeRule(overrides: Partial<RuleV2> = {}): RuleV2 {
return {
name: 'to-projects',
destination: 'Projects',
aggregation: 'any',
active: true,
triggers: [
{
criteriaType: 'fileName',
operator: 'contains',
value: 'note',
},
],
...overrides,
};
}
function makeApp(existingFolders: string[] = []): App {
const folders = new Set(existingFolders);
return {
metadataCache: {
getFileCache: () => ({}),
},
vault: {
read: async () => '',
adapter: {
exists: async (path: string) => folders.has(path),
},
getAbstractFileByPath: (path: string) =>
folders.has(path) ? ({ path } as unknown) : null,
},
} as unknown as App;
}
function makeFile(): TFile {
const file = new TFile();
file.path = 'Inbox/note.md';
file.name = 'note.md';
file.extension = 'md';
return file;
}
function makeManager(app: App): RuleManagerV2 {
return new RuleManagerV2(app, '/', new PerformanceTraceRecorder(() => false));
}
describe('RuleManagerV2 createFolder resolution', () => {
it('uses the global default (true) when the rule has no override', async () => {
const manager = makeManager(makeApp());
manager.setRules([makeRule()]);
manager.setCreateMissingFolders(true);
const result = await manager.moveFileBasedOnTags(makeFile());
expect(result).toEqual({ destination: 'Projects', createFolder: true });
});
it('uses the global default (false) when the rule has no override', async () => {
const manager = makeManager(makeApp());
manager.setRules([makeRule()]);
manager.setCreateMissingFolders(false);
const result = await manager.moveFileBasedOnTags(makeFile());
expect(result).toEqual({ destination: 'Projects', createFolder: false });
});
it('per-rule override true wins over global default false', async () => {
const manager = makeManager(makeApp());
manager.setRules([makeRule({ createDestinationFolder: true })]);
manager.setCreateMissingFolders(false);
const result = await manager.moveFileBasedOnTags(makeFile());
expect(result?.createFolder).toBe(true);
});
it('per-rule override false wins over global default true', async () => {
const manager = makeManager(makeApp());
manager.setRules([makeRule({ createDestinationFolder: false })]);
manager.setCreateMissingFolders(true);
const result = await manager.moveFileBasedOnTags(makeFile());
expect(result?.createFolder).toBe(false);
});
});
describe('RuleManagerV2 preview with auto-create disabled', () => {
it('blocks the move when the destination folder is missing', async () => {
const manager = makeManager(makeApp([]));
manager.setRules([makeRule({ createDestinationFolder: false })]);
manager.setCreateMissingFolders(true);
const preview = await manager.generatePreviewForFile(makeFile());
expect(preview.willBeMoved).toBe(false);
expect(preview.createFolder).toBe(false);
expect(preview.blockReason).toContain('does not exist');
});
it('allows the move when the destination folder already exists', async () => {
const manager = makeManager(makeApp(['Projects']));
manager.setRules([makeRule({ createDestinationFolder: false })]);
manager.setCreateMissingFolders(true);
const preview = await manager.generatePreviewForFile(makeFile());
expect(preview.willBeMoved).toBe(true);
expect(preview.createFolder).toBe(false);
});
});

View file

@ -5,6 +5,7 @@ import { handleError } from '../utils/Error';
import {
combinePath,
DESTINATION_PATH_BLOCK_REASONS,
folderExists,
normalizeDestinationFolderPath,
} from '../utils/PathUtils';
import { MetadataExtractor } from './MetadataExtractor';
@ -25,9 +26,19 @@ import type { PerformanceTraceRecorder } from '../infrastructure/debug/performan
*
* @since 0.5.0
*/
/**
* Result of resolving a file against the rules: the destination folder plus
* whether a missing destination folder should be created for this move.
*/
export interface RuleMoveResult {
destination: string;
createFolder: boolean;
}
export class RuleManagerV2 {
private rules: RuleV2[] = [];
private filter: string[] = []; // Filter remain V1-compatible
private createMissingFolders = true;
private metadataExtractor: MetadataExtractor;
private ruleMatcherV2: RuleMatcherV2;
private readonly filterEngine = new BlacklistFilterEngine();
@ -61,6 +72,24 @@ export class RuleManagerV2 {
this.filter = filter;
}
/**
* Sets the global default for creating missing destination folders.
* Individual rules can override this via `RuleV2.createDestinationFolder`.
*
* @param createMissingFolders - Whether missing folders are created by default
*/
public setCreateMissingFolders(createMissingFolders: boolean): void {
this.createMissingFolders = createMissingFolders;
}
/**
* Resolves the effective "create destination folder" decision for a rule,
* honoring the per-rule override and falling back to the global default.
*/
private resolveCreateFolder(rule: RuleV2): boolean {
return rule.createDestinationFolder ?? this.createMissingFolders;
}
private filterNeedsContent(): boolean {
return filtersNeedContent(this.filter);
}
@ -73,12 +102,12 @@ export class RuleManagerV2 {
*
* @param file - The specific TFile to evaluate
* @param skipFilter - Whether to skip filter evaluation
* @returns Target folder path if rule matches, null otherwise
* @returns Move result with destination and folder-creation decision, or null
*/
public async moveFileBasedOnTags(
file: TFile,
skipFilter = false
): Promise<string | null> {
): Promise<RuleMoveResult | null> {
return this.perf.recordAsync(
'RuleManagerV2.moveFileBasedOnTags',
async () => {
@ -112,7 +141,10 @@ export class RuleManagerV2 {
return null;
}
return normalized.path;
return {
destination: normalized.path,
createFolder: this.resolveCreateFolder(matchingRule),
};
}
// No rule matched - skip the file since only notes with rules should be moved
@ -193,6 +225,7 @@ export class RuleManagerV2 {
}
const targetFolder = normalized.path;
const createFolder = this.resolveCreateFolder(matchingRule);
// Calculate the full target path
const fullTargetPath = combinePath(targetFolder, fileName);
@ -206,6 +239,23 @@ export class RuleManagerV2 {
willBeMoved: false,
blockReason: 'File is already in the correct folder',
matchedRule: matchingRule.name,
createFolder,
tags,
};
}
// When auto-create is disabled and the destination folder does not
// exist yet, the move would be skipped - reflect that in the preview.
if (!createFolder && !(await folderExists(this.app, targetFolder))) {
return {
fileName,
currentPath: filePath,
targetPath: targetFolder,
willBeMoved: false,
blockReason:
'Destination folder does not exist and auto-create is disabled',
matchedRule: matchingRule.name,
createFolder,
tags,
};
}
@ -216,6 +266,7 @@ export class RuleManagerV2 {
targetPath: targetFolder,
willBeMoved: true,
matchedRule: matchingRule.name,
createFolder,
tags,
};
}

View file

@ -318,6 +318,7 @@ export function buildDefaultSettingsData(): SettingsData {
enableVaultIndexCache: true,
enablePerformanceDebug: false,
showReleaseNotesOnUpdate: true,
createMissingDestinationFolders: true,
attachments: {
moveWithNote: true,
skipSharedAttachments: true,
@ -384,6 +385,7 @@ function migrateFromLegacy(legacy: unknown): PluginData {
enableRuleEvaluationCache: true,
enableVaultIndexCache: true,
enablePerformanceDebug: false,
createMissingDestinationFolders: true,
};
const data: PluginData = {

View file

@ -3,9 +3,16 @@ import { MovePreview, PreviewEntry } from '../types/MovePreview';
import AdvancedNoteMoverPlugin from 'main';
import { NoticeManager } from '../utils/NoticeManager';
import { MobileUtils } from '../utils/MobileUtils';
import { combinePath, ensureFolderExists } from '../utils/PathUtils';
import {
combinePath,
ensureFolderExists,
folderExists,
} from '../utils/PathUtils';
import { handleError, createError } from '../utils/Error';
import { isNoteMoveConflictSkipOutcome } from '../application/note-move-conflict-skip-outcome';
import {
formatMoveSkippedDetail,
isNoteMoveConflictSkipOutcome,
} from '../application/note-move-skip-outcome';
import { executeNoteMoveWithConflictHandling } from '../application/execute-note-move-with-conflict';
import { persistConflictResolutionStrategy } from '../application/persist-conflict-resolution-strategy';
import { getAttachmentMoveSettings } from '../utils/attachment-settings';
@ -208,7 +215,8 @@ export class PreviewModal extends BaseModal {
const successfulEntries = this.movePreview.successfulMoves;
let movedCount = 0;
let errorCount = 0;
let skippedCount = 0;
let missingFolderSkippedCount = 0;
let conflictSkippedCount = 0;
const abortCtl = new AbortController();
if (this.actionFooterEl) {
@ -244,6 +252,17 @@ export class PreviewModal extends BaseModal {
const newPath = combinePath(targetFolder, file.name);
if (file.path === newPath) continue;
// Respect the folder-creation decision: skip if auto-create is
// disabled and the destination folder is missing (e.g. it was
// deleted between preview generation and execution).
if (
entry.createFolder === false &&
!(await folderExists(this.app, targetFolder))
) {
missingFolderSkippedCount++;
continue;
}
if (!(await ensureFolderExists(this.app, targetFolder))) {
throw createError(
`Failed to create target folder: ${targetFolder}`
@ -274,7 +293,7 @@ export class PreviewModal extends BaseModal {
if (moveOutcome.moved) {
movedCount++;
} else if (isNoteMoveConflictSkipOutcome(moveOutcome)) {
skippedCount++;
conflictSkippedCount++;
}
} catch (error) {
handleError(error, `Error moving file ${entry.fileName}`, false);
@ -297,19 +316,23 @@ export class PreviewModal extends BaseModal {
this.close();
const skippedDetail = formatMoveSkippedDetail({
missingFolder: missingFolderSkippedCount,
conflict: conflictSkippedCount,
});
const totalSkipped = missingFolderSkippedCount + conflictSkippedCount;
if (abortCtl.signal.aborted) {
NoticeManager.info(
`Bulk move stopped. ${movedCount} file(s) moved, ${skippedCount} skipped, ${errorCount} error(s).`
`Bulk move stopped. ${movedCount} file(s) moved, ${errorCount} error(s).${skippedDetail}`
);
} else if (errorCount === 0 && skippedCount === 0) {
} else if (errorCount === 0 && totalSkipped === 0) {
NoticeManager.success(`Successfully moved ${movedCount} files!`);
} else if (errorCount === 0) {
NoticeManager.info(
`Moved ${movedCount} files. ${skippedCount} file(s) skipped due to conflicts.`
);
NoticeManager.info(`Moved ${movedCount} files.${skippedDetail}`);
} else {
NoticeManager.warning(
`Moved ${movedCount} files, ${skippedCount} skipped, ${errorCount} errors. Check console for details.`
`Moved ${movedCount} files with ${errorCount} errors.${skippedDetail} Check console for details.`
);
}
}

View file

@ -69,6 +69,9 @@ export class RuleEditorModal extends BaseModal {
// Destination
this.createDestinationInput(container);
// Destination folder creation override
this.createFolderCreationSelector(container);
// Separator
container.createEl('hr', {
cls: 'advancedNoteMover-rule-editor-separator',
@ -174,6 +177,44 @@ export class RuleEditorModal extends BaseModal {
?.setAttribute('aria-describedby', descriptionId);
}
private createFolderCreationSelector(container: HTMLElement): void {
const currentValue: 'inherit' | 'always' | 'never' =
this.workingRule.createDestinationFolder === undefined
? 'inherit'
: this.workingRule.createDestinationFolder
? 'always'
: 'never';
new Setting(container)
.setName(SETTINGS_CONSTANTS.UI_TEXTS.RULE_CREATE_FOLDER_NAME)
.setDesc(SETTINGS_CONSTANTS.UI_TEXTS.RULE_CREATE_FOLDER_DESC)
.addDropdown(dropdown =>
dropdown
.addOption(
'inherit',
SETTINGS_CONSTANTS.UI_TEXTS.RULE_CREATE_FOLDER_INHERIT
)
.addOption(
'always',
SETTINGS_CONSTANTS.UI_TEXTS.RULE_CREATE_FOLDER_ALWAYS
)
.addOption(
'never',
SETTINGS_CONSTANTS.UI_TEXTS.RULE_CREATE_FOLDER_NEVER
)
.setValue(currentValue)
.onChange(value => {
if (value === 'always') {
this.workingRule.createDestinationFolder = true;
} else if (value === 'never') {
this.workingRule.createDestinationFolder = false;
} else {
delete this.workingRule.createDestinationFolder;
}
})
);
}
private createConditionsSection(container: HTMLElement): void {
const section = container.createDiv({
cls: 'advancedNoteMover-rule-conditions-section',

View file

@ -156,6 +156,7 @@ export class AdvancedNoteMoverSettingsTab extends PluginSettingTab {
this.filterSettings.addFilterSettings();
this.rulesSettings.addRulesSetting();
this.rulesSettings.addCreateMissingFoldersSetting();
this.rulesSettings.addRulesArray();
this.rulesSettings.addVaultReEvaluationSetting();
this.rulesSettings.addAddRuleButtonSetting();

View file

@ -32,6 +32,30 @@ export class RulesSettingsSection {
new Setting(this.containerEl).setDesc(descUseRules);
}
addCreateMissingFoldersSetting(): void {
const isMobile = MobileUtils.isMobile();
const setting = new Setting(this.containerEl)
.setName(SETTINGS_CONSTANTS.UI_TEXTS.CREATE_MISSING_FOLDERS_NAME)
.setDesc(SETTINGS_CONSTANTS.UI_TEXTS.CREATE_MISSING_FOLDERS_DESC)
.addToggle(toggle =>
toggle
.setValue(
this.plugin.pluginData.settings.createMissingDestinationFolders !==
false
)
.onChange(async value => {
this.plugin.pluginData.settings.createMissingDestinationFolders =
value;
await this.plugin.save_settings();
this.plugin.advancedNoteMover.updateRuleManager();
})
);
if (isMobile) {
setting.settingEl.addClass('advancedNoteMover-mobile-optimized');
}
}
addVaultReEvaluationSetting(): void {
let reEvaluateButton: ButtonComponent | undefined;

View file

@ -11,9 +11,15 @@ export type NotificationType =
export type OperationType = 'single' | 'bulk' | 'periodic';
/** Why a rule-based move was skipped without moving the file. */
export type FileMoveSkipReason =
| 'missing_destination_folder'
| 'conflict_skip'
| 'cached_conflict_skip';
/** Result of attempting to move one file via rules. */
export type FileMoveResult =
| { moved: false }
| { moved: false; skipReason?: FileMoveSkipReason }
| { moved: true; targetFolder: string };
/**

View file

@ -13,6 +13,11 @@ export interface PreviewEntry {
blockReason?: string;
/** The filter that blocked the move (if any) */
blockingFilter?: string;
/**
* Whether a missing destination folder should be created for this move.
* Resolved from the matched rule override or the global setting.
*/
createFolder?: boolean;
/** File tags for display */
tags: string[];
}

View file

@ -33,6 +33,13 @@ export interface SettingsData {
enablePerformanceDebug?: boolean;
/** When true (default), show the changelog modal once after a plugin version update. */
showReleaseNotesOnUpdate?: boolean;
/**
* When true (default), destination folders are created automatically if they
* do not exist. When false, notes are skipped (not moved) if the destination
* folder is missing. Individual rules can override this via
* {@link RuleV2.createDestinationFolder}.
*/
createMissingDestinationFolders?: boolean;
attachments?: AttachmentMoveSettings;
conflictResolution?: ConflictResolutionSettings;
}

View file

@ -165,4 +165,10 @@ export interface RuleV2 {
aggregation: AggregationType;
triggers: Trigger[];
active: boolean;
/**
* Per-rule override for creating the destination folder when it is missing.
* `undefined` inherits the global `createMissingDestinationFolders` setting,
* `true` always creates the folder, `false` skips the move if it is missing.
*/
createDestinationFolder?: boolean;
}

View file

@ -160,6 +160,34 @@ export function getParentPath(fullPath: string): string {
return fullPath.substring(0, lastSlashIndex);
}
/**
* Checks whether a folder already exists in the vault without creating it.
* @param app - The Obsidian App instance
* @param folderPath - The path of the folder to check
* @returns Promise<boolean> - true if the folder (or root) exists, false otherwise
*/
export async function folderExists(
app: App,
folderPath: string
): Promise<boolean> {
// Root always exists
if (!folderPath || folderPath === '/' || folderPath === '') {
return true;
}
const formattedPath = formatPath(folderPath);
if (!formattedPath) {
return true; // Root path after formatting
}
try {
return await app.vault.adapter.exists(formattedPath);
} catch (error) {
console.error(`Failed to check folder ${formattedPath}:`, error);
return false;
}
}
/**
* Ensures that a folder exists in the vault, creating it if necessary
* @param app - The Obsidian App instance

View file

@ -260,6 +260,18 @@ export class SettingsValidator {
}
}
// createMissingDestinationFolders (optional boolean)
if (
!this.validateBooleanField(
settings.createMissingDestinationFolders,
'settings.createMissingDestinationFolders',
result,
false
)
) {
result.isValid = false;
}
// retention policy (optional but recommended)
if (settings.retentionPolicy !== undefined) {
const rp = settings.retentionPolicy;
@ -643,6 +655,17 @@ export class SettingsValidator {
return false;
}
// Validate createDestinationFolder (optional boolean override)
if (
rule.createDestinationFolder !== undefined &&
typeof rule.createDestinationFolder !== 'boolean'
) {
result.errors.push(
`RuleV2 at index ${index}: 'createDestinationFolder' must be a boolean`
);
return false;
}
// Validate triggers array
if (!Array.isArray(rule.triggers)) {
result.errors.push(

View file

@ -1,9 +1,50 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import type { App } from 'obsidian';
import {
folderExists,
normalizeDestinationFolderPath,
sanitizePathSegment,
} from './PathUtils';
function makeApp(existsImpl: (path: string) => Promise<boolean>): App {
return {
vault: {
adapter: {
exists: existsImpl,
},
},
} as unknown as App;
}
describe('folderExists', () => {
it('treats root and empty paths as existing', async () => {
const exists = vi.fn().mockResolvedValue(false);
const app = makeApp(exists);
expect(await folderExists(app, '')).toBe(true);
expect(await folderExists(app, '/')).toBe(true);
expect(exists).not.toHaveBeenCalled();
});
it('checks formatted vault-relative paths via adapter.exists', async () => {
const exists = vi
.fn()
.mockImplementation(async (path: string) => path === 'Projects/Notes');
const app = makeApp(exists);
expect(await folderExists(app, '/Projects/Notes/')).toBe(true);
expect(await folderExists(app, 'Projects/Missing')).toBe(false);
expect(exists).toHaveBeenCalledWith('Projects/Notes');
});
it('returns false when adapter.exists throws', async () => {
const exists = vi.fn().mockRejectedValue(new Error('IO error'));
const app = makeApp(exists);
expect(await folderExists(app, 'Projects')).toBe(false);
});
});
describe('sanitizePathSegment', () => {
it('unwraps wikilinks and strips invalid characters', () => {
expect(sanitizePathSegment('[[Client A]]')).toBe('Client A');