Compare commits

...

17 commits

Author SHA1 Message Date
Ben
66f4c6c0a7
Merge pull request #17 from heathbar2151/issue-templates
Add bug report and feature request templates
2026-05-21 07:44:16 +02:00
Benjamin Heath
a24a9b4b68 add bug report and feature request templates 2026-05-20 12:37:29 -04:00
Ben
bb7ef8f192
Merge pull request #16 from al0cam/featMoveCount
Feat move count
2026-05-18 14:35:37 +02:00
Al0cam
2689d53573 feat: move count with version bump 2026-05-18 14:30:53 +02:00
Al0cam
c3d9e388a0 fix: add missing debug toggle button 2026-05-18 09:49:07 +02:00
Al0cam
a31fdaf936 feat: counting moves passed | failed 2026-05-18 09:45:31 +02:00
Ben
450605fd66
Merge pull request #15 from al0cam/timerFix
fix: timer killed by restart
2026-05-15 10:58:32 +02:00
Al0cam
30dbc37693 fix: timer killed by restart 2026-05-15 10:51:18 +02:00
Ben
eaa35a746b
Merge pull request #10 from al0cam/fix-tagfrontmatter
Fix tagfrontmatter
2026-03-09 20:47:58 +01:00
Al0cam
b3d3bb2f27 version bump 2026-03-09 20:46:39 +01:00
Al0cam
0c79ab38e3 fix: frontmatter not taken into account, requries testing 2026-03-09 15:16:01 +01:00
Al0cam
641c3172e5 Merge branch 'master' of https://github.com/al0cam/AutoMover 2026-02-20 07:46:32 +01:00
Al0cam
c9d7548cc9 fix: description update 2026-02-20 07:43:35 +01:00
Benjamin Filip Šikač
9f779588e4
Merge pull request #8 from al0cam/bug-fix-links
fix: links not opening web pages
2025-11-29 11:30:42 +01:00
Al0cam
226d74f7bd fix: links not opening web pages 2025-11-29 11:29:31 +01:00
Benjamin Filip Šikač
c2cb621b7a
Merge pull request #6 from al0cam/projectRules
Optimisation
    Addition of project rules
    Updated docs
    Minor bugfixes
    Collapsable UI
2025-11-10 09:47:47 +01:00
Al0cam
ed3c23fe14 chore: comment on console.log 2025-11-10 09:46:44 +01:00
14 changed files with 545 additions and 119 deletions

99
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View file

@ -0,0 +1,99 @@
name: Bug Report
description: Something is broken in the plugin
labels: ["bug", "needs-triage"]
body:
- type: markdown
attributes:
value: |
Before filing: search existing issues and make sure you're on the **latest plugin version**.
- type: input
id: plugin-version
attributes:
label: Plugin Version
placeholder: "e.g. 1.4.2"
validations:
required: true
- type: input
id: obsidian-version
attributes:
label: Obsidian Version
placeholder: "e.g. 1.6.3"
validations:
required: true
- type: input
id: installer-version
attributes:
label: Obsidian Installer Version
description: Found in Settings → About → Installer version. This matters — runtime bugs are often installer-related.
placeholder: "e.g. 1.1.16"
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating System
options:
- Windows
- macOS
- Linux
- Android
- iOS / iPadOS
validations:
required: true
- type: textarea
id: description
attributes:
label: What happened?
description: Be specific. "It doesn't work" is not a bug report.
placeholder: "When I do X, Y happens instead of Z."
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to Reproduce
placeholder: |
1. Open a note
2. Run command '...'
3. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
placeholder: What should have happened?
validations:
required: true
- type: textarea
id: console-errors
attributes:
label: Console Errors
description: Open the developer console (Ctrl+Shift+I / Cmd+Opt+I), reproduce the bug, paste any errors here. If there are none, say so.
render: bash
- type: dropdown
id: restricted-mode
attributes:
label: Does the bug occur with all other plugins disabled?
description: Helps rule out plugin conflicts.
options:
- "Yes"
- "No"
- "Didn't test"
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Anything Else?
description: Screenshots, screen recordings, related issues, vault setup details — whatever's relevant.

View file

@ -0,0 +1,59 @@
name: Feature Request
description: Suggest a new feature or improvement for the plugin
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Before submitting: search existing issues to make sure this hasn't already been requested or discussed.
- type: textarea
id: problem
attributes:
label: What problem does this solve?
description: Describe the workflow friction or limitation you're running into. Don't jump straight to your solution yet.
placeholder: "When I try to do X, I have to Y which is annoying because..."
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed solution
description: What would you like to see added or changed? Be as specific as you can.
placeholder: "It would help if the plugin could..."
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives you've considered
description: Any workarounds you're currently using, or other approaches you've thought about.
placeholder: "I've tried X but it falls short because..."
- type: dropdown
id: scope
attributes:
label: How impactful would this be for your workflow?
options:
- Nice to have
- Would use it regularly
- Blocking my use of the plugin
validations:
required: true
- type: checkboxes
id: checklist
attributes:
label: Before submitting
options:
- label: I've searched existing issues and this hasn't been requested before
required: true
- label: This is specific to this plugin, not a general Obsidian feature request
- type: textarea
id: context
attributes:
label: Additional context
description: Screenshots, mockups, links to similar features in other tools — anything that helps illustrate the request.

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

