diff --git a/IO/SettingsIO.ts b/IO/SettingsIO.ts index e9c31d1..c0de89d 100644 --- a/IO/SettingsIO.ts +++ b/IO/SettingsIO.ts @@ -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; } } diff --git a/Settings/Settings.ts b/Settings/Settings.ts index 07f3915..3ac4fd7 100644 --- a/Settings/Settings.ts +++ b/Settings/Settings.ts @@ -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 = { projectRules: [], automaticMoving: false, timer: null, + debugLogging: false, collapseSections: { tutorial: false, movingRules: false, @@ -39,6 +41,12 @@ export const DEFAULT_SETTINGS: Partial = { }, }; +/** + * 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 + */ function loadSettings(AutoMoverPlugin: AutoMoverPlugin): Partial { return Object.assign({}, DEFAULT_SETTINGS, AutoMoverPlugin.loadData()); } diff --git a/Utils/LoggerUtil.ts b/Utils/LoggerUtil.ts new file mode 100644 index 0000000..8c08490 --- /dev/null +++ b/Utils/LoggerUtil.ts @@ -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; diff --git a/Utils/MovingUtil.ts b/Utils/MovingUtil.ts index 1e3c218..9e4ef5d 100644 --- a/Utils/MovingUtil.ts +++ b/Utils/MovingUtil.ts @@ -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 { + 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; } diff --git a/Utils/RuleMatcherUtil.ts b/Utils/RuleMatcherUtil.ts index b5cf075..badf751 100644 --- a/Utils/RuleMatcherUtil.ts +++ b/Utils/RuleMatcherUtil.ts @@ -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; } diff --git a/main.ts b/main.ts index 03d3409..3eecf74 100644 --- a/main.ts +++ b/main.ts @@ -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 { // 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 { 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 { 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 { 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"); }