Compare commits

...

29 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
Al0cam
ecba33e549 docs: add docs for tags and linked moving rules in projectRules 2025-11-10 09:37:49 +01:00
Al0cam
f875abfec8 bugfix: fix for regex groups in tags 2025-11-10 09:37:30 +01:00
Al0cam
fa556ce751 bugfix: work on tag regex and docs 2025-11-10 07:59:19 +01:00
Benjamin Filip Šikač
bdfe7d4549
Enhance UI guide with images and clarifications
Added images and clarified text for collapsible UI sections and project rules.
2025-11-08 13:10:01 +01:00
Al0cam
e577323446 fix: comment on console.logs 2025-11-08 13:03:26 +01:00
Al0cam
3a7410d070 feat: readme update, optimisations and default project behaviour 2025-11-08 12:57:19 +01:00
Al0cam
3927556663 feat: project rules implemented, readme remaining 2025-11-07 17:59:47 +01:00
Al0cam
7135c5181e feat: work on moving logic 2025-11-02 18:39:34 +01:00
Al0cam
b12444aeff feat: add css and new settings tab for project rules 2025-11-01 10:04:48 +01:00
Al0cam
ff721f4811 feat: part of project rules, models and basic rendering 2025-10-27 06:59:03 +01:00
Al0cam
a97fa797b4 feat: add collapse of rule lists 2025-10-25 20:02:46 +02:00
Al0cam
6e440b65b8 sketch: feature sketch 2025-10-16 06:57:50 +02:00
26 changed files with 1446 additions and 343 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 type { AutoMoverSettings } from "Settings/Settings";
import { Notice, TFile } from "obsidian"; import loggerUtil from "Utils/LoggerUtil";
import { TFile } from "obsidian";
import type { App } from "obsidian"; import type { App } from "obsidian";
class SettingsIO { class SettingsIO {
@ -8,6 +9,11 @@ class SettingsIO {
private constructor() {} private constructor() {}
/**
* Returns the singleton instance of SettingsIO, creating it on first access.
*
* @returns SettingsIO
*/
public static getInstance(): SettingsIO { public static getInstance(): SettingsIO {
if (!SettingsIO.instance) { if (!SettingsIO.instance) {
SettingsIO.instance = new SettingsIO(); SettingsIO.instance = new SettingsIO();
@ -15,6 +21,12 @@ class SettingsIO {
return SettingsIO.instance; 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 { public init(app: App): void {
this.app = app; this.app = app;
} }
@ -57,11 +69,10 @@ class SettingsIO {
const fs = require("node:fs"); const fs = require("node:fs");
fs.writeFileSync(filePath, settingsData); fs.writeFileSync(filePath, settingsData);
new Notice("Settings exported successfully"); loggerUtil.infoNotice("Settings exported successfully");
return true; return true;
} catch (error) { } catch (error) {
console.error("Failed to export settings:", error); loggerUtil.errorNotice("Failed to export settings", error);
new Notice("Failed to export settings");
return false; return false;
} }
} }
@ -77,11 +88,10 @@ class SettingsIO {
try { try {
const filename = "AutoMover_settings.json"; const filename = "AutoMover_settings.json";
await this.app.vault.create(filename, settingsData); await this.app.vault.create(filename, settingsData);
new Notice(`Settings exported to vault: ${filename}`); loggerUtil.infoNotice(`Settings exported to vault: ${filename}`);
return true; return true;
} catch (error) { } catch (error) {
console.error("Failed to export to vault:", error); loggerUtil.errorNotice("Failed to export settings to vault", error);
new Notice("Failed to export settings to vault");
return false; return false;
} }
} }
@ -123,15 +133,14 @@ class SettingsIO {
const importedSettings = JSON.parse(fileContent); const importedSettings = JSON.parse(fileContent);
if (!this.validateSettings(importedSettings)) { if (!this.validateSettings(importedSettings)) {
new Notice("Invalid settings file format"); loggerUtil.warnNotice("Invalid settings file format");
return null; return null;
} }
new Notice("Settings imported successfully"); loggerUtil.infoNotice("Settings imported successfully");
return importedSettings; return importedSettings;
} catch (error) { } catch (error) {
console.error("Failed to import settings:", error); loggerUtil.errorNotice("Failed to import settings", error);
new Notice("Failed to import settings");
return null; return null;
} }
} }
@ -148,7 +157,7 @@ class SettingsIO {
const file = this.app.vault.getAbstractFileByPath(filename); const file = this.app.vault.getAbstractFileByPath(filename);
if (!file || !(file instanceof TFile)) { if (!file || !(file instanceof TFile)) {
new Notice(`Could not find ${filename} in vault`); loggerUtil.warnNotice(`Could not find ${filename} in vault`);
return null; return null;
} }
@ -156,15 +165,14 @@ class SettingsIO {
const importedSettings = JSON.parse(fileContent); const importedSettings = JSON.parse(fileContent);
if (!this.validateSettings(importedSettings)) { if (!this.validateSettings(importedSettings)) {
new Notice("Invalid settings file format"); loggerUtil.warnNotice("Invalid settings file format");
return null; return null;
} }
new Notice("Settings imported from vault successfully"); loggerUtil.infoNotice("Settings imported from vault successfully");
return importedSettings; return importedSettings;
} catch (error) { } catch (error) {
console.error("Failed to import from vault:", error); loggerUtil.errorNotice("Failed to import settings from vault", error);
new Notice("Failed to import settings from vault");
return null; return null;
} }
} }

31
Models/ProjectRule.ts Normal file
View file

@ -0,0 +1,31 @@
import { MovingRule } from "./MovingRule";
/**
* Project rules for defining how files should be moved inside project folders
*/
export class ProjectRule {
/**
* The name of the project this rule belongs to
* e.g. "Project A"
* e.g. "Book collection"
* e.g. "Music albums 2025"
*/
public projectName: string;
/**
* Project destination folder
* e.g. "Projects/Project A"
* e.g. "Projects/Project B/
*/
public folder: string;
/**
* Rules for moving files inside the project folder
*/
public rules: Array<MovingRule>;
public collapsed: boolean;
constructor(projectName?: string, folder?: string, rules?: Array<MovingRule>, collapsed?: boolean) {
this.projectName = projectName || "";
this.folder = folder || "";
this.rules = rules || [];
this.collapsed = collapsed || false;
}
}

216
README.md
View file