@ -1,7 +1,7 @@
# AutoMover Plugin
This plugin is used for designating folders in which your files will be moved automatically.
It seeks to be an alternative to the "https://github.com/farux/obsidian-auto-note-mover" plugin from farux.
It seeks to be an alternative to the https://github.com/farux/obsidian-auto-note-mover plugin from farux.
The problem I had with that plugin was the lack of support for regex and regex groups in the destination paths.
Therefore, this plugin supports regex and regex groups to create the destination paths unless they already exist.
@ -19,11 +19,11 @@ Therefore, this plugin supports regex and regex groups to create the destination
## Documentation
- [UI Guide](docs/ui-guide.md) - Complete overview of the plugin interface and settings
- [Moving Rules](docs/moving-rules.md) - Filename-based rules with regex examples
- [Tag Rules](docs/tag-rules.md) - Filename-based rules with regex examples
- [Project Rules](docs/project-rules.md) - Organize files by project using frontmatter
- [Exclusion Rules](docs/exclusion-rules.md) - Protect specific files and folders
- [UI Guide](https://github.com/al0cam/AutoMover/blob/master/docs/ui-guide.md) - Complete overview of the plugin interface and settings
- [Moving Rules](https://github.com/al0cam/AutoMover/blob/master/docs/moving-rules.md) - Filename-based rules with regex examples
- [Tag Rules](https://github.com/al0cam/AutoMover/blob/master/docs/tag-rules.md) - Filename-based rules with regex examples
- [Project Rules](https://github.com/al0cam/AutoMover/blob/master/docs/project-rules.md) - Organize files by project using frontmatter
- [Exclusion Rules](https://github.com/al0cam/AutoMover/blob/master/docs/exclusion-rules.md) - Protect specific files and folders
## Quick Start

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());
}

View file

@ -96,6 +96,18 @@ export class SettingsTab extends PluginSettingTab {
}),
);
new Setting(containerEl)
.setName("Debug logging")
.setDesc(
"Log verbose debug messages to the developer console (Ctrl+Shift+I). Leave off unless diagnosing an issue.",
)
.addToggle((cb) =>
cb.setValue(this.plugin.settings.debugLogging).onChange(async (value) => {
this.plugin.settings.debugLogging = value;
await this.plugin.saveData(this.plugin.settings);
}),
);
// TUTORIAL START
/**
* Instead of using the default .setting-item class I created a custom class to add other styling,
@ -161,5 +173,6 @@ export class SettingsTab extends PluginSettingTab {
tagSection(containerEl, this.plugin, this.display);
exclusionSection(containerEl, this.plugin, this.display);
projectSection(containerEl, this.plugin, this.display);
};
}

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,6 +1,7 @@
import type { MovingRule } from "Models/MovingRule";
import { ProjectRule } from "Models/ProjectRule";
import type { TagCache, TFile } from "obsidian";
import loggerUtil from "Utils/LoggerUtil";
import type { TFile } from "obsidian";
class RuleMatcherUtil {
private static instance: 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);
@ -64,24 +70,24 @@ class RuleMatcherUtil {
* @param rules
* @returns MovingRule | null
*/
public getMatchingRuleByTag(tags: TagCache[], rules: MovingRule[]): MovingRule | null {
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;
}
const regex = this.getCompiledRegex(rule.regex);
for (const tag of tags) {
if (regex.test(tag.tag)) {
if (regex.test(tag)) {
return rule;
}
}
@ -99,16 +105,24 @@ class RuleMatcherUtil {
*/
public getGroupMatches(file: TFile, rule: MovingRule): RegExpMatchArray | null {
const regex = this.getCompiledRegex(rule.regex);
console.log("Compiled regex: ", regex);
// console.log("Compiled regex: ", regex);
const matches = file.name.match(regex);
return matches;
}
public getGroupMatchesForTags(tags: TagCache[], rule: MovingRule): RegExpMatchArray | null {
/**
* 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);
// console.log("Compiled regex: ", regex);
for (const tag of tags) {
const matches = tag.tag.match(regex);
const matches = tag.match(regex);
if (matches) {
return matches;
}
@ -125,12 +139,12 @@ class RuleMatcherUtil {
*/
private isValidRegex(pattern: string): boolean {
try {
console.log("Validating regex pattern: ", pattern);
// console.log("Validating regex pattern: ", pattern);
new RegExp(pattern);
return true;
} catch (e) {
if (e instanceof SyntaxError) {
console.error(`Invalid regex pattern: ${e.message}`);
loggerUtil.error(`Invalid regex pattern: ${e.message}`);
}
return false;
}

View file

@ -20,6 +20,25 @@ The numbers are elaborate below the image.
11. **Duplicate rule button**: This button will duplicate the selected rule from the list of rules.
## Notifications for the Moving Toggles
Each of the three moving triggers above (on-open, manual run, automatic timer) reports back differently:
- **On-open**: when opening a file actually triggers a move, a short notice is shown - `1 file moved.`, `1 file not moved - duplicate name.`, or `1 file failed to move.`. Opening a file that doesn't match any rule stays silent so normal navigation isn't interrupted.
- **Manual run** (ribbon icon or `AutoMover: Move files` command): always reports a summary, including `No files needed to be moved.` when nothing matched. This is the path to use when you want explicit confirmation of what happened.
- **Automatic timer**: only shows a notice when at least one file was actually moved on that tick. Duplicate-name skips and move failures are intentionally suppressed here, because those conditions persist across ticks - a filename that collides today will still collide on the next tick, and a rule with a bad destination will keep failing - so reporting them every interval would spam notices indefinitely. If you need the full breakdown of duplicates and failures, trigger a manual run.
## Debug Logging Toggle
Directly below the moving toggles there is a **Debug logging** toggle. It is placed there on purpose - the rule lists further down can grow long, and burying a developer-facing switch at the very bottom of the settings tab makes it awkward to reach. It is independent of the moving toggles above and controls verbose console output, not Obsidian notices.
- When **off** (the default), only error-level messages and the user-facing notices described above are produced. This is the right setting for normal use.
- When **on**, the plugin writes extra `debug` and `info` messages to the developer console (open it with `Ctrl+Shift+I` in Obsidian). These include things like which rule matched a given file, why a move was skipped as a duplicate, and the path a file was renamed to. None of this appears as a notice in the editor - you have to open the dev tools to see it.
Leave this off unless you are diagnosing an issue or filing a bug report. The verbose output is meant to make problems reproducible, not to be read during regular use.
## Export and Import
By default, if you are using some way of syncing your obsidian vaults, this doesn't provide anyting new for you.

163
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,25 +27,33 @@ 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();
// negative ifs for easier reading and debugging
if (!this.areMovingTriggersEnabled()) return;
if (!this.areThereRulesToApply()) return;
this.registerEvent(
this.app.workspace.on("file-open", (file: obsidian.TFile) => {
this.app.workspace.on("file-open", async (file: obsidian.TFile) => {
if (!this.settings.moveOnOpen) return;
if (file == null || file.path == null) return;
if (this.isFileExcluded(file)) return;
this.matchAndMoveFile(file);
const result = await this.matchAndMoveFile(file);
if (result === "moved") loggerUtil.infoNotice("1 file moved.");
else if (result === "duplicate")
loggerUtil.infoNotice("1 file not moved - duplicate name.");
else if (result === "invalid_path" || result === "error")
loggerUtil.infoNotice("1 file failed to move.");
}),
);
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();
}),
);
@ -64,7 +80,7 @@ export default class AutoMoverPlugin extends obsidian.Plugin {
// console.log("Automatic moving run");
if (!this.settings.automaticMoving) return;
if (this.settings.timer == null || this.settings.timer <= 0) return;
this.goThroughAllFiles();
this.goThroughAllFiles({ silentIfUnchanged: true });
timerUtil.startTimer(this.automaticMoving.bind(this), this.settings.timer);
}
@ -73,15 +89,36 @@ export default class AutoMoverPlugin extends obsidian.Plugin {
*
* @returns void
*/
goThroughAllFiles() {
async goThroughAllFiles(options: { silentIfUnchanged?: boolean } = {}) {
// 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++;
}
if (options.silentIfUnchanged && moved === 0) return;
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"));
}
/**
@ -98,90 +135,100 @@ 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 {
const tags = this.app.metadataCache.getFileCache(file)?.tags;
if (tags == null || tags.length === 0) return false;
async matchAndMoveByTag(file: obsidian.TFile): Promise<MoveOutcome | null> {
const cache = this.app.metadataCache.getFileCache(file);
if (cache == null) return null;
const tags = obsidian.getAllTags(cache);
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);
console.log("File: ", file.path);
console.log("Tag rule: ", tagRule);
console.log("Tag matches: ", matches);
// console.log("File: ", file.path);
// 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);
@ -190,20 +237,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");
}

View file

@ -1,9 +1,9 @@
{
"id": "auto-mover",
"name": "AutoMover",
"version": "1.0.7",
"version": "1.0.9",
"minAppVersion": "0.15.0",
"description": "Moves files with specified names into the same folder.",
"description": "Move files and notes with specified names into their designated folders according to rules you define.",
"author": "Al0cam",
"authorUrl": "https://github.com/al0cam",
"fundingUrl": "",

View file

@ -1,7 +1,7 @@
{
"name": "AutoMover",
"version": "1.0.7",
"description": "AutoMover: Moves files with specified names/tags where you want.",
"version": "1.0.9",
"description": "AutoMover: Move files and notes with specified names into their designated folders according to rules you define.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",

View file

@ -1,3 +1,4 @@
{
"1.0.7": "0.15.0"
"1.0.8": "0.15.0",
"1.0.9": "0.15.0"
}