feat: counting moves passed | failed

This commit is contained in:
Al0cam 2026-05-18 09:45:31 +02:00
parent 450605fd66
commit a31fdaf936
6 changed files with 318 additions and 94 deletions

View file

@ -1,5 +1,6 @@
import type { AutoMoverSettings } from "Settings/Settings";
import { Notice, TFile } from "obsidian";
import loggerUtil from "Utils/LoggerUtil";
import { TFile } from "obsidian";
import type { App } from "obsidian";
class SettingsIO {
@ -8,6 +9,11 @@ class SettingsIO {
private constructor() {}
/**
* Returns the singleton instance of SettingsIO, creating it on first access.
*
* @returns SettingsIO
*/
public static getInstance(): SettingsIO {
if (!SettingsIO.instance) {
SettingsIO.instance = new SettingsIO();
@ -15,6 +21,12 @@ class SettingsIO {
return SettingsIO.instance;
}
/**
* Stores the Obsidian app reference used for vault and dialog access.
*
* @param app - The Obsidian App instance
* @returns void
*/
public init(app: App): void {
this.app = app;
}
@ -57,11 +69,10 @@ class SettingsIO {
const fs = require("node:fs");
fs.writeFileSync(filePath, settingsData);
new Notice("Settings exported successfully");
loggerUtil.infoNotice("Settings exported successfully");
return true;
} catch (error) {
console.error("Failed to export settings:", error);
new Notice("Failed to export settings");
loggerUtil.errorNotice("Failed to export settings", error);
return false;
}
}
@ -77,11 +88,10 @@ class SettingsIO {
try {
const filename = "AutoMover_settings.json";
await this.app.vault.create(filename, settingsData);
new Notice(`Settings exported to vault: ${filename}`);
loggerUtil.infoNotice(`Settings exported to vault: ${filename}`);
return true;
} catch (error) {
console.error("Failed to export to vault:", error);
new Notice("Failed to export settings to vault");
loggerUtil.errorNotice("Failed to export settings to vault", error);
return false;
}
}
@ -123,15 +133,14 @@ class SettingsIO {
const importedSettings = JSON.parse(fileContent);
if (!this.validateSettings(importedSettings)) {
new Notice("Invalid settings file format");
loggerUtil.warnNotice("Invalid settings file format");
return null;
}
new Notice("Settings imported successfully");
loggerUtil.infoNotice("Settings imported successfully");
return importedSettings;
} catch (error) {
console.error("Failed to import settings:", error);
new Notice("Failed to import settings");
loggerUtil.errorNotice("Failed to import settings", error);
return null;
}
}
@ -148,7 +157,7 @@ class SettingsIO {
const file = this.app.vault.getAbstractFileByPath(filename);
if (!file || !(file instanceof TFile)) {
new Notice(`Could not find ${filename} in vault`);
loggerUtil.warnNotice(`Could not find ${filename} in vault`);
return null;
}
@ -156,15 +165,14 @@ class SettingsIO {
const importedSettings = JSON.parse(fileContent);
if (!this.validateSettings(importedSettings)) {
new Notice("Invalid settings file format");
loggerUtil.warnNotice("Invalid settings file format");
return null;
}
new Notice("Settings imported from vault successfully");
loggerUtil.infoNotice("Settings imported from vault successfully");
return importedSettings;
} catch (error) {
console.error("Failed to import from vault:", error);
new Notice("Failed to import settings from vault");
loggerUtil.errorNotice("Failed to import settings from vault", error);
return null;
}
}

View file

@ -12,6 +12,7 @@ export interface AutoMoverSettings {
projectRules: ProjectRule[];
automaticMoving: boolean;
timer: number | null; // in miliseconds
debugLogging: boolean;
collapseSections: {
tutorial: boolean;
movingRules: boolean;
@ -30,6 +31,7 @@ export const DEFAULT_SETTINGS: Partial<AutoMoverSettings> = {
projectRules: [],
automaticMoving: false,
timer: null,
debugLogging: false,
collapseSections: {
tutorial: false,
movingRules: false,
@ -39,6 +41,12 @@ export const DEFAULT_SETTINGS: Partial<AutoMoverSettings> = {
},
};
/**
* Loads the plugin settings by merging persisted data on top of DEFAULT_SETTINGS.
*
* @param AutoMoverPlugin - The plugin instance used to read persisted data
* @returns Partial<AutoMoverSettings>
*/
function loadSettings(AutoMoverPlugin: AutoMoverPlugin): Partial<AutoMoverSettings> {
return Object.assign({}, DEFAULT_SETTINGS, AutoMoverPlugin.loadData());
}

122
Utils/LoggerUtil.ts Normal file
View file

@ -0,0 +1,122 @@
import type AutoMoverPlugin from "main";
import { Notice } from "obsidian";
class LoggerUtil {
private static instance: LoggerUtil;
private static readonly PREFIX = "[AutoMover]";
private static readonly NOTICE_DURATION_MS = 5000;
private plugin: AutoMoverPlugin | null = null;
private constructor() {}
/**
* Returns the singleton instance of LoggerUtil, creating it on first access.
*
* @returns LoggerUtil
*/
public static getInstance(): LoggerUtil {
if (!LoggerUtil.instance) {
LoggerUtil.instance = new LoggerUtil();
}
return LoggerUtil.instance;
}
/**
* Wires the logger to the plugin so it can read the current debug toggle
* from settings on every call (settings may be reassigned on reload).
*/
public init(plugin: AutoMoverPlugin): void {
this.plugin = plugin;
}
/**
* Reads the current debug toggle from plugin settings.
*
* @returns true if debug logging is enabled, false otherwise
*/
private isDebugEnabled(): boolean {
return this.plugin?.settings?.debugLogging === true;
}
/**
* Logs a debug message to the console, prefixed with the plugin tag.
* No-op when debug logging is disabled in settings.
*
* @param args - Values to log
* @returns void
*/
public debug(...args: unknown[]): void {
if (!this.isDebugEnabled()) return;
console.debug(LoggerUtil.PREFIX, ...args);
}
/**
* Logs an informational message to the console, prefixed with the plugin tag.
*
* @param args - Values to log
* @returns void
*/
public info(...args: unknown[]): void {
console.info(LoggerUtil.PREFIX, ...args);
}
/**
* Logs a warning message to the console, prefixed with the plugin tag.
*
* @param args - Values to log
* @returns void
*/
public warn(...args: unknown[]): void {
console.warn(LoggerUtil.PREFIX, ...args);
}
/**
* Logs an error message to the console, prefixed with the plugin tag.
*
* @param args - Values to log
* @returns void
*/
public error(...args: unknown[]): void {
console.error(LoggerUtil.PREFIX, ...args);
}
/**
* Console.info + Obsidian Notice. `message` shows in both places; any
* additional args (e.g. an Error or rule object) are appended to the
* console line only.
*/
public infoNotice(message: string, ...extra: unknown[]): void {
console.info(LoggerUtil.PREFIX, message, ...extra);
new Notice(message, LoggerUtil.NOTICE_DURATION_MS);
}
/**
* Console.warn + Obsidian Notice. `message` shows in both places; any
* additional args are appended to the console line only.
*
* @param message - The user-facing warning message
* @param extra - Optional values appended to the console line only
* @returns void
*/
public warnNotice(message: string, ...extra: unknown[]): void {
console.warn(LoggerUtil.PREFIX, message, ...extra);
new Notice(message, LoggerUtil.NOTICE_DURATION_MS);
}
/**
* Console.error + Obsidian Notice. `message` shows in both places; any
* additional args (e.g. an Error or rule object) are appended to the
* console line only.
*
* @param message - The user-facing error message
* @param extra - Optional values appended to the console line only
* @returns void
*/
public errorNotice(message: string, ...extra: unknown[]): void {
console.error(LoggerUtil.PREFIX, message, ...extra);
new Notice(message, LoggerUtil.NOTICE_DURATION_MS);
}
}
const loggerUtil = LoggerUtil.getInstance();
export default loggerUtil;

View file

@ -1,6 +1,18 @@
import { Notice, TFile, TFolder } from "obsidian";
import loggerUtil from "Utils/LoggerUtil";
import { TFile, TFolder } from "obsidian";
import type { App } from "obsidian";
/**
* Result of a single `MovingUtil.moveFile` call.
*
* - `moved` file was renamed successfully
* - `no_op` file is already at the destination, nothing to do
* - `duplicate` a different file already exists at the destination path
* - `invalid_path` destination folder didn't exist and could not be created
* - `error` `vault.rename` rejected for any other reason (logged to console)
*/
export type MoveOutcome = "moved" | "no_op" | "duplicate" | "invalid_path" | "error";
class MovingUtil {
private static instance: MovingUtil;
private app: App;
@ -40,26 +52,46 @@ class MovingUtil {
}
/**
* Moves the file to the destination path
* Moves a file into the destination folder, preserving its name.
*
* Behavior:
* - If the file is already at the destination, returns `"no_op"` without touching the vault.
* - If another file already occupies the destination path, returns `"duplicate"`
* (checked up-front via `getAbstractFileByPath` rather than by parsing rename errors).
* - If the destination folder doesn't exist, intermediate folders are created; failure
* to create them returns `"invalid_path"`.
* - Any other rename rejection is caught, logged, and returned as `"error"`.
*
* @param file - File to be moved
* @param newPath - Destination path
* @returns void
* @param newPath - Destination folder (the file's own name is appended automatically)
* @returns a {@link MoveOutcome} describing what happened
*/
public moveFile(file: TFile, newPath: string): void {
if (this.isFolder(newPath)) {
this.app.vault.rename(file, `${newPath}/${file.name}`);
} else {
new Notice(
`Invalid destination path\n${newPath} is not a folder!\nCreating requested folder.`,
5000,
);
console.error(
`Invalid destination path\n${newPath} is not a folder!\nCreating requested folder.`,
);
this.createMissingFolders(newPath).then(() => {
this.app.vault.rename(file, `${newPath}/${file.name}`);
});
public async moveFile(file: TFile, newPath: string): Promise<MoveOutcome> {
const targetPath = `${newPath}/${file.name}`;
if (file.path === targetPath) {
return "no_op";
}
if (this.app.vault.getAbstractFileByPath(targetPath)) {
loggerUtil.debug(`Duplicate at "${targetPath}", skipping "${file.path}"`);
return "duplicate";
}
if (!this.isFolder(newPath)) {
const created = await this.createMissingFolders(newPath);
if (!created) {
loggerUtil.error(`Could not create destination folder "${newPath}"`);
return "invalid_path";
}
}
try {
await this.app.vault.rename(file, targetPath);
return "moved";
} catch (e) {
loggerUtil.error(`Failed to move "${file.path}" -> "${targetPath}": ${e}`);
return "error";
}
}
@ -74,8 +106,7 @@ class MovingUtil {
if (this.isFolder(newPath)) {
this.app.vault.rename(folder, `${newPath}/${folder.name}`);
} else {
new Notice(`Invalid destination path\n${newPath} is not a folder!`, 5000);
console.error(`Invalid destination path\n${newPath} is not a folder!`);
loggerUtil.errorNotice(`Invalid destination path\n${newPath} is not a folder!`);
}
}
@ -91,8 +122,7 @@ class MovingUtil {
return folder;
});
} else {
new Notice("Folder already exists", 5000);
console.error("Folder already exists");
loggerUtil.errorNotice("Folder already exists");
}
return null;
}

View file

@ -1,5 +1,6 @@
import type { MovingRule } from "Models/MovingRule";
import { ProjectRule } from "Models/ProjectRule";
import loggerUtil from "Utils/LoggerUtil";
import type { TFile } from "obsidian";
class RuleMatcherUtil {
@ -8,6 +9,11 @@ class RuleMatcherUtil {
private constructor() {}
/**
* Returns the singleton instance of RuleMatcherUtil, creating it on first access.
*
* @returns RuleMatcherUtil
*/
public static getInstance(): RuleMatcherUtil {
if (!RuleMatcherUtil.instance) {
RuleMatcherUtil.instance = new RuleMatcherUtil();
@ -38,15 +44,15 @@ class RuleMatcherUtil {
public getMatchingRuleByName(file: TFile, rules: MovingRule[]): MovingRule | null {
for (const rule of rules) {
if (rule.regex == null || rule.regex === "") {
console.error("Rule does not have a regex: ", rule);
loggerUtil.error("Rule does not have a regex: ", rule);
continue;
}
if (rule.folder == null || rule.folder === "") {
console.error("Rule does not have a destination folder: ", rule);
loggerUtil.error("Rule does not have a destination folder: ", rule);
continue;
}
if (!this.isValidRegex(rule.regex)) {
console.error("Rule has an invalid regex: ", rule);
loggerUtil.error("Rule has an invalid regex: ", rule);
continue;
}
const regex = this.getCompiledRegex(rule.regex);
@ -67,15 +73,15 @@ class RuleMatcherUtil {
public getMatchingRuleByTag(tags: string[], rules: MovingRule[]): MovingRule | null {
for (const rule of rules) {
if (rule.regex == null || rule.regex === "") {
console.error("Tag Rule does not have a regex: ", rule);
loggerUtil.error("Tag Rule does not have a regex: ", rule);
continue;
}
if (rule.folder == null || rule.folder === "") {
console.error("Tag Rule does not have a destination folder: ", rule);
loggerUtil.error("Tag Rule does not have a destination folder: ", rule);
continue;
}
if (!this.isValidRegex(rule.regex)) {
console.error("Tag Rule has an invalid regex: ", rule);
loggerUtil.error("Tag Rule has an invalid regex: ", rule);
continue;
}
@ -104,6 +110,14 @@ class RuleMatcherUtil {
return matches;
}
/**
* Returns the regex groups from the first tag that matches the rule
* If no tag matches, returns null
*
* @param tags - The list of tags to test
* @param rule - The rule whose regex is applied to each tag
* @returns RegExpMatchArray | null
*/
public getGroupMatchesForTags(tags: string[], rule: MovingRule): RegExpMatchArray | null {
const regex = this.getCompiledRegex(rule.regex);
// console.log("Compiled regex: ", regex);
@ -130,7 +144,7 @@ class RuleMatcherUtil {
return true;
} catch (e) {
if (e instanceof SyntaxError) {
console.error(`Invalid regex pattern: ${e.message}`);
loggerUtil.error(`Invalid regex pattern: ${e.message}`);
}
return false;
}

140
main.ts
View file

@ -2,7 +2,8 @@ import settingsIO from "IO/SettingsIO";
import * as Settings from "Settings/Settings";
import { SettingsTab } from "Settings/SettingsTab";
import exclusionMatcherUtil from "Utils/ExclusionMatcherUtil";
import movingUtil from "Utils/MovingUtil";
import loggerUtil from "Utils/LoggerUtil";
import movingUtil, { type MoveOutcome } from "Utils/MovingUtil";
import ruleMatcherUtil from "Utils/RuleMatcherUtil";
import timerUtil from "Utils/TimerUtil";
import * as obsidian from "obsidian";
@ -11,6 +12,13 @@ import projectMatcherUtil from "Utils/ProjectMatcherUtil";
export default class AutoMoverPlugin extends obsidian.Plugin {
settings: Settings.AutoMoverSettings;
/**
* Obsidian lifecycle hook called when the plugin is enabled.
* Initializes utilities, loads settings, registers the settings tab,
* file-open and automatic-moving events, the command, and the ribbon icon.
*
* @returns void
*/
async onload() {
// console.log("loading plugin");
movingUtil.init(this.app);
@ -19,6 +27,7 @@ export default class AutoMoverPlugin extends obsidian.Plugin {
* Loading settings and creating the settings tab
*/
this.settings = Object.assign({}, Settings.DEFAULT_SETTINGS, await this.loadData());
loggerUtil.init(this);
this.addSettingTab(new SettingsTab(this.app, this));
this.automaticMoving();
@ -39,7 +48,7 @@ export default class AutoMoverPlugin extends obsidian.Plugin {
this.registerEvent(
// since i am defining my own event, ts-lint is crying about it but it works in the end
this.app.workspace.on("AutoMover:automatic-moving-update", () => {
console.log("Automatic moving update");
loggerUtil.debug("Automatic moving update");
this.automaticMoving();
}),
);
@ -75,15 +84,34 @@ export default class AutoMoverPlugin extends obsidian.Plugin {
*
* @returns void
*/
goThroughAllFiles() {
async goThroughAllFiles() {
// console.log("Going through all files");
const files = this.app.vault.getFiles();
for (const file of files) {
const candidates: obsidian.TFile[] = [];
for (const file of this.app.vault.getFiles()) {
if (file == null || file.path == null) continue;
if (this.isFileExcluded(file)) continue;
this.matchAndMoveFile(file);
candidates.push(file);
}
new obsidian.Notice("All files moved!", 5000);
const results = await Promise.all(candidates.map((file) => this.matchAndMoveFile(file)));
let moved = 0;
let duplicate = 0;
let failed = 0;
for (const result of results) {
if (result === "moved") moved++;
else if (result === "duplicate") duplicate++;
else if (result === "invalid_path" || result === "error") failed++;
}
const lines: string[] = [];
if (moved > 0) lines.push(`${moved} ${moved === 1 ? "file" : "files"} moved.`);
if (duplicate > 0)
lines.push(`${duplicate} ${duplicate === 1 ? "file" : "files"} not moved - duplicate name.`);
if (failed > 0)
lines.push(`${failed} ${failed === 1 ? "file" : "files"} failed to move.`);
loggerUtil.infoNotice(lines.length === 0 ? "No files needed to be moved." : lines.join("\n"));
}
/**
@ -100,53 +128,56 @@ export default class AutoMoverPlugin extends obsidian.Plugin {
}
/**
* Matches the file to the rule and moves it to the destination folder
* Matches the file to the first applicable rule (project, then name, then tag)
* and moves it.
*
* @param file - File to be matched and moved
* @returns void
* @returns the MoveOutcome from the matched rule, or null if no rule matched
*/
matchAndMoveFile(file: obsidian.TFile): void {
async matchAndMoveFile(file: obsidian.TFile): Promise<MoveOutcome | null> {
// console.log("Moving file: ", file.path);
if (this.matchAndMoveByProject(file)) return;
else if (this.matchAndMoveByName(file)) return;
else this.matchAndMoveByTag(file);
const projectResult = await this.matchAndMoveByProject(file);
if (projectResult !== null) return projectResult;
const nameResult = await this.matchAndMoveByName(file);
if (nameResult !== null) return nameResult;
return this.matchAndMoveByTag(file);
}
/**
* Matches the file by name and moves it to the destination folder
* Matches the file by name and moves it to the destination folder.
*
* @param file - File to be matched and moved
* @returns true if the file was moved, false otherwise
* @returns the MoveOutcome, or null if no rule matched
*/
matchAndMoveByName(file: obsidian.TFile): boolean {
async matchAndMoveByName(file: obsidian.TFile): Promise<MoveOutcome | null> {
const rule = ruleMatcherUtil.getMatchingRuleByName(file, this.settings.movingRules);
if (rule == null || rule.folder == null) return false;
if (rule == null || rule.folder == null) return null;
if (ruleMatcherUtil.isRegexGrouped(rule)) {
const matches = ruleMatcherUtil.getGroupMatches(file, rule);
const finalDestinationPath = ruleMatcherUtil.constructFinalDesinationPath(rule, matches!);
movingUtil.moveFile(file, finalDestinationPath);
} else {
movingUtil.moveFile(file, rule.folder);
return movingUtil.moveFile(file, finalDestinationPath);
}
return true;
return movingUtil.moveFile(file, rule.folder);
}
/**
* Matches the file by tags it contains and moves it to the destination folder
* Matches the file by tags it contains and moves it to the destination folder.
*
* @param file - File to be matched and moved
* @returns true if the file was moved, false otherwise
* @returns the MoveOutcome, or null if no rule matched
*/
matchAndMoveByTag(file: obsidian.TFile): boolean {
async matchAndMoveByTag(file: obsidian.TFile): Promise<MoveOutcome | null> {
const cache = this.app.metadataCache.getFileCache(file);
if (cache == null) return false;
if (cache == null) return null;
const tags = obsidian.getAllTags(cache);
if (tags == null || tags.length === 0) return false;
if (tags == null || tags.length === 0) return null;
const tagRule = ruleMatcherUtil.getMatchingRuleByTag(tags, this.settings.tagRules);
if (tagRule == null || tagRule.folder == null) return false;
if (tagRule == null || tagRule.folder == null) return null;
if (ruleMatcherUtil.isRegexGrouped(tagRule)) {
const matches = ruleMatcherUtil.getGroupMatchesForTags(tags, tagRule);
@ -154,38 +185,43 @@ export default class AutoMoverPlugin extends obsidian.Plugin {
// console.log("Tag rule: ", tagRule);
// console.log("Tag matches: ", matches);
const finalDestinationPath = ruleMatcherUtil.constructFinalDesinationPath(tagRule, matches!);
movingUtil.moveFile(file, finalDestinationPath);
} else {
movingUtil.moveFile(file, tagRule.folder);
return movingUtil.moveFile(file, finalDestinationPath);
}
return true;
return movingUtil.moveFile(file, tagRule.folder);
}
matchAndMoveByProject(file: obsidian.TFile): boolean {
/**
* Matches the file by its `Project` (or `project`) frontmatter value and
* moves it according to the matched project rule. If the project rule has
* inner rules, the first matching inner rule's destination is appended;
* otherwise (or on a "./" destination) the file is moved to the project root.
*
* @param file - File to be matched and moved
* @returns the MoveOutcome, or null if no project rule matched
*/
async matchAndMoveByProject(file: obsidian.TFile): Promise<MoveOutcome | null> {
const cache = this.app.metadataCache.getFileCache(file);
if (cache == null) return false;
if (cache.frontmatter == null) return false;
if (cache.frontmatter.Project == null && cache.frontmatter.project == null) return false;
if (cache == null) return null;
if (cache.frontmatter == null) return null;
if (cache.frontmatter.Project == null && cache.frontmatter.project == null) return null;
const projectName: string = cache.frontmatter.Project || cache.frontmatter.project;
const projectRule = projectMatcherUtil.getMatchingProjectRule(projectName, this.settings.projectRules);
if (projectRule == null || projectRule.folder == null) return false;
if (projectRule == null || projectRule.folder == null) return null;
// If no rules defined, move to project root
if (projectRule.rules == null || projectRule.rules.length === 0) {
console.log("No rules defined, moving to project root");
movingUtil.moveFile(file, projectRule.folder);
return true;
loggerUtil.debug("No rules defined, moving to project root");
return movingUtil.moveFile(file, projectRule.folder);
}
const rule = ruleMatcherUtil.getMatchingRuleByName(file, projectRule.rules);
// If no rule matches or folder is "./", move to project root
if (rule == null || rule.folder === "./") {
console.log("No matching rule or './' destination, moving to project root");
movingUtil.moveFile(file, projectRule.folder);
return true;
loggerUtil.debug("No matching rule or './' destination, moving to project root");
return movingUtil.moveFile(file, projectRule.folder);
}
// console.log("Project rule's moving rule found: ", rule);
@ -194,20 +230,26 @@ export default class AutoMoverPlugin extends obsidian.Plugin {
const matches = ruleMatcherUtil.getGroupMatches(file, rule);
const ruleDesinationPath = ruleMatcherUtil.constructFinalDesinationPath(rule, matches!);
const finalDestinationPath = projectMatcherUtil.constructProjectDestinationPath(projectRule, ruleDesinationPath);
movingUtil.moveFile(file, finalDestinationPath);
} else {
const finalDestinationPath = projectMatcherUtil.constructProjectDestinationPath(projectRule, rule.folder);
movingUtil.moveFile(file, finalDestinationPath);
return movingUtil.moveFile(file, finalDestinationPath);
}
return true;
const finalDestinationPath = projectMatcherUtil.constructProjectDestinationPath(projectRule, rule.folder);
return movingUtil.moveFile(file, finalDestinationPath);
}
/**
* Reloads the plugin settings from disk, merged on top of DEFAULT_SETTINGS.
*
* @returns void
*/
async asyncloadSettings() {
this.settings = Object.assign({}, Settings.DEFAULT_SETTINGS, await this.loadData());
}
/**
* Obsidian lifecycle hook called when the plugin is disabled or reloaded.
*
* @returns void
*/
async onunload() {
// console.log("unloading plugin");
}