@ -1,186 +1,62 @@
# AutoMover plugin # AutoMover Plugin
This plugin is used for designating folders in which your files will be moved automatically. 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. 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. Therefore, this plugin supports regex and regex groups to create the destination paths unless they already exist.
## How to use ## Features
Using the plugin doens't require any special setup. Just install it and you're good to go. - **Automatic file moving** based on filename patterns, tags, or project metadata
Also, you can use it without knowing what regex is, but it's recommended to learn it to get the most out of this plugin. - **Regex support** with capture groups for dynamic folder creation
- **Project-based organization** using frontmatter metadata
- **Tag-based rules** for organizing files by tags
- **Exclusion rules** to protect specific files/folders from being moved
- **Multiple triggers**: on file open, manual execution, or time-based automation
- **Collapsible UI** for better organization of complex rule sets
- **Import/Export** settings for easy backup and sharing
## Introduction to the UI ## Documentation
This image shows the UI of the plugin with numbers attached to each part of the UI. - [UI Guide](https://github.com/al0cam/AutoMover/blob/master/docs/ui-guide.md) - Complete overview of the plugin interface and settings
The numbers are elaborate below the image. - [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
![settingsUpdatedWithExportImport](https://github.com/user-attachments/assets/70819309-abf1-4537-8106-7705995a03ae) ## Quick Start
1. **Plugin location**: This is where you can access the plugin's settings. 1. Install the plugin from the Obsidian Community Plugins
2. **Export/Import**: This is where you can export and import the settings you have set up. 2. Open Settings → AutoMover
3. **On-open toggle button**: This button toggles whether the plugin will run when you open a file. 3. Create your first rule:
4. **Manual run button**: This button will run the mover manually for all the files in the obsidian vault. - **Search criteria**: Enter a filename pattern or regex (e.g., "Meeting")
5. **Automatic moving toggle and input**: This toggle and input will allow you to set a time interval in which the plugin will run automatically. If the time is not set it won't run automatically. - **Destination folder**: Enter the target folder path (e.g., "Work/Meetings")
6. **Quick Tutorial**: This is a quick tutorial and reminder on how to use the plugin. 4. Toggle "Move on open" to enable automatic moving
7. **Search criteria**: This is where you can input strings or regex that will be used to match the files you want to move. 5. Use the "Move files" button to apply rules to existing files
8. **Destination path**: This is where you can input the destination path for the files that match the search criteria.
9. **Add rule button**: This button will add a rule to the list of rules.
10. **Delete rule button**: This button will delete the selected rule from the list of rules.
11. **Duplicate rule button**: This button will duplicate the selected rule from the list of rules.
### Rule Priority
### Export and Import The plugin checks rules in this order:
By default, if you are using some way of syncing your obsidian vaults, this doesn't provide anyting new for you. 1. **Exclusion rules** - Files matching these are never moved
However, if you wish to transition your notes accross devices which aren't synced or accross vaults, this makes your life easier. 2. **Project rules** - Files with `Project` frontmatter match first
3. **Moving rules** - Filename-based pattern matching
4. **Tag rules** - Tag-based matching (if no filename rule matched)
The export and import buttons are used to export and import the settings you have set up. ## Installation
In case your device can't open a file manager for you to choose the destination and name for the file,
then it will save the settings in the root folder of your vault with the name "AutoMoverSettings.json".
**Obsidian doesn't show json files by default, so you will have to use a file manager to see the file.** ### From Obsidian Community Plugins (Recommended)
I didn't want the file to be popping out of your notes, I don't consider it more important than your notes. 1. Open Settings → Community Plugins
2. Browse and search for "AutoMover"
3. Click Install, then Enable
The same goes for the import button, if your device can't open a file manager for you to choose the file to import, ### Manual Installation
then it will look for the file "AutoMoverSettings.json" in the root folder of your vault. 1. Download the latest release from GitHub
2. Extract files to `.obsidian/plugins/AutoMover/`
3. Reload Obsidian
4. Enable the plugin in Settings → Community Plugins
Importing new settings will overwrite the current settings you have set up and there is no undo button. ## Requesting Features and Reporting Issues
Therefore, the best thing to do is to export the settings before importing new ones, in case you care about them.
### Timer and timed moving
The time displayed is an interval, 20:00:00 will execucte every 20 hours.
Other examples could include:
1. 00:05:00 -> triggers every five minutes
2. 00:00:05 -> triggers every five seconds
3. 72:30:00 -> triggers every 3 days and 30 minutes (if your Device is online and obsidian runnning for that long)
### Tag rules UI
From patch 1.0.6 onwards, you can move files using tags.
It takes into account the first rule that is a match,
**if it first matches with a FileName rule, it won't check the Tag rules.**
There are no pictures for this one, as it is identical to the previous ruleset.
### Exclusion rules UI
From patch 1.0.2 onwards, you can exclude files and folders from being moved.
The UI that is used for the exclusion rules is the same as the one used for the search criteria.
But here is a quick glance at how it looks:
![ImageOfExclusionRules](https://github.com/user-attachments/assets/d2d6e30b-c36f-4650-833f-46036ba864d4)
1. **Excluded folder or files**: This is where you can input the folder or file paths you want to exclude from being moved.
2. **Add rule button**: This button will add a rule to the list of rules.
3. **Delete rule button**: This button will delete the selected rule from the list of rules.
4. **Duplicate rule button**: This button will duplicate the selected rule from the list of rules.
### Command palette
From patch 1.0.4 onwards, you can access the plugin from the command palette.
With the available commands being:
- **AutoMover: Move files**: This command will run the mover manually for all the files in the obsidian vault.
### Sidebar (Ribbon)
From patch 1.0.4 onwards, you can access the plugin from the sidebar (ribbon).
In case you need something faster than the command palette, now you have the the option to use the button in the sidebar.
This is what it looks like:
![sidebarRibbon](https://github.com/user-attachments/assets/a632cecf-9113-45c2-947e-ab5ac85c47d9)
## Usage of moving rules
#### Important note 1: The text is case sensitive.
#### Important note 2: Use the "/" character to separate folders.
#### Writing Regex: The best tool for writing regex is Regex101 (https://regex101.com/)
### Examples without regex
Lets imagine you have three files called Scroll 1, Scroll 2 and Scroll 3.
Such as you can see below:
![BeforeBasicScrollRule](https://github.com/user-attachments/assets/32b37cd2-8233-4af0-9e77-79cecb0a3c78)
After you install the plugin, you can create a rule to move all files that start with "Scroll" to the "Scrolls" folder.
The rule would look like this:
![BasicScrollRule](https://github.com/user-attachments/assets/292885cf-8bac-4d9d-95f7-80aeae4ed2aa)
This will then create a folder called "Scrolls" and move all files that start with "Scroll" to that folder.
The end result will look like this:
![BasicResult](https://github.com/user-attachments/assets/a4cd919f-db48-4d92-80cd-ae881a2f1154)
### Example with the usage of regex and groups
#### Important note: The regex flavor used in this plugin is the ECMAScript(Javascript) flavor. The biggest difference between ECMAScript and PCRE2 is that {} brackets require the first value to be something ({0,1} -> zero or one of), while in PCRE2 it can be empty ({,1} -> zero or one of).
Imagine that you wanted to sort each scroll by its number into a separate folder.
You could use the following regex to achieve that:
![RegexGroupingScrolls](https://github.com/user-attachments/assets/d2336c5c-1c14-4dd3-85a6-563aa6c8bde9)
This will then create a folder for each scroll number and move the respective files to the correct folder.
The end result will look like this:
![RegexGroupingResult](https://github.com/user-attachments/assets/96da1cf2-0799-4984-ae5b-302d5ff35a8f)
## Usage of exclusion rules
The exclusion rules are used to exclude files and folders from being moved.
They go above the moving rules and are used to filter out files and folders that you don't want to be moved.
They syntax is the same as the one used for the moving rules but here you can find some exaples of how to use them anyway:
### Example without regex
Lets imagine you have some creatures you want to move into folders like this.
The list of creatures looks like this:
![ExclusionExample1Before](https://github.com/user-attachments/assets/a6f5a658-94de-4d78-9eb5-2342164d7cff)
And the rules you want to apply to them look like this:
![rulesToApplyRats](https://github.com/user-attachments/assets/83eef1c8-1874-465c-a356-ec2754f62f52)
After moving them you are left with this:
![Example1Sorted](https://github.com/user-attachments/assets/2c952393-3e0b-4ff6-885e-2af1e526d4ab)
And now you are thinking "Damn, despite all that rage, those are still rats in a cage..."
And because you saw how angry the rats were, you decided you don't want to include them in further sorting.
Therefore, a rule would look like this:
![ExcludedRat](https://github.com/user-attachments/assets/f4eaa39e-f7b4-43e1-9e34-b9dfe24eb6f7)
And the result would look like this:
![RatsUntrapped](https://github.com/user-attachments/assets/91de85b3-14e1-471b-91e1-bb677b714ae5)
### Example with regex
I don't have any funny examples for this one, but I thought it could be useful to have the opportunity to exclude files and folders using regex as well.
Let's imagine you are moving the following files:
![SystemFiles](https://github.com/user-attachments/assets/9bc0de03-8e3a-4a11-8b23-48b28cade020)
And you decided you want to exclude those that have numbers as a suffix.
Then your rules can look like this:
![SystemRules](https://github.com/user-attachments/assets/c1779dee-2c13-459c-a11d-4f39fd49c3de)
And the result would look like this:
![SystemResult](https://github.com/user-attachments/assets/b5c2b915-7a3d-41f5-a91a-249f57f88d86)
A sidenote for this is that regex can by default be used to manage exclusions as well.
So the current example of exclusion could have been handled with a slight change to the moving rule as well.
The rule would looks like this "System [^\d\W]*" and the result would be the same.
## Requesting features and reporting issues
If you want to request a feature or report an issue, please do so by creating an issue in the issues tab of this repository. If you want to request a feature or report an issue, please do so by creating an issue in the issues tab of this repository.
In case you for some reason want to contact me directly, you can do so by sending me an email which is displayed in my github profile or via LinkedIn which is also displayed in my github profile. In case you for some reason want to contact me directly, you can do so by sending me an email which is displayed in my github profile or via LinkedIn which is also displayed in my github profile.
@ -191,7 +67,7 @@ If you want to contribute to this plugin, you can do so by forking this reposito
Thank you! Thank you!
## Future plans ## Future Plans
- [x] Add excluded folder support - [x] Add excluded folder support
- [x] Add excluded file support - [x] Add excluded file support
@ -206,3 +82,9 @@ Thank you!
- [ ] Add undo button to the notification popup for the moved files - [ ] Add undo button to the notification popup for the moved files
- [x] Add collapse/expand all rules button
- [x] Add Project moving rules
- [x] Project name and destination path
- [x] Subfield that contain the moving rules for the project
- [x] Add project rules UI
- [x] Project rules business logic

View file

@ -2,20 +2,36 @@ import { ExclusionRule } from "Models/ExclusionRule";
import type AutoMoverPlugin from "main"; import type AutoMoverPlugin from "main";
import { Setting } from "obsidian"; import { Setting } from "obsidian";
export function exclusionSection( export function exclusionSection(containerEl: HTMLElement, plugin: AutoMoverPlugin, display: () => void) {
containerEl: HTMLElement, /**
plugin: AutoMoverPlugin, * Debounced save function to avoid excessive disk writes
display: () => void, */
) { let saveTimeout: NodeJS.Timeout | null = null;
const debouncedSave = () => {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
plugin.saveData(plugin.settings);
}, 500);
};
/** /**
* Header for excluded folders * Header for excluded folders
*/ */
const exclusionRuleContainer = containerEl.createDiv({ const exclusionRuleContainer = containerEl.createDiv({
cls: "moving_rules_container", cls: "moving_rules_container",
}); });
new Setting(exclusionRuleContainer).setName("Exclusion rules").setHeading();
const exclusionList = exclusionRuleContainer.createDiv({ // Class used from obdsidian's css for consistency
const exclusionRuleDetails = exclusionRuleContainer.createEl("details", {});
exclusionRuleDetails.createEl("summary", { text: "Exclusion rules", cls: ["setting-item-heading"] });
exclusionRuleDetails.open = !plugin.settings.collapseSections.exclusionRules;
exclusionRuleDetails.addEventListener("toggle", async () => {
plugin.settings.collapseSections.exclusionRules = !exclusionRuleDetails.open;
await plugin.saveData(plugin.settings);
});
const exclusionList = exclusionRuleDetails.createDiv({
cls: "rule_list", cls: "rule_list",
}); });
const exclusionHeader = exclusionList.createDiv({ const exclusionHeader = exclusionList.createDiv({
@ -45,10 +61,7 @@ export function exclusionSection(
cls: "rule_input", cls: "rule_input",
}).onchange = (e) => { }).onchange = (e) => {
exclusion.regex = (e.target as HTMLInputElement).value; exclusion.regex = (e.target as HTMLInputElement).value;
plugin.settings.exclusionRules.map((ef) => debouncedSave();
ef === exclusion ? exclusion : ef,
);
plugin.saveData(plugin.settings);
}; };
const duplicateExclusionButton = child.createEl("button", { const duplicateExclusionButton = child.createEl("button", {
@ -65,9 +78,7 @@ export function exclusionSection(
cls: "rule_button rule_button_remove", cls: "rule_button rule_button_remove",
}); });
deleteExclusionButton.addEventListener("click", () => { deleteExclusionButton.addEventListener("click", () => {
plugin.settings.exclusionRules = plugin.settings.exclusionRules.filter( plugin.settings.exclusionRules = plugin.settings.exclusionRules.filter((r) => r !== exclusion);
(r) => r !== exclusion,
);
display(); display();
}); });
} }

View file

@ -2,11 +2,18 @@ import AutoMoverPlugin from "main";
import { MovingRule } from "Models/MovingRule"; import { MovingRule } from "Models/MovingRule";
import { Setting } from "obsidian"; import { Setting } from "obsidian";
export default function movingRuleSection( export default function movingRuleSection(containerEl: HTMLElement, plugin: AutoMoverPlugin, display: () => void) {
containerEl: HTMLElement, /**
plugin: AutoMoverPlugin, * Debounced save function to avoid excessive disk writes
display: () => void, */
) { let saveTimeout: NodeJS.Timeout | null = null;
const debouncedSave = () => {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
plugin.saveData(plugin.settings);
}, 500);
};
/** /**
* Header of the rules * Header of the rules
*/ */
@ -14,16 +21,24 @@ export default function movingRuleSection(
cls: "moving_rules_container", cls: "moving_rules_container",
}); });
new Setting(movingRulesContainer).setName("Moving rules").setHeading(); // Class used from obdsidian's css for consistency
const movingRuleDetails = movingRulesContainer.createEl("details", {});
movingRuleDetails.createEl("summary", { text: "Moving rules", cls: ["setting-item-heading"] });
const ruleList = movingRulesContainer.createDiv({ cls: "rule_list" }); movingRuleDetails.open = !plugin.settings.collapseSections.movingRules;
movingRuleDetails.addEventListener("toggle", async () => {
plugin.settings.collapseSections.movingRules = !movingRuleDetails.open;
await plugin.saveData(plugin.settings);
});
const ruleList = movingRuleDetails.createDiv({ cls: "rule_list" });
const ruleHeader = ruleList.createDiv({ cls: "rule margig_right" }); const ruleHeader = ruleList.createDiv({ cls: "rule margig_right" });
ruleHeader.createEl("p", { ruleHeader.createEl("p", {
text: "Search criteria (string or regex)", text: "Search criteria (string or regex)",
cls: "rule_title", cls: "rule_title",
}); });
ruleHeader.createEl("p", { ruleHeader.createEl("p", {
text: "Folder (string that can contain regex groups)", text: "Folder (can contain regex groups)",
cls: "rule_title", cls: "rule_title",
}); });
@ -46,16 +61,14 @@ export default function movingRuleSection(
cls: "rule_input", cls: "rule_input",
}).onchange = (e) => { }).onchange = (e) => {
rule.regex = (e.target as HTMLInputElement).value; rule.regex = (e.target as HTMLInputElement).value;
plugin.settings.movingRules.map((r) => (r === rule ? rule : r)); debouncedSave();
plugin.saveData(plugin.settings);
}; };
child.createEl("input", { child.createEl("input", {
value: rule.folder, value: rule.folder,
cls: "rule_input", cls: "rule_input",
}).onchange = (e) => { }).onchange = (e) => {
rule.folder = (e.target as HTMLInputElement).value; rule.folder = (e.target as HTMLInputElement).value;
plugin.settings.movingRules.map((r) => (r === rule ? rule : r)); debouncedSave();
plugin.saveData(plugin.settings);
}; };
const duplicateRuleButton = child.createEl("button", { const duplicateRuleButton = child.createEl("button", {
@ -72,9 +85,7 @@ export default function movingRuleSection(
cls: "rule_button rule_button_remove", cls: "rule_button rule_button_remove",
}); });
deleteRuleButton.addEventListener("click", () => { deleteRuleButton.addEventListener("click", () => {
plugin.settings.movingRules = plugin.settings.movingRules.filter( plugin.settings.movingRules = plugin.settings.movingRules.filter((r) => r !== rule);
(r) => r !== rule,
);
display(); display();
}); });
} }

159
Settings/ProjectSection.ts Normal file
View file

@ -0,0 +1,159 @@
import { MovingRule } from "Models/MovingRule";
import * as obsidian from "obsidian";
import type AutoMoverPlugin from "main";
import { ProjectRule } from "Models/ProjectRule";
export function projectSection(containerEl: HTMLElement, plugin: AutoMoverPlugin, display: () => void) {
/**
* Debounced save function to avoid excessive disk writes
*/
let saveTimeout: NodeJS.Timeout | null = null;
const debouncedSave = () => {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
plugin.saveData(plugin.settings);
}, 500);
};
/**
* Header for project folders
*/
const projectRuleContainer = containerEl.createDiv({
cls: "moving_rules_container",
});
// Class used from obdsidian's css for consistency
const projectRuleDetails = projectRuleContainer.createEl("details", {});
projectRuleDetails.createEl("summary", { text: "Project rules", cls: ["setting-item-heading"] });
projectRuleDetails.open = !plugin.settings.collapseSections.projectRules;
projectRuleDetails.addEventListener("toggle", async () => {
plugin.settings.collapseSections.projectRules = !projectRuleDetails.open;
await plugin.saveData(plugin.settings);
});
const projectList = projectRuleDetails.createDiv({
cls: "rule_list",
});
const projectHeader = projectList.createDiv({
cls: "rule margig_right",
});
projectHeader.createEl("p", {
text: "Project name",
cls: "rule_title",
});
projectHeader.createEl("p", {
text: "Destination",
cls: "rule_title",
});
const addProjectButton = projectHeader.createEl("button", {
text: "+",
cls: "rule_button",
});
addProjectButton.addEventListener("click", () => {
plugin.settings.projectRules.push(new ProjectRule());
display();
});
/**
* List of project
*/
for (const project of plugin.settings.projectRules as ProjectRule[]) {
const child = projectList.createDiv({ cls: "project" });
// Class used from obdsidian's css for consistency
const movingRulesDetails = child.createEl("details", {});
const movingRulesSummary = movingRulesDetails.createEl("summary", {
cls: ["setting-item-heading", "rule"],
});
movingRulesDetails.open = !project.collapsed;
movingRulesDetails.addEventListener("toggle", async () => {
project.collapsed = !movingRulesDetails.open;
await plugin.saveData(plugin.settings);
});
movingRulesSummary.createEl("input", {
value: project.projectName,
cls: "rule_input",
}).onchange = (e) => {
project.projectName = (e.target as HTMLInputElement).value;
debouncedSave();
};
movingRulesSummary.createEl("input", {
value: project.folder,
cls: "rule_input",
}).onchange = (e) => {
project.folder = (e.target as HTMLInputElement).value;
debouncedSave();
};
const addRuleButton = movingRulesSummary.createEl("button", {
text: "+",
cls: "rule_button",
});
addRuleButton.addEventListener("click", () => {
project.rules.push(new MovingRule());
display();
});
const duplicateRuleButton = movingRulesSummary.createEl("button", {
text: "⿻",
cls: "rule_button rule_button_duplicate",
});
duplicateRuleButton.addEventListener("click", () => {
plugin.settings.projectRules.push(new ProjectRule(project.projectName, project.folder));
display();
});
const deleteRuleButton = movingRulesSummary.createEl("button", {
text: "x",
cls: "rule_button rule_button_remove",
});
deleteRuleButton.addEventListener("click", () => {
plugin.settings.projectRules = plugin.settings.projectRules.filter((p) => p !== project);
display();
});
const movingRules = movingRulesDetails.createDiv();
/**
* List of project rules
*/
for (const rule of project.rules) {
const child = movingRules.createDiv({ cls: "project_rule" });
child.createEl("input", {
value: rule.regex,
cls: "rule_input",
}).onchange = (e) => {
rule.regex = (e.target as HTMLInputElement).value;
debouncedSave();
};
child.createEl("input", {
value: rule.folder,
cls: "rule_input",
}).onchange = (e) => {
rule.folder = (e.target as HTMLInputElement).value;
debouncedSave();
};
const duplicateRuleButton = child.createEl("button", {
text: "⿻",
cls: "rule_button rule_button_duplicate",
});
duplicateRuleButton.addEventListener("click", () => {
project.rules.push(new MovingRule(rule.regex, rule.folder));
display();
});
const deleteRuleButton = child.createEl("button", {
text: "x",
cls: "rule_button rule_button_remove",
});
deleteRuleButton.addEventListener("click", () => {
project.rules = project.rules.filter((r) => r !== rule);
display();
});
}
}
}

View file

@ -1,6 +1,7 @@
import type AutoMoverPlugin from "main"; import type AutoMoverPlugin from "main";
import type { ExclusionRule } from "Models/ExclusionRule"; import type { ExclusionRule } from "Models/ExclusionRule";
import type { MovingRule } from "Models/MovingRule"; import type { MovingRule } from "Models/MovingRule";
import { ProjectRule } from "Models/ProjectRule";
export interface AutoMoverSettings { export interface AutoMoverSettings {
moveOnOpen: boolean; moveOnOpen: boolean;
@ -8,8 +9,17 @@ export interface AutoMoverSettings {
movingRules: MovingRule[]; movingRules: MovingRule[];
exclusionRules: ExclusionRule[]; exclusionRules: ExclusionRule[];
tagRules: MovingRule[]; tagRules: MovingRule[];
projectRules: ProjectRule[];
automaticMoving: boolean; automaticMoving: boolean;
timer: number | null; // in miliseconds timer: number | null; // in miliseconds
debugLogging: boolean;
collapseSections: {
tutorial: boolean;
movingRules: boolean;
exclusionRules: boolean;
tagRules: boolean;
projectRules: boolean;
};
} }
export const DEFAULT_SETTINGS: Partial<AutoMoverSettings> = { export const DEFAULT_SETTINGS: Partial<AutoMoverSettings> = {
@ -18,12 +28,25 @@ export const DEFAULT_SETTINGS: Partial<AutoMoverSettings> = {
movingRules: [], movingRules: [],
exclusionRules: [], exclusionRules: [],
tagRules: [], tagRules: [],
projectRules: [],
automaticMoving: false, automaticMoving: false,
timer: null, timer: null,
debugLogging: false,
collapseSections: {
tutorial: false,
movingRules: false,
exclusionRules: false,
tagRules: false,
projectRules: false,
},
}; };
function loadSettings( /**
AutoMoverPlugin: AutoMoverPlugin, * Loads the plugin settings by merging persisted data on top of DEFAULT_SETTINGS.
): Partial<AutoMoverSettings> { *
* @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()); return Object.assign({}, DEFAULT_SETTINGS, AutoMoverPlugin.loadData());
} }

View file

@ -5,6 +5,8 @@ import { type App, PluginSettingTab, Setting } from "obsidian";
import { exclusionSection } from "./ExclusionSection"; import { exclusionSection } from "./ExclusionSection";
import movingRuleSection from "./MovingRuleSection"; import movingRuleSection from "./MovingRuleSection";
import { tagSection } from "./TagSection"; import { tagSection } from "./TagSection";
import { groupCollapsed } from "console";
import { projectSection } from "./ProjectSection";
export class SettingsTab extends PluginSettingTab { export class SettingsTab extends PluginSettingTab {
plugin: AutoMoverPlugin; plugin: AutoMoverPlugin;
@ -94,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 // TUTORIAL START
/** /**
* Instead of using the default .setting-item class I created a custom class to add other styling, * Instead of using the default .setting-item class I created a custom class to add other styling,
@ -102,11 +116,23 @@ export class SettingsTab extends PluginSettingTab {
const tutorialContainer = containerEl.createDiv({ const tutorialContainer = containerEl.createDiv({
cls: "moving_rules_container", cls: "moving_rules_container",
}); });
new Setting(tutorialContainer).setName("Tutorial").setHeading();
// Class used from obdsidian's css for consistency
const tutorialDetails = tutorialContainer.createEl("details", {});
tutorialDetails.createEl("summary", { text: "Tutorial", cls: ["setting-item-heading"] });
tutorialDetails.open = !this.plugin.settings.collapseSections.tutorial;
tutorialDetails.addEventListener("toggle", async () => {
this.plugin.settings.collapseSections.tutorial = !tutorialDetails.open;
await this.plugin.saveData(this.plugin.settings);
});
const tutorialDetailsContainer = tutorialDetails.createDiv({});
/** /**
* Rule description and tutorial * Rule description and tutorial
*/ */
const description = tutorialContainer.createDiv(); const description = tutorialDetailsContainer.createDiv();
description.createSpan({ description.createSpan({
text: "This plugin allows you to move files to a specified folder based on the file name.", text: "This plugin allows you to move files to a specified folder based on the file name.",
@ -146,5 +172,7 @@ export class SettingsTab extends PluginSettingTab {
movingRuleSection(containerEl, this.plugin, this.display); movingRuleSection(containerEl, this.plugin, this.display);
tagSection(containerEl, this.plugin, this.display); tagSection(containerEl, this.plugin, this.display);
exclusionSection(containerEl, this.plugin, this.display); exclusionSection(containerEl, this.plugin, this.display);
projectSection(containerEl, this.plugin, this.display);
}; };
} }

View file

@ -2,20 +2,36 @@ import { MovingRule } from "Models/MovingRule";
import * as obsidian from "obsidian"; import * as obsidian from "obsidian";
import type AutoMoverPlugin from "main"; import type AutoMoverPlugin from "main";
export function tagSection( export function tagSection(containerEl: HTMLElement, plugin: AutoMoverPlugin, display: () => void) {
containerEl: HTMLElement, /**
plugin: AutoMoverPlugin, * Debounced save function to avoid excessive disk writes
display: () => void, */
) { let saveTimeout: NodeJS.Timeout | null = null;
const debouncedSave = () => {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
plugin.saveData(plugin.settings);
}, 500);
};
/** /**
* Header for excluded folders * Header for excluded folders
*/ */
const tagRuleContainer = containerEl.createDiv({ const tagRuleContainer = containerEl.createDiv({
cls: "moving_rules_container", cls: "moving_rules_container",
}); });
new obsidian.Setting(tagRuleContainer).setName("Tag rules").setHeading();
const tagList = tagRuleContainer.createDiv({ // Class used from obdsidian's css for consistency
const tagRuleDetails = tagRuleContainer.createEl("details", {});
tagRuleDetails.createEl("summary", { text: "Tag rules", cls: ["setting-item-heading"] });
tagRuleDetails.open = !plugin.settings.collapseSections.tagRules;
tagRuleDetails.addEventListener("toggle", async () => {
plugin.settings.collapseSections.tagRules = !tagRuleDetails.open;
await plugin.saveData(plugin.settings);
});
const tagList = tagRuleDetails.createDiv({
cls: "rule_list", cls: "rule_list",
}); });
const tagHeader = tagList.createDiv({ const tagHeader = tagList.createDiv({
@ -45,16 +61,14 @@ export function tagSection(
cls: "rule_input", cls: "rule_input",
}).onchange = (e) => { }).onchange = (e) => {
rule.regex = (e.target as HTMLInputElement).value; rule.regex = (e.target as HTMLInputElement).value;
plugin.settings.tagRules.map((r) => (r === rule ? rule : r)); debouncedSave();
plugin.saveData(plugin.settings);
}; };
child.createEl("input", { child.createEl("input", {
value: rule.folder, value: rule.folder,
cls: "rule_input", cls: "rule_input",
}).onchange = (e) => { }).onchange = (e) => {
rule.folder = (e.target as HTMLInputElement).value; rule.folder = (e.target as HTMLInputElement).value;
plugin.settings.tagRules.map((r) => (r === rule ? rule : r)); debouncedSave();
plugin.saveData(plugin.settings);
}; };
const duplicateRuleButton = child.createEl("button", { const duplicateRuleButton = child.createEl("button", {
@ -71,9 +85,7 @@ export function tagSection(
cls: "rule_button rule_button_remove", cls: "rule_button rule_button_remove",
}); });
deleteRuleButton.addEventListener("click", () => { deleteRuleButton.addEventListener("click", () => {
plugin.settings.tagRules = plugin.settings.tagRules.filter( plugin.settings.tagRules = plugin.settings.tagRules.filter((r) => r !== rule);
(r) => r !== rule,
);
display(); display();
}); });
} }

View file

@ -3,6 +3,7 @@ import type { TFile } from "obsidian";
class ExclusionMatcherUtil { class ExclusionMatcherUtil {
private static instance: ExclusionMatcherUtil; private static instance: ExclusionMatcherUtil;
private regexCache: Map<string, RegExp> = new Map();
private constructor() {} private constructor() {}
@ -13,6 +14,18 @@ class ExclusionMatcherUtil {
return ExclusionMatcherUtil.instance; return ExclusionMatcherUtil.instance;
} }
/**
* Gets a compiled regex from cache or creates and caches it
* @param pattern - The regex pattern string
* @returns RegExp
*/
private getCompiledRegex(pattern: string): RegExp {
if (!this.regexCache.has(pattern)) {
this.regexCache.set(pattern, new RegExp(pattern));
}
return this.regexCache.get(pattern)!;
}
/** /**
* This method is used to check if the file is excluded by the exclusion rule * This method is used to check if the file is excluded by the exclusion rule
* @param file - The file to be checked * @param file - The file to be checked
@ -21,7 +34,7 @@ class ExclusionMatcherUtil {
*/ */
isFilePathExcluded(file: TFile, exclusionRules: ExclusionRule[]): boolean { isFilePathExcluded(file: TFile, exclusionRules: ExclusionRule[]): boolean {
for (const rule of exclusionRules) { for (const rule of exclusionRules) {
const regex = new RegExp(rule.regex); const regex = this.getCompiledRegex(rule.regex);
if (regex.test(file.path)) { if (regex.test(file.path)) {
return true; return true;
} }

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"; 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 { class MovingUtil {
private static instance: MovingUtil; private static instance: MovingUtil;
private app: App; 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 file - File to be moved
* @param newPath - Destination path * @param newPath - Destination folder (the file's own name is appended automatically)
* @returns void * @returns a {@link MoveOutcome} describing what happened
*/ */
public moveFile(file: TFile, newPath: string): void { public async moveFile(file: TFile, newPath: string): Promise<MoveOutcome> {
if (this.isFolder(newPath)) { const targetPath = `${newPath}/${file.name}`;
this.app.vault.rename(file, `${newPath}/${file.name}`);
} else { if (file.path === targetPath) {
new Notice( return "no_op";
`Invalid destination path\n${newPath} is not a folder!\nCreating requested folder.`, }
5000,
); if (this.app.vault.getAbstractFileByPath(targetPath)) {
console.error( loggerUtil.debug(`Duplicate at "${targetPath}", skipping "${file.path}"`);
`Invalid destination path\n${newPath} is not a folder!\nCreating requested folder.`, return "duplicate";
); }
this.createMissingFolders(newPath).then(() => {
this.app.vault.rename(file, `${newPath}/${file.name}`); 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)) { if (this.isFolder(newPath)) {
this.app.vault.rename(folder, `${newPath}/${folder.name}`); this.app.vault.rename(folder, `${newPath}/${folder.name}`);
} else { } else {
new Notice(`Invalid destination path\n${newPath} is not a folder!`, 5000); loggerUtil.errorNotice(`Invalid destination path\n${newPath} is not a folder!`);
console.error(`Invalid destination path\n${newPath} is not a folder!`);
} }
} }
@ -91,8 +122,7 @@ class MovingUtil {
return folder; return folder;
}); });
} else { } else {
new Notice("Folder already exists", 5000); loggerUtil.errorNotice("Folder already exists");
console.error("Folder already exists");
} }
return null; return null;
} }

View file

@ -0,0 +1,48 @@
import { ProjectRule } from "Models/ProjectRule";
class ProjectMatcherUtil {
private static instance: ProjectMatcherUtil;
private constructor() {}
public static getInstance(): ProjectMatcherUtil {
if (!ProjectMatcherUtil.instance) {
ProjectMatcherUtil.instance = new ProjectMatcherUtil();
}
return ProjectMatcherUtil.instance;
}
/**
* Returns the first project rule that matches the file
* If no rule matches, returns null
* @param projectName
* @param projectRules
* @returns ProjectRule | null
*/
public getMatchingProjectRule(projectName: string, projectRules: ProjectRule[]): ProjectRule | null {
for (const projectRule of projectRules) {
if (projectRule.projectName === projectName) {
return projectRule;
}
}
return null;
}
/**
* Prepends the project folder to the destination path
* and checks whether the project folder ends with a slash
*
* @param projectRule
* @param subPath
* @returns string
*/
public constructProjectDestinationPath(projectRule: ProjectRule, subPath: string): string {
let projectFolder = projectRule.folder;
if (!projectFolder.endsWith("/")) {
projectFolder += "/";
}
return projectFolder + subPath;
}
}
const projectMatcherUtil = ProjectMatcherUtil.getInstance();
export default projectMatcherUtil;

View file

@ -1,11 +1,19 @@
import type { MovingRule } from "Models/MovingRule"; import type { MovingRule } from "Models/MovingRule";
import type { TagCache, TFile } from "obsidian"; import { ProjectRule } from "Models/ProjectRule";
import loggerUtil from "Utils/LoggerUtil";
import type { TFile } from "obsidian";
class RuleMatcherUtil { class RuleMatcherUtil {
private static instance: RuleMatcherUtil; private static instance: RuleMatcherUtil;
private regexCache: Map<string, RegExp> = new Map();
private constructor() {} private constructor() {}
/**
* Returns the singleton instance of RuleMatcherUtil, creating it on first access.
*
* @returns RuleMatcherUtil
*/
public static getInstance(): RuleMatcherUtil { public static getInstance(): RuleMatcherUtil {
if (!RuleMatcherUtil.instance) { if (!RuleMatcherUtil.instance) {
RuleMatcherUtil.instance = new RuleMatcherUtil(); RuleMatcherUtil.instance = new RuleMatcherUtil();
@ -13,6 +21,18 @@ class RuleMatcherUtil {
return RuleMatcherUtil.instance; return RuleMatcherUtil.instance;
} }
/**
* Gets a compiled regex from cache or creates and caches it
* @param pattern - The regex pattern string
* @returns RegExp
*/
private getCompiledRegex(pattern: string): RegExp {
if (!this.regexCache.has(pattern)) {
this.regexCache.set(pattern, new RegExp(pattern));
}
return this.regexCache.get(pattern)!;
}
/** /**
* Returns the first rule that matches the file * Returns the first rule that matches the file
* If no rule matches, returns null * If no rule matches, returns null
@ -24,18 +44,19 @@ class RuleMatcherUtil {
public getMatchingRuleByName(file: TFile, rules: MovingRule[]): MovingRule | null { public getMatchingRuleByName(file: TFile, rules: MovingRule[]): MovingRule | null {
for (const rule of rules) { for (const rule of rules) {
if (rule.regex == null || rule.regex === "") { 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; continue;
} }
if (rule.folder == null || rule.folder === "") { 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; continue;
} }
if (!this.isValidRegex(rule.regex)) { if (!this.isValidRegex(rule.regex)) {
console.error("Rule has an invalid regex: ", rule); loggerUtil.error("Rule has an invalid regex: ", rule);
continue; continue;
} }
if (file.name.match(rule.regex)) { const regex = this.getCompiledRegex(rule.regex);
if (regex.test(file.name)) {
return rule; return rule;
} }
} }
@ -49,23 +70,24 @@ class RuleMatcherUtil {
* @param rules * @param rules
* @returns MovingRule | null * @returns MovingRule | null
*/ */
public getMatchingRuleByTag(tags: TagCache[], rules: MovingRule[]): MovingRule | null { public getMatchingRuleByTag(tags: string[], rules: MovingRule[]): MovingRule | null {
for (const rule of rules) { for (const rule of rules) {
if (rule.regex == null || rule.regex === "") { 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; continue;
} }
if (rule.folder == null || rule.folder === "") { 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; continue;
} }
if (!this.isValidRegex(rule.regex)) { if (!this.isValidRegex(rule.regex)) {
console.error("Tag Rule has an invalid regex: ", rule); loggerUtil.error("Tag Rule has an invalid regex: ", rule);
continue; continue;
} }
const regex = this.getCompiledRegex(rule.regex);
for (const tag of tags) { for (const tag of tags) {
if (tag.tag.match(rule.regex)) { if (regex.test(tag)) {
return rule; return rule;
} }
} }
@ -82,10 +104,32 @@ class RuleMatcherUtil {
* @returns string[] * @returns string[]
*/ */
public getGroupMatches(file: TFile, rule: MovingRule): RegExpMatchArray | null { public getGroupMatches(file: TFile, rule: MovingRule): RegExpMatchArray | null {
const matches = file.name.match(rule.regex); const regex = this.getCompiledRegex(rule.regex);
// console.log("Compiled regex: ", regex);
const matches = file.name.match(regex);
return matches; 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);
for (const tag of tags) {
const matches = tag.match(regex);
if (matches) {
return matches;
}
}
return null;
}
/** /**
* Checks if the regex pattern is valid * Checks if the regex pattern is valid
* Meaning, syntax errors are caught, but not logical errors * Meaning, syntax errors are caught, but not logical errors
@ -95,11 +139,12 @@ class RuleMatcherUtil {
*/ */
private isValidRegex(pattern: string): boolean { private isValidRegex(pattern: string): boolean {
try { try {
// console.log("Validating regex pattern: ", pattern);
new RegExp(pattern); new RegExp(pattern);
return true; return true;
} catch (e) { } catch (e) {
if (e instanceof SyntaxError) { if (e instanceof SyntaxError) {
console.error(`Invalid regex pattern: ${e.message}`); loggerUtil.error(`Invalid regex pattern: ${e.message}`);
} }
return false; return false;
} }

52
docs/exclusion-rules.md Normal file
View file

@ -0,0 +1,52 @@
# Exclusion Rules
The exclusion rules are used to exclude files and folders from being moved.
They go above the moving rules and are used to filter out files and folders that you don't want to be moved.
They syntax is the same as the one used for the moving rules but here you can find some exaples of how to use them anyway:
## Example without Regex
Lets imagine you have some creatures you want to move into folders like this.
The list of creatures looks like this:
![ExclusionExample1Before](https://github.com/user-attachments/assets/a6f5a658-94de-4d78-9eb5-2342164d7cff)
And the rules you want to apply to them look like this:
![rulesToApplyRats](https://github.com/user-attachments/assets/83eef1c8-1874-465c-a356-ec2754f62f52)
After moving them you are left with this:
![Example1Sorted](https://github.com/user-attachments/assets/2c952393-3e0b-4ff6-885e-2af1e526d4ab)
And now you are thinking "Damn, despite all that rage, those are still rats in a cage..."
And because you saw how angry the rats were, you decided you don't want to include them in further sorting.
Therefore, a rule would look like this:
![ExcludedRat](https://github.com/user-attachments/assets/f4eaa39e-f7b4-43e1-9e34-b9dfe24eb6f7)
And the result would look like this:
![RatsUntrapped](https://github.com/user-attachments/assets/91de85b3-14e1-471b-91e1-bb677b714ae5)
## Example with Regex
I don't have any funny examples for this one, but I thought it could be useful to have the opportunity to exclude files and folders using regex as well.
Let's imagine you are moving the following files:
![SystemFiles](https://github.com/user-attachments/assets/9bc0de03-8e3a-4a11-8b23-48b28cade020)
And you decided you want to exclude those that have numbers as a suffix.
Then your rules can look like this:
![SystemRules](https://github.com/user-attachments/assets/c1779dee-2c13-459c-a11d-4f39fd49c3de)
And the result would look like this:
![SystemResult](https://github.com/user-attachments/assets/b5c2b915-7a3d-41f5-a91a-249f57f88d86)
A sidenote for this is that regex can by default be used to manage exclusions as well.
So the current example of exclusion could have been handled with a slight change to the moving rule as well.
The rule would looks like this "System [^\d\W]*" and the result would be the same.

39
docs/moving-rules.md Normal file
View file

@ -0,0 +1,39 @@
# Moving Rules
## Important Notes
#### Important note 1: The text is case sensitive.
#### Important note 2: Use the "/" character to separate folders.
#### Writing Regex: The best tool for writing regex is Regex101 (https://regex101.com/)
## Examples without Regex
Lets imagine you have three files called Scroll 1, Scroll 2 and Scroll 3.
Such as you can see below:
![BeforeBasicScrollRule](https://github.com/user-attachments/assets/32b37cd2-8233-4af0-9e77-79cecb0a3c78)
After you install the plugin, you can create a rule to move all files that start with "Scroll" to the "Scrolls" folder.
The rule would look like this:
![BasicScrollRule](https://github.com/user-attachments/assets/292885cf-8bac-4d9d-95f7-80aeae4ed2aa)
This will then create a folder called "Scrolls" and move all files that start with "Scroll" to that folder.
The end result will look like this:
![BasicResult](https://github.com/user-attachments/assets/a4cd919f-db48-4d92-80cd-ae881a2f1154)
## Example with Regex and Groups
#### Important note: The regex flavor used in this plugin is the ECMAScript(Javascript) flavor. The biggest difference between ECMAScript and PCRE2 is that {} brackets require the first value to be something ({0,1} -> zero or one of), while in PCRE2 it can be empty ({,1} -> zero or one of).
Imagine that you wanted to sort each scroll by its number into a separate folder.
You could use the following regex to achieve that:
![RegexGroupingScrolls](https://github.com/user-attachments/assets/d2336c5c-1c14-4dd3-85a6-563aa6c8bde9)
This will then create a folder for each scroll number and move the respective files to the correct folder.
The end result will look like this:
![RegexGroupingResult](https://github.com/user-attachments/assets/96da1cf2-0799-4984-ae5b-302d5ff35a8f)

54
docs/project-rules.md Normal file
View file

@ -0,0 +1,54 @@
# Project Rules
Project rules allow you to organize files by project using frontmatter metadata. Files with a `Project` or `project` field in their frontmatter will be matched against project rules first, before checking regular moving rules or tag rules.
For reference, frontmatter refers to the obsidian file properties defined at the top of a note, enclosed within triple dashes (`---`).
Reference to file properties: https://help.obsidian.md/properties
## How Project Rules Work
1. **Frontmatter matching**: Add a `Project` or `project` field to your file's frontmatter
2. **Project folder**: Each project rule has a base folder where all project files go
3. **Sub-rules**: Within each project, you can define moving rules that work just like regular moving rules (see [Moving Rules](moving-rules.md) for detailed examples). The only difference is that sub-rules place files within the project folder instead of the vault root.
4. **Fallback to project root**: If no sub-rule matches, the file is moved to the project root folder
## Project Rule Precedence
The plugin checks rules in this order:
1. Project rules (if file has `Project` frontmatter)
2. Moving rules (filename-based)
3. Tag rules (tag-based)
## Fallback Behavior
Files with project frontmatter will always end up in their project folder, even if no specific rule matches:
- **No sub-rules defined**: File goes to project root folder
- **No sub-rule matches**: File goes to project root folder
- **Sub-rule with empty folder or `./`**: File goes to project root folder
- **Sub-rule with specific folder**: File goes to that subfolder within the project
## Example
Imagine you have a project called "Book Collection" with the following structure:
**File frontmatter:**
```yaml
---
Project: Book Collection
---
```
**Project rule setup:**
- Project name: `Book Collection`
- Project folder: `Projects/Books`
- Sub-rules:
- Regex: `Chapter` → Folder: `Chapters`
- Regex: `Character` → Folder: `Characters`
- Regex: `Index` → Folder: `./`
**Results:**
- `Chapter 1.md``Projects/Books/Chapters/Chapter 1.md`
- `Character Notes.md``Projects/Books/Characters/Character Notes.md`
- `Index.md``Projects/Books/Index.md` (using `./` moves to project root)
- `Random Note.md``Projects/Books/Random Note.md` (no matching rule, goes to project root)

80
docs/tag-rules.md Normal file
View file

@ -0,0 +1,80 @@
# Tag Rules
Tag rules allow you to organize files based on their Obsidian tags. When a file contains tags, the plugin will check each tag against your defined tag rules and move the file accordingly.
## How Tag Rules Work
1. **Tag matching**: The plugin checks all tags in a file against your tag rules
2. **First match wins**: If multiple tag rules match, only the first matching rule is applied
3. **Tag format flexibility**: Tags are matched with or without the `#` prefix (both work)
4. **Regex support**: You can use regex patterns to match tags and extract groups for dynamic folder paths
## Rule Precedence
The plugin checks rules in this order:
1. Project rules (if file has `Project` frontmatter)
2. Moving rules (filename-based)
3. Tag rules (tag-based)
## Important Notes
#### Important note 1: The text is case sensitive.
#### Important note 2: Use the "/" character to separate folders.
#### Important note 3: The regex flavor used is ECMAScript (JavaScript). The biggest difference from PCRE2 is that {} brackets require the first value to be something ({0,1} works, but {,1} does not).
#### Writing Regex: The best tool for writing regex is Regex101 (https://regex101.com/)
## Examples without Regex
Let's imagine you have files tagged with #work, #personal, and #urgent.
**Tag rules setup:**
- Regex: `work` → Folder: `Work`
- Regex: `personal` → Folder: `Personal`
- Regex: `urgent` → Folder: `Urgent`
Note: You can use `#work` or just `work` in the regex field - both will match the tag `#work`.
**Results:**
- A file tagged with `#work` → moves to `Work/`
- A file tagged with `#personal` → moves to `Personal/`
- A file tagged with `#urgent` → moves to `Urgent/`
- A file tagged with `#work` and `#urgent` → moves to `Work/` (first matching rule)
## Example with Regex and Groups
Imagine you have files tagged with tags like `#project-alpha`, `#project-beta`, and `#project-gamma`.
You want to extract the project name and organize files into subfolders based on that name.
**Tag rule setup:**
- Regex: `#?project-(.+)` → Folder: `Projects/$1`
The regex pattern explained:
- `#?` - Optionally matches the `#` symbol
- `project-` - Matches the literal text "project-"
- `(.+)` - Captures one or more characters (the project name)
- `$1` - In the folder path, this is replaced with the captured group
**Results:**
- File tagged with `#project-alpha` → moves to `Projects/alpha/`
- File tagged with `#project-beta` → moves to `Projects/beta/`
- File tagged with `#project-gamma` → moves to `Projects/gamma/`
## Advanced Example with Multiple Groups
You can use multiple capture groups for more complex organization:
**Tag rule setup:**
- Regex: `#?([a-z]+)-([0-9]+)` → Folder: `$1/Year-$2`
**Results:**
- File tagged with `#archive-2023` → moves to `archive/Year-2023/`
- File tagged with `#draft-2024` → moves to `draft/Year-2024/`
## Tips
- **Test your regex**: Use Regex101.com to test patterns before adding them to the plugin
- **Tag priority**: If a file has multiple tags, organize your tag rules with the most specific patterns first
- **Combine with other rules**: Remember that project rules take priority over tag rules
- **Escape special characters**: If your tags contain regex special characters like `.` or `()`, escape them with a backslash

136
docs/ui-guide.md Normal file
View file

@ -0,0 +1,136 @@
# UI Guide
## Introduction to the UI
This image shows the UI of the plugin with numbers attached to each part of the UI.
The numbers are elaborate below the image.
![settingsUpdatedWithExportImport](https://github.com/user-attachments/assets/70819309-abf1-4537-8106-7705995a03ae)
1. **Plugin location**: This is where you can access the plugin's settings.
2. **Export/Import**: This is where you can export and import the settings you have set up.
3. **On-open toggle button**: This button toggles whether the plugin will run when you open a file.
4. **Manual run button**: This button will run the mover manually for all the files in the obsidian vault.
5. **Automatic moving toggle and input**: This toggle and input will allow you to set a time interval in which the plugin will run automatically. If the time is not set it won't run automatically.
6. **Quick Tutorial**: This is a quick tutorial and reminder on how to use the plugin.
7. **Search criteria**: This is where you can input strings or regex that will be used to match the files you want to move.
8. **Destination path**: This is where you can input the destination path for the files that match the search criteria.
9. **Add rule button**: This button will add a rule to the list of rules.
10. **Delete rule button**: This button will delete the selected rule from the list of rules.
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.
However, if you wish to transition your notes accross devices which aren't synced or accross vaults, this makes your life easier.
The export and import buttons are used to export and import the settings you have set up.
In case your device can't open a file manager for you to choose the destination and name for the file,
then it will save the settings in the root folder of your vault with the name "AutoMoverSettings.json".
**Obsidian doesn't show json files by default, so you will have to use a file manager to see the file.**
I didn't want the file to be popping out of your notes, I don't consider it more important than your notes.
The same goes for the import button, if your device can't open a file manager for you to choose the file to import,
then it will look for the file "AutoMoverSettings.json" in the root folder of your vault.
Importing new settings will overwrite the current settings you have set up and there is no undo button.
Therefore, the best thing to do is to export the settings before importing new ones, in case you care about them.
## Timer and Timed Moving
The time displayed is an interval, 20:00:00 will execucte every 20 hours.
Other examples could include:
1. 00:05:00 -> triggers every five minutes
2. 00:00:05 -> triggers every five seconds
3. 72:30:00 -> triggers every 3 days and 30 minutes (if your Device is online and obsidian runnning for that long)
## Collapsible UI Sections
From patch 1.0.7 onwards, all rule sections can be collapsed and expanded for better organization (image below).
Each section (Tutorial, Moving Rules, Exclusion Rules, Tag Rules, and Project Rules) can be individually collapsed by clicking on the section header.
Additionally, individual project rules can be collapsed to hide their sub-rules, making it easier to manage multiple projects without cluttering the interface.
The collapse state is saved automatically, so your preferred view will persist between sessions.
<img width="1548" height="475" alt="image" src="https://github.com/user-attachments/assets/a8ef8f50-196c-4658-ae3d-9dba2daf896a" />
## Project Rules UI
From patch 1.0.7 onwards, you can use project rules to group moving rules together.
And to move files based on the project they belong to.
Project rules take precedence over the normal moving rules considering they are way more specific.
Each project rule can be collapsed individually to hide its sub-rules, and the entire Project Rules section can also be collapsed.
<img width="1531" height="611" alt="image" src="https://github.com/user-attachments/assets/c74d13de-6a2f-48f7-bc48-3e25f0f607e9" />
1. **Add Project rule button**: This button will add a project rule to the list of project rules.
2. **Delete rule button**: This button will delete the selected project rule from the list of rules.
3. **Duplicate rule button**: This button will duplicate the selected project rule from the list of rules.
4. **Collapse/Expand arrow**: This arrow will collapse or expand the project rule to hide or show its sub-rules.
5. **Add moving rule button**: This button will add a moving rule to the selected project rule.
6. **Delete moving rule button**: This button will delete the selected moving rule from the project rule.
7. **Duplicate moving rule button**: This button will duplicate the selected moving rule
On the picture you can see also an expanded project rule with two moving rules inside it.
And below it a collapsed project rule.
## Tag Rules UI
From patch 1.0.6 onwards, you can move files using tags.
It takes into account the first rule that is a match,
**if it first matches with a FileName rule, it won't check the Tag rules.**
There are no pictures for this one, as it is identical to the previous ruleset.
## Exclusion Rules UI
From patch 1.0.2 onwards, you can exclude files and folders from being moved.
The UI that is used for the exclusion rules is the same as the one used for the search criteria.
But here is a quick glance at how it looks:
![ImageOfExclusionRules](https://github.com/user-attachments/assets/d2d6e30b-c36f-4650-833f-46036ba864d4)
1. **Excluded folder or files**: This is where you can input the folder or file paths you want to exclude from being moved.
2. **Add rule button**: This button will add a rule to the list of rules.
3. **Delete rule button**: This button will delete the selected rule from the list of rules.
4. **Duplicate rule button**: This button will duplicate the selected rule from the list of rules.
## Command Palette
From patch 1.0.4 onwards, you can access the plugin from the command palette.
With the available commands being:
- **AutoMover: Move files**: This command will run the mover manually for all the files in the obsidian vault.
## Sidebar (Ribbon)
From patch 1.0.4 onwards, you can access the plugin from the sidebar (ribbon).
In case you need something faster than the command palette, now you have the the option to use the button in the sidebar.
This is what it looks like:
![sidebarRibbon](https://github.com/user-attachments/assets/a632cecf-9113-45c2-947e-ab5ac85c47d9)

185
main.ts
View file

@ -2,14 +2,23 @@ import settingsIO from "IO/SettingsIO";
import * as Settings from "Settings/Settings"; import * as Settings from "Settings/Settings";
import { SettingsTab } from "Settings/SettingsTab"; import { SettingsTab } from "Settings/SettingsTab";
import exclusionMatcherUtil from "Utils/ExclusionMatcherUtil"; 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 ruleMatcherUtil from "Utils/RuleMatcherUtil";
import timerUtil from "Utils/TimerUtil"; import timerUtil from "Utils/TimerUtil";
import * as obsidian from "obsidian"; import * as obsidian from "obsidian";
import projectMatcherUtil from "Utils/ProjectMatcherUtil";
export default class AutoMoverPlugin extends obsidian.Plugin { export default class AutoMoverPlugin extends obsidian.Plugin {
settings: Settings.AutoMoverSettings; 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() { async onload() {
// console.log("loading plugin"); // console.log("loading plugin");
movingUtil.init(this.app); movingUtil.init(this.app);
@ -18,25 +27,33 @@ export default class AutoMoverPlugin extends obsidian.Plugin {
* Loading settings and creating the settings tab * Loading settings and creating the settings tab
*/ */
this.settings = Object.assign({}, Settings.DEFAULT_SETTINGS, await this.loadData()); this.settings = Object.assign({}, Settings.DEFAULT_SETTINGS, await this.loadData());
loggerUtil.init(this);
this.addSettingTab(new SettingsTab(this.app, this)); this.addSettingTab(new SettingsTab(this.app, this));
this.automaticMoving();
// negative ifs for easier reading and debugging // negative ifs for easier reading and debugging
if (!this.areMovingTriggersEnabled()) return; if (!this.areMovingTriggersEnabled()) return;
if (!this.areThereRulesToApply()) return; if (!this.areThereRulesToApply()) return;
this.registerEvent( 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 (!this.settings.moveOnOpen) return;
if (file == null || file.path == null) return; if (file == null || file.path == null) return;
if (this.isFileExcluded(file)) 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( this.registerEvent(
// since i am defining my own event, ts-lint is crying about it but it works in the end // 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", () => { this.app.workspace.on("AutoMover:automatic-moving-update", () => {
// console.log("Automatic moving update"); loggerUtil.debug("Automatic moving update");
this.automaticMoving(); this.automaticMoving();
}), }),
); );
@ -63,7 +80,7 @@ export default class AutoMoverPlugin extends obsidian.Plugin {
// console.log("Automatic moving run"); // console.log("Automatic moving run");
if (!this.settings.automaticMoving) return; if (!this.settings.automaticMoving) return;
if (this.settings.timer == null || this.settings.timer <= 0) 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); timerUtil.startTimer(this.automaticMoving.bind(this), this.settings.timer);
} }
@ -72,15 +89,36 @@ export default class AutoMoverPlugin extends obsidian.Plugin {
* *
* @returns void * @returns void
*/ */
goThroughAllFiles() { async goThroughAllFiles(options: { silentIfUnchanged?: boolean } = {}) {
// console.log("Going through all files"); // console.log("Going through all files");
const files = this.app.vault.getFiles(); const candidates: obsidian.TFile[] = [];
for (const file of files) { for (const file of this.app.vault.getFiles()) {
if (file == null || file.path == null) continue; if (file == null || file.path == null) continue;
if (this.isFileExcluded(file)) 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"));
} }
/** /**
@ -97,65 +135,128 @@ 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 * @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); // console.log("Moving file: ", file.path);
if (this.matchAndMoveByName(file)) return; const projectResult = await this.matchAndMoveByProject(file);
else this.matchAndMoveByTag(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 * @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); 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)) { if (ruleMatcherUtil.isRegexGrouped(rule)) {
const matches = ruleMatcherUtil.getGroupMatches(file, rule); const matches = ruleMatcherUtil.getGroupMatches(file, rule);
const finalDestinationPath = ruleMatcherUtil.constructFinalDesinationPath(rule, matches!); const finalDestinationPath = ruleMatcherUtil.constructFinalDesinationPath(rule, matches!);
movingUtil.moveFile(file, finalDestinationPath); return movingUtil.moveFile(file, finalDestinationPath);
} else {
movingUtil.moveFile(file, rule.folder);
} }
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 * @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 tags = this.app.metadataCache.getFileCache(file)?.tags; const cache = this.app.metadataCache.getFileCache(file);
if (tags == null || tags.length === 0) return false; 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); 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)) { if (ruleMatcherUtil.isRegexGrouped(tagRule)) {
const matches = ruleMatcherUtil.getGroupMatches(file, tagRule); const matches = ruleMatcherUtil.getGroupMatchesForTags(tags, tagRule);
// console.log("File: ", file.path);
// console.log("Tag rule: ", tagRule);
// console.log("Tag matches: ", matches);
const finalDestinationPath = ruleMatcherUtil.constructFinalDesinationPath(tagRule, matches!); const finalDestinationPath = ruleMatcherUtil.constructFinalDesinationPath(tagRule, matches!);
movingUtil.moveFile(file, finalDestinationPath); return movingUtil.moveFile(file, finalDestinationPath);
} else {
movingUtil.moveFile(file, tagRule.folder);
} }
return true; return movingUtil.moveFile(file, tagRule.folder);
} }
/**
* 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 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 null;
// If no rules defined, move to project root
if (projectRule.rules == null || projectRule.rules.length === 0) {
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 === "./") {
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);
if (ruleMatcherUtil.isRegexGrouped(rule)) {
const matches = ruleMatcherUtil.getGroupMatches(file, rule);
const ruleDesinationPath = ruleMatcherUtil.constructFinalDesinationPath(rule, matches!);
const finalDestinationPath = projectMatcherUtil.constructProjectDestinationPath(projectRule, ruleDesinationPath);
return movingUtil.moveFile(file, finalDestinationPath);
}
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() { async asyncloadSettings() {
this.settings = Object.assign({}, Settings.DEFAULT_SETTINGS, await this.loadData()); 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() { async onunload() {
// console.log("unloading plugin"); // console.log("unloading plugin");
} }
@ -176,9 +277,21 @@ export default class AutoMoverPlugin extends obsidian.Plugin {
areThereRulesToApply(): boolean { areThereRulesToApply(): boolean {
return ( return (
(this.settings.movingRules.length > 0 && (this.settings.movingRules.length > 0 &&
this.settings.movingRules.every((rule) => rule.regex !== "" && rule.folder !== "")) || this.settings.movingRules.some((rule) => rule.regex !== "" && rule.folder !== "")) ||
(this.settings.tagRules.length > 0 && (this.settings.tagRules.length > 0 &&
this.settings.tagRules.every((rule) => rule.regex !== "" && rule.folder !== "")) this.settings.tagRules.some((rule) => rule.regex !== "" && rule.folder !== "")) ||
this.areThereProjectRulesToApply()
);
}
/**
* If there are no project rules to apply, then there is no point in checking for them
* @returns boolean
*/
areThereProjectRulesToApply(): boolean {
return (
this.settings.projectRules.length > 0 &&
this.settings.projectRules.some((projectRule) => projectRule.projectName !== "" && projectRule.folder !== "")
); );
} }

View file

@ -1,9 +1,9 @@
{ {
"id": "auto-mover", "id": "auto-mover",
"name": "AutoMover", "name": "AutoMover",
"version": "1.0.6", "version": "1.0.9",
"minAppVersion": "0.15.0", "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", "author": "Al0cam",
"authorUrl": "https://github.com/al0cam", "authorUrl": "https://github.com/al0cam",
"fundingUrl": "", "fundingUrl": "",

View file

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

View file

@ -8,71 +8,118 @@ If your plugin does not need CSS, delete this file.
*/ */
.moving_rules_container { .moving_rules_container {
padding: 0.75em 0; padding: 0.75em 0;
border-top: 1px solid var(--background-modifier-border); border-top: 1px solid var(--background-modifier-border);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.rule { .rule {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
margin-bottom: 0.5em; margin-bottom: 0.5em;
justify-content: space-evenly; justify-content: space-evenly;
gap: 0.5em; gap: 0.5em;
} }
.rule_header { .rule_header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
width: 100%; width: 100%;
} }
.rule_title { .rule_title {
padding: 0.5em; padding: 0.5em;
width: 100%; width: 100%;
font-weight: bold; font-weight: bold;
} }
.rule_input { .rule_input {
width: 100%; width: 100%;
padding: 0.5em; padding: 0.5em;
border: 1px solid var(--background-modifier-border); border: 1px solid var(--background-modifier-border);
border-radius: 0.25em; border-radius: 0.25em;
} }
.rule_button { .rule_button {
cursor: pointer; cursor: pointer;
} }
.rule_button:hover { .rule_button:hover {
background-color: var(--color-green); background-color: var(--color-green);
} }
.rule_button:active { .rule_button:active {
background-color: var(--background-modifier-active); background-color: var(--background-modifier-active);
} }
.rule_button_remove:hover { .rule_button_remove:hover {
background-color: var(--color-red) !important; background-color: var(--color-red) !important;
} }
.rule_button_duplicate:hover { .rule_button_duplicate:hover {
background-color: var(--color-blue) !important; background-color: var(--color-blue) !important;
} }
/* Style for timer-setting input field */ /* Style for timer-setting input field */
.setting-item.timer-setting input[type="text"] { .setting-item.timer-setting input[type="text"] {
width: auto; width: auto;
min-width: 8ch; min-width: 8ch;
/* Minimum for hh:mm:ss */ /* Minimum for hh:mm:ss */
max-width: 16ch; max-width: 16ch;
/* Allows up to ~9999:59:59 */ /* Allows up to ~9999:59:59 */
/* padding: 2px 6px; */ /* padding: 2px 6px; */
font-family: monospace; font-family: monospace;
text-align: center; text-align: center;
/* box-sizing: content-box; */ /* box-sizing: content-box; */
}
.project {
display: flex;
flex-direction: column;
gap: 0.5em;
border-bottom: 1px solid var(--background-modifier-border);
padding-bottom: 0.75em;
padding-top: 0.75em;
}
.project_rule {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 0.5em;
margin-left: 3em;
justify-content: space-evenly;
gap: 0.5em;
}
summary {
/* Hides the marker in most browsers */
list-style: none;
/* Hides the marker in Safari/WebKit */
/* This is crucial for cross-browser consistency */
-webkit-appearance: none;
}
/* Add a custom icon using ::before */
summary::before {
content: "▶";
/* Closed state icon */
margin-right: 10px;
/* Position the arrow before the content */
display: inline-block;
transition: transform 0.2s;
/* Customize the look/color of the arrow */
color: #fff;
min-width: 12px;
/* Ensure it doesn't shift */
}
/* Rotate the icon when the details is open */
details[open] > summary::before {
transform: rotate(90deg);
/* Rotates the '▶' into a '▼' */
} }

View file

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