Compare commits

..

51 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
Benjamin Filip Šikač
0459f5f5cf
Merge pull request #4 from al0cam/tags
Tags
2025-09-27 19:30:05 +02:00
Al0cam
7f271e8046 chore: removed unused TagUtil 2025-09-27 19:27:22 +02:00
Al0cam
4a1a0950cb doc: updated readme, bumped version, moved tags above exclusions 2025-09-27 19:16:57 +02:00
Al0cam
c886373038 feat: added tag moving 2025-09-27 19:01:00 +02:00
Al0cam
c3144528b3 updated with callback to display() 2025-08-13 12:40:18 +02:00
Al0cam
c881ce7a4a updated settings tab, to add some rerendering options 2025-08-12 14:58:44 +02:00
Al0cam
20363baab6 initial creation of RuleIO 2025-08-11 11:16:18 +02:00
Al0cam
79c9de8af8 updated biome and readme 2025-08-11 11:15:05 +02:00
Al0cam
6c303e7391 added disclamiers about syncing and visibility of json 2025-04-28 10:36:25 +02:00
Benjamin Filip Šikač
bde43b95a7
Update README.md 2025-04-28 10:24:42 +02:00
Al0cam
555a224900 bumping version 2025-04-28 10:23:19 +02:00
Al0cam
ed5637692e updated readme with import export 2025-04-28 10:23:02 +02:00
Al0cam
2bc63e3872 feat: added export/import for obsidian settings 2025-04-28 08:58:59 +02:00
Al0cam
1e616ac1a3 removed * from obsidian import in favour of atomic imports 2025-04-28 08:58:12 +02:00
Benjamin Filip Šikač
050278aa5b
added disclaimer how the timer works 2025-04-27 20:29:13 +02:00
Benjamin Filip Šikač
dda385882c
Update README.md 2025-04-27 20:26:39 +02:00
Benjamin Filip Šikač
ee079d1107
Update README.md 2025-04-27 20:25:44 +02:00
Benjamin Filip Šikač
4d0d293c23
Update README.md 2025-04-17 19:13:27 +02:00
Al0cam
a058b61952 Bumped version 2025-04-17 19:10:35 +02:00
Al0cam
5ad3814661 Updated readme with latest changes 2025-04-17 19:02:41 +02:00
Al0cam
6f4cc32364 feat: added auto time based moving, ribbon and cmd access to manual 2025-04-17 18:16:09 +02:00
Al0cam
8bd4d68467 Updated backlog 2025-04-14 20:06:24 +02:00
30 changed files with 2105 additions and 451 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.

20
IO/RuleIO.ts Normal file
View file

@ -0,0 +1,20 @@
class SettingsIO {
private static instance: SettingsIO;
private constructor() {}
public static getInstance(): SettingsIO {
if (!SettingsIO.instance) {
SettingsIO.instance = new SettingsIO();
}
return SettingsIO.instance;
}
// reading from yaml
// reading from json
// reading from regular txt file
}
const settingsIO = SettingsIO.getInstance();
export default settingsIO;

202
IO/SettingsIO.ts Normal file
View file

@ -0,0 +1,202 @@
import type { AutoMoverSettings } from "Settings/Settings";
import loggerUtil from "Utils/LoggerUtil";
import { TFile } from "obsidian";
import type { App } from "obsidian";
class SettingsIO {
private static instance: SettingsIO;
private app: App;
private constructor() {}
/**
* Returns the singleton instance of SettingsIO, creating it on first access.
*
* @returns SettingsIO
*/
public static getInstance(): SettingsIO {
if (!SettingsIO.instance) {
SettingsIO.instance = new SettingsIO();
}
return SettingsIO.instance;
}
/**
* Stores the Obsidian app reference used for vault and dialog access.
*
* @param app - The Obsidian App instance
* @returns void
*/
public init(app: App): void {
this.app = app;
}
/**
* Exports the plugin settings to a JSON file.
*
* @param settings - The settings object to export.
* @returns A promise that resolves to true if the export was successful, false otherwise.
*/
public async exportSettings(settings: AutoMoverSettings) {
try {
if (!this.app) {
throw new Error("App reference not set");
}
const settingsData = JSON.stringify(settings, null, 2);
const electron = (window as any).require
? (window as any).require("electron")
: null;
const remote = electron ? electron.remote : null;
if (!remote || !remote.dialog) {
// Fall back to saving in vault if Electron APIs are not available
return this.exportToVault(settingsData);
}
const { canceled, filePath } = await remote.dialog.showSaveDialog({
title: "Export AutoMover Settings",
defaultPath: "AutoMover_settings.json",
filters: [{ name: "JSON Files", extensions: ["json"] }],
properties: ["createDirectory"],
});
if (canceled || !filePath) {
return false;
}
const fs = require("node:fs");
fs.writeFileSync(filePath, settingsData);
loggerUtil.infoNotice("Settings exported successfully");
return true;
} catch (error) {
loggerUtil.errorNotice("Failed to export settings", error);
return false;
}
}
/**
* Exports the settings to the vault.
* Exports the settings to a file named AutoMover_settings.json
*
* @param settingsData - The settings data to export.
* @returns A promise that resolves to true if the export was successful, false otherwise.
*/
private async exportToVault(settingsData: string): Promise<boolean> {
try {
const filename = "AutoMover_settings.json";
await this.app.vault.create(filename, settingsData);
loggerUtil.infoNotice(`Settings exported to vault: ${filename}`);
return true;
} catch (error) {
loggerUtil.errorNotice("Failed to export settings to vault", error);
return false;
}
}
/**
* Imports settings from a JSON file.
*
* @returns The imported settings or null if the import failed.
*/
public async importSettings(): Promise<AutoMoverSettings | null> {
try {
if (!this.app) {
throw new Error("App reference not set");
}
const electron = (window as any).require
? (window as any).require("electron")
: null;
const remote = electron ? electron.remote : null;
if (!remote || !remote.dialog) {
// Fall back to importing from vault if Electron APIs are not available
return this.importFromVault();
}
const { canceled, filePaths } = await remote.dialog.showOpenDialog({
title: "Import AutoMover Settings",
filters: [{ name: "JSON Files", extensions: ["json"] }],
properties: ["openFile"],
});
if (canceled || !filePaths || filePaths.length === 0) {
return null;
}
const fs = require("node:fs");
const fileContent = fs.readFileSync(filePaths[0], "utf8");
const importedSettings = JSON.parse(fileContent);
if (!this.validateSettings(importedSettings)) {
loggerUtil.warnNotice("Invalid settings file format");
return null;
}
loggerUtil.infoNotice("Settings imported successfully");
return importedSettings;
} catch (error) {
loggerUtil.errorNotice("Failed to import settings", error);
return null;
}
}
/**
* Fallback method to import settings from the vault.
* from the file AutoMover_settings.json
*
* @returns The imported settings or null if the import failed.
*/
private async importFromVault(): Promise<AutoMoverSettings | null> {
try {
const filename = "AutoMover_settings.json";
const file = this.app.vault.getAbstractFileByPath(filename);
if (!file || !(file instanceof TFile)) {
loggerUtil.warnNotice(`Could not find ${filename} in vault`);
return null;
}
const fileContent = await this.app.vault.read(file);
const importedSettings = JSON.parse(fileContent);
if (!this.validateSettings(importedSettings)) {
loggerUtil.warnNotice("Invalid settings file format");
return null;
}
loggerUtil.infoNotice("Settings imported from vault successfully");
return importedSettings;
} catch (error) {
loggerUtil.errorNotice("Failed to import settings from vault", error);
return null;
}
}
/**
* Validates the settings object to ensure it has the correct structure.
*
* @param settings - The settings object to validate.
* @return True if the settings object is valid, false otherwise.
*/
private validateSettings(settings: any): settings is AutoMoverSettings {
if (!settings) return false;
if (typeof settings.moveOnOpen !== "boolean") return false;
if (!Array.isArray(settings.movingRules)) return false;
if (!Array.isArray(settings.exclusionRules)) return false;
if (typeof settings.automaticMoving !== "boolean") return false;
if (settings.timer !== null && typeof settings.timer !== "number")
return false;
return true;
}
}
const settingsIO = SettingsIO.getInstance();
export default settingsIO;

View file

@ -1,6 +1,20 @@
/**
* Regex based rule for moving files
*/
export class MovingRule {
/**
* This filed is used for defining the regex with its group matchers
* This field is used for defining the regex with its group matchers
*
* This can contain the follwoing:
* - Names of files w/w/o extensions
* - Names of folders
* - Extensions of files
* - Regex strings w/w/o groups
* - Any combination of the above
*
* Tag update:
* - The regex can now hold tags as well, which can be used to match files based on their tags.
*
*/
public regex: string;
/**

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

178
README.md
View file

@ -1,140 +1,62 @@
# AutoMover plugin
# AutoMover Plugin
This plugin is used for designating folders in which your files will be moved automatically.
It seeks to be an alternative to the "https://github.com/farux/obsidian-auto-note-mover" plugin from farux.
It seeks to be an alternative to the https://github.com/farux/obsidian-auto-note-mover plugin from farux.
The problem I had with that plugin was the lack of support for regex and regex groups in the destination paths.
Therefore, this plugin supports regex and regex groups to create the destination paths unless they already exist.
## How to use
## Features
Using the plugin doens't require any special setup. Just install it and you're good to go.
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.
- **Automatic file moving** based on filename patterns, tags, or project metadata
- **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.
The numbers are elaborate below the image.
- [UI Guide](https://github.com/al0cam/AutoMover/blob/master/docs/ui-guide.md) - Complete overview of the plugin interface and settings
- [Moving Rules](https://github.com/al0cam/AutoMover/blob/master/docs/moving-rules.md) - Filename-based rules with regex examples
- [Tag Rules](https://github.com/al0cam/AutoMover/blob/master/docs/tag-rules.md) - Filename-based rules with regex examples
- [Project Rules](https://github.com/al0cam/AutoMover/blob/master/docs/project-rules.md) - Organize files by project using frontmatter
- [Exclusion Rules](https://github.com/al0cam/AutoMover/blob/master/docs/exclusion-rules.md) - Protect specific files and folders
![Explanation](https://github.com/user-attachments/assets/2c7ec7e0-f8e1-4f7f-ad12-c74e733be751)
## Quick Start
1. Install the plugin from the Obsidian Community Plugins
2. Open Settings → AutoMover
3. Create your first rule:
- **Search criteria**: Enter a filename pattern or regex (e.g., "Meeting")
- **Destination folder**: Enter the target folder path (e.g., "Work/Meetings")
4. Toggle "Move on open" to enable automatic moving
5. Use the "Move files" button to apply rules to existing files
1. **Plugin location**: This is where you can access the plugin's settings.
2. **On-open toggle button**: This button toggles whether the plugin will run when you open a file.
3. **Manual run button**: This button will run the mover manually for all the files in the obsidian vault.
4. **Quick Tutorial**: This is a quick tutorial and reminder on how to use the plugin.
5. **Search criteria**: This is where you can input strings or regex that will be used to match the files you want to move.
6. **Destination path**: This is where you can input the destination path for the files that match the search criteria.
7. **Add rule button**: This button will add a rule to the list of rules.
8. **Delete rule button**: This button will delete the selected rule from the list of rules.
9. **Duplicate rule button**: This button will duplicate the selected rule from the list of rules.
### Rule Priority
The plugin checks rules in this order:
1. **Exclusion rules** - Files matching these are never moved
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)
### 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:
## Installation
![ImageOfExclusionRules](https://github.com/user-attachments/assets/d2d6e30b-c36f-4650-833f-46036ba864d4)
### From Obsidian Community Plugins (Recommended)
1. Open Settings → Community Plugins
2. Browse and search for "AutoMover"
3. Click Install, then Enable
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.
### Manual Installation
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
## 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
## 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.
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.
@ -145,10 +67,24 @@ If you want to contribute to this plugin, you can do so by forking this reposito
Thank you!
## Future plans
## Future Plans
- [x] Add excluded folder support
- [x] Add excluded file support
- [x] Add regex support for excluded folders and files (must support language accents like ñ, á, š, đ, こ, 猫, etc.)
- [x] Add time based execution of rule sorting
- [x] Exposing the move files button to the left toolbar
- [x] Exposing the move files to the commands accessible via command palette
- [x] Add import and export of rules
- [x] Add #tag rule support
- [ ] Add a file like .gitignore which contains all the moving rules (i am assuming the list can grow quite big for some people)
- [ ] Auto tagging of moved files with the destination folder name (last folder in the path)
- [ ] 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

@ -0,0 +1,85 @@
import { ExclusionRule } from "Models/ExclusionRule";
import type AutoMoverPlugin from "main";
import { Setting } from "obsidian";
export function exclusionSection(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 excluded folders
*/
const exclusionRuleContainer = containerEl.createDiv({
cls: "moving_rules_container",
});
// 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",
});
const exclusionHeader = exclusionList.createDiv({
cls: "rule margig_right",
});
exclusionHeader.createEl("p", {
text: "Excluded folders or files (string or regex)",
cls: "rule_title",
});
const addExclusionButton = exclusionHeader.createEl("button", {
text: "+",
cls: "rule_button",
});
addExclusionButton.addEventListener("click", () => {
plugin.settings.exclusionRules.push(new ExclusionRule());
display();
});
/**
* List of excluded folders
*/
for (const exclusion of plugin.settings.exclusionRules) {
const child = exclusionList.createDiv({ cls: "rule" });
child.createEl("input", {
value: exclusion.regex,
cls: "rule_input",
}).onchange = (e) => {
exclusion.regex = (e.target as HTMLInputElement).value;
debouncedSave();
};
const duplicateExclusionButton = child.createEl("button", {
text: "⿻",
cls: "rule_button rule_button_duplicate",
});
duplicateExclusionButton.addEventListener("click", () => {
plugin.settings.exclusionRules.push(new ExclusionRule(exclusion.regex));
display();
});
const deleteExclusionButton = child.createEl("button", {
text: "x",
cls: "rule_button rule_button_remove",
});
deleteExclusionButton.addEventListener("click", () => {
plugin.settings.exclusionRules = plugin.settings.exclusionRules.filter((r) => r !== exclusion);
display();
});
}
}

View file

@ -0,0 +1,92 @@
import AutoMoverPlugin from "main";
import { MovingRule } from "Models/MovingRule";
import { Setting } from "obsidian";
export default function movingRuleSection(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 of the rules
*/
const movingRulesContainer = containerEl.createDiv({
cls: "moving_rules_container",
});
// Class used from obdsidian's css for consistency
const movingRuleDetails = movingRulesContainer.createEl("details", {});
movingRuleDetails.createEl("summary", { text: "Moving rules", cls: ["setting-item-heading"] });
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" });
ruleHeader.createEl("p", {
text: "Search criteria (string or regex)",
cls: "rule_title",
});
ruleHeader.createEl("p", {
text: "Folder (can contain regex groups)",
cls: "rule_title",
});
const addRuleButton = ruleHeader.createEl("button", {
text: "+",
cls: "rule_button",
});
addRuleButton.addEventListener("click", () => {
plugin.settings.movingRules.push(new MovingRule());
display();
});
/**
* List of rules
*/
for (const rule of plugin.settings.movingRules) {
const child = ruleList.createDiv({ cls: "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", () => {
plugin.settings.movingRules.push(new MovingRule(rule.regex, rule.folder));
display();
});
const deleteRuleButton = child.createEl("button", {
text: "x",
cls: "rule_button rule_button_remove",
});
deleteRuleButton.addEventListener("click", () => {
plugin.settings.movingRules = plugin.settings.movingRules.filter((r) => r !== rule);
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,12 +1,25 @@
import type AutoMoverPlugin from "main";
import type { ExclusionRule } from "Models/ExclusionRule";
import type { MovingRule } from "Models/MovingRule";
import { ProjectRule } from "Models/ProjectRule";
export interface AutoMoverSettings {
moveOnOpen: boolean;
// moveOnSave: boolean;
movingRules: MovingRule[];
exclusionRules: ExclusionRule[];
tagRules: MovingRule[];
projectRules: ProjectRule[];
automaticMoving: boolean;
timer: number | null; // in miliseconds
debugLogging: boolean;
collapseSections: {
tutorial: boolean;
movingRules: boolean;
exclusionRules: boolean;
tagRules: boolean;
projectRules: boolean;
};
}
export const DEFAULT_SETTINGS: Partial<AutoMoverSettings> = {
@ -14,10 +27,26 @@ export const DEFAULT_SETTINGS: Partial<AutoMoverSettings> = {
// moveOnSave: true,
movingRules: [],
exclusionRules: [],
tagRules: [],
projectRules: [],
automaticMoving: false,
timer: null,
debugLogging: false,
collapseSections: {
tutorial: false,
movingRules: false,
exclusionRules: false,
tagRules: false,
projectRules: false,
},
};
function loadSettings(
AutoMoverPlugin: AutoMoverPlugin,
): Partial<AutoMoverSettings> {
/**
* Loads the plugin settings by merging persisted data on top of DEFAULT_SETTINGS.
*
* @param AutoMoverPlugin - The plugin instance used to read persisted data
* @returns Partial<AutoMoverSettings>
*/
function loadSettings(AutoMoverPlugin: AutoMoverPlugin): Partial<AutoMoverSettings> {
return Object.assign({}, DEFAULT_SETTINGS, AutoMoverPlugin.loadData());
}

View file

@ -1,22 +1,52 @@
import settingsIO from "IO/SettingsIO";
import timerUtil from "Utils/TimerUtil";
import type AutoMoverPlugin from "main";
import { ExclusionRule } from "Models/ExclusionRule";
import { MovingRule } from "Models/MovingRule";
import * as obsidian from "obsidian";
import { type App, PluginSettingTab, Setting } from "obsidian";
import { exclusionSection } from "./ExclusionSection";
import movingRuleSection from "./MovingRuleSection";
import { tagSection } from "./TagSection";
import { groupCollapsed } from "console";
import { projectSection } from "./ProjectSection";
export class SettingsTab extends obsidian.PluginSettingTab {
export class SettingsTab extends PluginSettingTab {
plugin: AutoMoverPlugin;
constructor(app: obsidian.App, plugin: AutoMoverPlugin) {
constructor(app: App, plugin: AutoMoverPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
display = () => {
const { containerEl } = this;
containerEl.empty();
new obsidian.Setting(containerEl).setName("Automatic moving").setHeading();
new obsidian.Setting(containerEl)
new Setting(containerEl).setName("Automatic moving").setHeading();
new Setting(containerEl)
.setName("Export/Import of settings")
.setDesc(
`Import will replace the current settings.
If you aren't prompted to choose a location, then the file will be exported to/imported from the vault root as AutoMover_settings.json (json files aren't visible in the vault by default).`,
)
.addButton((button) => {
button.setButtonText("Export settings");
button.onClick(async () => {
settingsIO.exportSettings(this.plugin.settings);
});
})
.addButton((button) => {
button.setButtonText("Import settings");
button.onClick(async () => {
const importedSettings = await settingsIO.importSettings();
if (importedSettings) {
this.plugin.settings = importedSettings;
await this.plugin.saveData(this.plugin.settings);
this.plugin.app.workspace.trigger("AutoMover:automatic-moving-update", this.plugin.settings);
this.display();
}
});
});
new Setting(containerEl)
.setName("Move on open")
.setDesc("Should the file be moved when it is opened?")
.addToggle((cb) =>
@ -26,11 +56,9 @@ export class SettingsTab extends obsidian.PluginSettingTab {
}),
);
new obsidian.Setting(containerEl)
new Setting(containerEl)
.setName("Manually move")
.setDesc(
"Execute the command to go through your notes and move them according to the rules specified below.",
)
.setDesc("Execute the command to go through your notes and move them according to the rules specified below.")
.addButton((button) => {
button.setButtonText("Move files");
button.onClick(async () => {
@ -38,18 +66,47 @@ export class SettingsTab extends obsidian.PluginSettingTab {
});
});
// there is no default event to move on save, therefore, i'd need to define a new one
// running move on change could be an option, but it would produce an overhead in performance because of constant checking for changes
//
// new obsidian.Setting(containerEl)
// .setName("Move on save")
// .setDesc("Should the file be moved when it is saved?")
// .addToggle((cb) =>
// cb.setValue(this.plugin.settings.moveOnSave).onChange(async (value) => {
// this.plugin.settings.moveOnSave = value;
// await this.plugin.saveData(this.plugin.settings);
// }),
// );
const automaticMovingContainer = containerEl.createDiv({});
new Setting(automaticMovingContainer)
.setName("Automatic moving")
.setDesc(
`Execute a timed event that goes through all the files and moves them according to the rules specified below.
The formatting is hh:mm:ss (if set 00:05:00 it will execute every 5 minutes).
If the timer is set to 0, the automatic moving will do nothing.`,
)
.setClass("timer-setting")
.addToggle((cb) =>
cb.setValue(this.plugin.settings.automaticMoving).onChange(async (value) => {
this.plugin.settings.automaticMoving = value;
await this.plugin.saveData(this.plugin.settings);
this.app.workspace.trigger("AutoMover:automatic-moving-update", this.plugin.settings);
this.display();
}),
)
.addText((cb) =>
cb
.setDisabled(!this.plugin.settings.automaticMoving)
.setValue(timerUtil.formatTime(this.plugin.settings.timer))
.setPlaceholder("hh:mm:ss")
.onChange(async (value) => {
this.plugin.settings.timer = timerUtil.parseTimeToMs(value);
await this.plugin.saveData(this.plugin.settings);
this.app.workspace.trigger("AutoMover:automatic-moving-update", this.plugin.settings);
}),
);
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
/**
@ -59,11 +116,23 @@ export class SettingsTab extends obsidian.PluginSettingTab {
const tutorialContainer = containerEl.createDiv({
cls: "moving_rules_container",
});
new obsidian.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
*/
const description = tutorialContainer.createDiv();
const description = tutorialDetailsContainer.createDiv();
description.createSpan({
text: "This plugin allows you to move files to a specified folder based on the file name.",
@ -100,152 +169,10 @@ export class SettingsTab extends obsidian.PluginSettingTab {
example2.createSpan({ text: "Scrolls/$1", cls: "rule_title" });
// TUTORIAL END
// MOVING RULES START
/**
* Header of the rules
*/
const movingRulesContainer = containerEl.createDiv({
cls: "moving_rules_container",
});
movingRuleSection(containerEl, this.plugin, this.display);
tagSection(containerEl, this.plugin, this.display);
exclusionSection(containerEl, this.plugin, this.display);
projectSection(containerEl, this.plugin, this.display);
new obsidian.Setting(movingRulesContainer)
.setName("Moving rules")
.setHeading();
const ruleList = movingRulesContainer.createDiv({ cls: "rule_list" });
const ruleHeader = ruleList.createDiv({ cls: "rule margig_right" });
ruleHeader.createEl("p", {
text: "Search criteria (string or regex)",
cls: "rule_title",
});
ruleHeader.createEl("p", {
text: "Folder (string that can contain regex groups)",
cls: "rule_title",
});
const addRuleButton = ruleHeader.createEl("button", {
text: "+",
cls: "rule_button",
});
addRuleButton.addEventListener("click", () => {
this.plugin.settings.movingRules.push(new MovingRule());
// this is used to rerender the settings tab
this.display();
});
/**
* List of rules
*/
for (const rule of this.plugin.settings.movingRules) {
const child = ruleList.createDiv({ cls: "rule" });
child.createEl("input", {
value: rule.regex,
cls: "rule_input",
}).onchange = (e) => {
rule.regex = (e.target as HTMLInputElement).value;
this.plugin.settings.movingRules.map((r) => (r === rule ? rule : r));
this.plugin.saveData(this.plugin.settings);
};
child.createEl("input", {
value: rule.folder,
cls: "rule_input",
}).onchange = (e) => {
rule.folder = (e.target as HTMLInputElement).value;
this.plugin.settings.movingRules.map((r) => (r === rule ? rule : r));
this.plugin.saveData(this.plugin.settings);
};
const duplicateRuleButton = child.createEl("button", {
text: "⿻",
cls: "rule_button rule_button_duplicate",
});
duplicateRuleButton.addEventListener("click", () => {
this.plugin.settings.movingRules.push(
new MovingRule(rule.regex, rule.folder),
);
this.display();
});
const deleteRuleButton = child.createEl("button", {
text: "x",
cls: "rule_button rule_button_remove",
});
deleteRuleButton.addEventListener("click", () => {
this.plugin.settings.movingRules =
this.plugin.settings.movingRules.filter((r) => r !== rule);
this.display();
});
}
// MOVING RULES END
// EXCLUSION FOLDERS START
/**
* Header for excluded folders
*/
const exclusionRuleContainer = containerEl.createDiv({
cls: "moving_rules_container",
});
new obsidian.Setting(exclusionRuleContainer)
.setName("Exclusion rules")
.setHeading();
const exclusionList = exclusionRuleContainer.createDiv({
cls: "rule_list",
});
const exclusionHeader = exclusionList.createDiv({
cls: "rule margig_right",
});
exclusionHeader.createEl("p", {
text: "Excluded folders or files (string or regex)",
cls: "rule_title",
});
const addExclusionButton = exclusionHeader.createEl("button", {
text: "+",
cls: "rule_button",
});
addExclusionButton.addEventListener("click", () => {
this.plugin.settings.exclusionRules.push(new ExclusionRule());
this.display();
});
/**
* List of excluded folders
*/
for (const exclusion of this.plugin.settings.exclusionRules) {
const child = exclusionList.createDiv({ cls: "rule" });
child.createEl("input", {
value: exclusion.regex,
cls: "rule_input",
}).onchange = (e) => {
exclusion.regex = (e.target as HTMLInputElement).value;
this.plugin.settings.exclusionRules.map((ef) =>
ef === exclusion ? exclusion : ef,
);
this.plugin.saveData(this.plugin.settings);
};
const duplicateExclusionButton = child.createEl("button", {
text: "⿻",
cls: "rule_button rule_button_duplicate",
});
duplicateExclusionButton.addEventListener("click", () => {
this.plugin.settings.exclusionRules.push(
new ExclusionRule(exclusion.regex),
);
this.display();
});
const deleteExclusionButton = child.createEl("button", {
text: "x",
cls: "rule_button rule_button_remove",
});
deleteExclusionButton.addEventListener("click", () => {
this.plugin.settings.exclusionRules =
this.plugin.settings.exclusionRules.filter((r) => r !== exclusion);
this.display();
});
}
// EXCLUSION FOLDERS END
}
};
}

92
Settings/TagSection.ts Normal file
View file

@ -0,0 +1,92 @@
import { MovingRule } from "Models/MovingRule";
import * as obsidian from "obsidian";
import type AutoMoverPlugin from "main";
export function tagSection(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 excluded folders
*/
const tagRuleContainer = containerEl.createDiv({
cls: "moving_rules_container",
});
// 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",
});
const tagHeader = tagList.createDiv({
cls: "rule margig_right",
});
tagHeader.createEl("p", {
text: "Tag search criteria (string or regex)",
cls: "rule_title",
});
const addTagButton = tagHeader.createEl("button", {
text: "+",
cls: "rule_button",
});
addTagButton.addEventListener("click", () => {
plugin.settings.tagRules.push(new MovingRule());
display();
});
/**
* List of tag rules
*/
for (const rule of plugin.settings.tagRules) {
const child = tagList.createDiv({ cls: "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", () => {
plugin.settings.tagRules.push(new MovingRule(rule.regex, rule.folder));
display();
});
const deleteRuleButton = child.createEl("button", {
text: "x",
cls: "rule_button rule_button_remove",
});
deleteRuleButton.addEventListener("click", () => {
plugin.settings.tagRules = plugin.settings.tagRules.filter((r) => r !== rule);
display();
});
}
}

View file

@ -3,6 +3,7 @@ import type { TFile } from "obsidian";
class ExclusionMatcherUtil {
private static instance: ExclusionMatcherUtil;
private regexCache: Map<string, RegExp> = new Map();
private constructor() {}
@ -13,6 +14,18 @@ class ExclusionMatcherUtil {
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
* @param file - The file to be checked
@ -20,12 +33,8 @@ class ExclusionMatcherUtil {
* @returns true if the file path is excluded, false otherwise
*/
isFilePathExcluded(file: TFile, exclusionRules: ExclusionRule[]): boolean {
console.log("Exclusion rules: ", exclusionRules);
for (const rule of exclusionRules) {
const regex = new RegExp(rule.regex);
console.log("Regex: ", regex);
console.log("File path: ", file.path);
console.log("Result: ", regex.test(file.path));
const regex = this.getCompiledRegex(rule.regex);
if (regex.test(file.path)) {
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,8 +1,21 @@
import * as obsidian from "obsidian";
import loggerUtil from "Utils/LoggerUtil";
import { TFile, TFolder } from "obsidian";
import type { App } from "obsidian";
/**
* Result of a single `MovingUtil.moveFile` call.
*
* - `moved` file was renamed successfully
* - `no_op` file is already at the destination, nothing to do
* - `duplicate` a different file already exists at the destination path
* - `invalid_path` destination folder didn't exist and could not be created
* - `error` `vault.rename` rejected for any other reason (logged to console)
*/
export type MoveOutcome = "moved" | "no_op" | "duplicate" | "invalid_path" | "error";
class MovingUtil {
private static instance: MovingUtil;
private app: obsidian.App;
private app: App;
private constructor() {}
@ -13,7 +26,7 @@ class MovingUtil {
return MovingUtil.instance;
}
public init(app: obsidian.App): void {
public init(app: App): void {
this.app = app;
}
@ -25,7 +38,7 @@ class MovingUtil {
*/
public isFile(path: string): boolean {
const file = this.app.vault.getAbstractFileByPath(path);
return file instanceof obsidian.TFile;
return file instanceof TFile;
}
/**
@ -35,32 +48,50 @@ class MovingUtil {
* @returns boolean
*/
public isFolder(path: string): boolean {
return (
this.app.vault.getAbstractFileByPath(path) instanceof obsidian.TFolder
);
return this.app.vault.getAbstractFileByPath(path) instanceof TFolder;
}
/**
* Moves the file to the destination path
* Moves a file into the destination folder, preserving its name.
*
* Behavior:
* - If the file is already at the destination, returns `"no_op"` without touching the vault.
* - If another file already occupies the destination path, returns `"duplicate"`
* (checked up-front via `getAbstractFileByPath` rather than by parsing rename errors).
* - If the destination folder doesn't exist, intermediate folders are created; failure
* to create them returns `"invalid_path"`.
* - Any other rename rejection is caught, logged, and returned as `"error"`.
*
* @param file - File to be moved
* @param newPath - Destination path
* @returns void
* @param newPath - Destination folder (the file's own name is appended automatically)
* @returns a {@link MoveOutcome} describing what happened
*/
public moveFile(file: obsidian.TFile, newPath: string): void {
if (this.isFolder(newPath)) {
this.app.vault.rename(file, `${newPath}/${file.name}`);
} else {
new obsidian.Notice(
`Invalid destination path\n${newPath} is not a folder!\nCreating requested folder.`,
5000,
);
console.error(
`Invalid destination path\n${newPath} is not a folder!\nCreating requested folder.`,
);
this.createMissingFolders(newPath).then(() => {
this.app.vault.rename(file, `${newPath}/${file.name}`);
});
public async moveFile(file: TFile, newPath: string): Promise<MoveOutcome> {
const targetPath = `${newPath}/${file.name}`;
if (file.path === targetPath) {
return "no_op";
}
if (this.app.vault.getAbstractFileByPath(targetPath)) {
loggerUtil.debug(`Duplicate at "${targetPath}", skipping "${file.path}"`);
return "duplicate";
}
if (!this.isFolder(newPath)) {
const created = await this.createMissingFolders(newPath);
if (!created) {
loggerUtil.error(`Could not create destination folder "${newPath}"`);
return "invalid_path";
}
}
try {
await this.app.vault.rename(file, targetPath);
return "moved";
} catch (e) {
loggerUtil.error(`Failed to move "${file.path}" -> "${targetPath}": ${e}`);
return "error";
}
}
@ -71,15 +102,11 @@ class MovingUtil {
* @param newPath - Destination path
* @returns void
*/
public moveFolder(folder: obsidian.TFolder, newPath: string): void {
public moveFolder(folder: TFolder, newPath: string): void {
if (this.isFolder(newPath)) {
this.app.vault.rename(folder, `${newPath}/${folder.name}`);
} else {
new obsidian.Notice(
`Invalid destination path\n${newPath} is not a folder!`,
5000,
);
console.error(`Invalid destination path\n${newPath} is not a folder!`);
loggerUtil.errorNotice(`Invalid destination path\n${newPath} is not a folder!`);
}
}
@ -89,14 +116,13 @@ class MovingUtil {
* @param path - Path of the folder to be created
* @returns Created folder or null if folder already exists
*/
public createFolder(path: string): obsidian.TFolder | null {
public createFolder(path: string): TFolder | null {
if (!this.isFolder(path)) {
this.app.vault.createFolder(path).then((folder) => {
return folder;
});
} else {
new obsidian.Notice("Folder already exists", 5000);
console.error("Folder already exists");
loggerUtil.errorNotice("Folder already exists");
}
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 { ProjectRule } from "Models/ProjectRule";
import loggerUtil from "Utils/LoggerUtil";
import type { TFile } from "obsidian";
class RuleMatcherUtil {
private static instance: RuleMatcherUtil;
private regexCache: Map<string, RegExp> = new Map();
private constructor() {}
/**
* Returns the singleton instance of RuleMatcherUtil, creating it on first access.
*
* @returns RuleMatcherUtil
*/
public static getInstance(): RuleMatcherUtil {
if (!RuleMatcherUtil.instance) {
RuleMatcherUtil.instance = new RuleMatcherUtil();
@ -13,6 +21,18 @@ class RuleMatcherUtil {
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
* If no rule matches, returns null
@ -21,27 +41,60 @@ class RuleMatcherUtil {
* @param rules
* @returns MovingRule | null
*/
public getMatchingRule(file: TFile, rules: MovingRule[]): MovingRule | null {
public getMatchingRuleByName(file: TFile, rules: MovingRule[]): MovingRule | null {
for (const rule of rules) {
if (rule.regex == null || rule.regex === "") {
console.error("Rule does not have a regex: ", rule);
loggerUtil.error("Rule does not have a regex: ", rule);
continue;
}
if (rule.folder == null || rule.folder === "") {
console.error("Rule does not have a destination folder: ", rule);
loggerUtil.error("Rule does not have a destination folder: ", rule);
continue;
}
if (!this.isValidRegex(rule.regex)) {
console.error("Rule has an invalid regex: ", rule);
loggerUtil.error("Rule has an invalid regex: ", rule);
continue;
}
if (file.name.match(rule.regex)) {
const regex = this.getCompiledRegex(rule.regex);
if (regex.test(file.name)) {
return rule;
}
}
return null;
}
/**
* Returns the first rule that matches any of the tags
* If no rule matches, returns null
* @param tags
* @param rules
* @returns MovingRule | null
*/
public getMatchingRuleByTag(tags: string[], rules: MovingRule[]): MovingRule | null {
for (const rule of rules) {
if (rule.regex == null || rule.regex === "") {
loggerUtil.error("Tag Rule does not have a regex: ", rule);
continue;
}
if (rule.folder == null || rule.folder === "") {
loggerUtil.error("Tag Rule does not have a destination folder: ", rule);
continue;
}
if (!this.isValidRegex(rule.regex)) {
loggerUtil.error("Tag Rule has an invalid regex: ", rule);
continue;
}
const regex = this.getCompiledRegex(rule.regex);
for (const tag of tags) {
if (regex.test(tag)) {
return rule;
}
}
}
return null;
}
/**
* Returns the regex groups of the file that match the rule
* If no groups match, returns an empty array
@ -50,14 +103,33 @@ class RuleMatcherUtil {
* @param rule
* @returns string[]
*/
public getGroupMatches(
file: TFile,
rule: MovingRule,
): RegExpMatchArray | null {
const matches = file.name.match(rule.regex);
public getGroupMatches(file: TFile, rule: MovingRule): RegExpMatchArray | null {
const regex = this.getCompiledRegex(rule.regex);
// console.log("Compiled regex: ", regex);
const matches = file.name.match(regex);
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
* Meaning, syntax errors are caught, but not logical errors
@ -67,11 +139,12 @@ class RuleMatcherUtil {
*/
private isValidRegex(pattern: string): boolean {
try {
// console.log("Validating regex pattern: ", pattern);
new RegExp(pattern);
return true;
} catch (e) {
if (e instanceof SyntaxError) {
console.error(`Invalid regex pattern: ${e.message}`);
loggerUtil.error(`Invalid regex pattern: ${e.message}`);
}
return false;
}
@ -84,10 +157,7 @@ class RuleMatcherUtil {
* @param rule
* @param matches
*/
public constructFinalDesinationPath(
rule: MovingRule,
matches: RegExpMatchArray,
): string {
public constructFinalDesinationPath(rule: MovingRule, matches: RegExpMatchArray): string {
let folderPath = rule.folder;
const unnamedGroups = this.getUnnamedGroups(rule.regex);
// it has been asserted before that there is no way this can be null before the call, the rule and file are checked to be valid
@ -105,10 +175,7 @@ class RuleMatcherUtil {
* @returns boolean
*/
public isRegexGrouped(rule: MovingRule): boolean {
return (
this.doesDestinationUseGroups(rule) &&
this.getUnnamedGroups(rule.regex) != null
);
return this.doesDestinationUseGroups(rule) && this.getUnnamedGroups(rule.regex) != null;
}
/**

44
Utils/TimerUtil.ts Normal file
View file

@ -0,0 +1,44 @@
class TimerUtil {
private static instance: TimerUtil;
private timer: NodeJS.Timeout | null = null;
private constructor() {}
static getInstance(): TimerUtil {
if (!TimerUtil.instance) {
TimerUtil.instance = new TimerUtil();
}
return TimerUtil.instance;
}
formatTime(ms: number | null): string {
if (ms == null) return "00:00:00";
const totalSeconds = Math.floor(ms / 1000);
const hours = String(Math.floor(totalSeconds / 3600)).padStart(2, "0");
const minutes = String(Math.floor((totalSeconds % 3600) / 60)).padStart(
2,
"0",
);
const seconds = String(totalSeconds % 60).padStart(2, "0");
return `${hours}:${minutes}:${seconds}`;
}
parseTimeToMs(timeStr: string): number {
const [h, m, s] = timeStr.split(":").map(Number);
if ([h, m, s].some((n) => Number.isNaN(n))) return 0;
return ((h ?? 0) * 3600 + (m ?? 0) * 60 + (s ?? 0)) * 1000;
}
startTimer(callback: () => void, interval: number) {
if (this.timer) {
// console.log("Clearing existing timer");
clearInterval(this.timer);
}
// console.log("Starting timer with interval: ", interval);
this.timer = setTimeout(callback, interval);
}
}
const timerUtil = TimerUtil.getInstance();
export default timerUtil;

View file

@ -1,21 +1,18 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.2.2/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"ignore": []
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"organizeImports": {
"enabled": true
"indentWidth": 2,
"lineWidth": 120
},
"linter": {
"enabled": true,
@ -27,5 +24,12 @@
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

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)

279
main.ts
View file

@ -1,40 +1,87 @@
import settingsIO from "IO/SettingsIO";
import * as Settings from "Settings/Settings";
import { SettingsTab } from "Settings/SettingsTab";
import movingUtil from "Utils/MovingUtil";
import ruleMatcherUtil from "Utils/RuleMatcherUtil";
import * as obsidian from "obsidian";
import exclusionMatcherUtil from "Utils/ExclusionMatcherUtil";
import loggerUtil from "Utils/LoggerUtil";
import movingUtil, { type MoveOutcome } from "Utils/MovingUtil";
import ruleMatcherUtil from "Utils/RuleMatcherUtil";
import timerUtil from "Utils/TimerUtil";
import * as obsidian from "obsidian";
import projectMatcherUtil from "Utils/ProjectMatcherUtil";
export default class AutoMoverPlugin extends obsidian.Plugin {
settings: Settings.AutoMoverSettings;
/**
* Obsidian lifecycle hook called when the plugin is enabled.
* Initializes utilities, loads settings, registers the settings tab,
* file-open and automatic-moving events, the command, and the ribbon icon.
*
* @returns void
*/
async onload() {
// console.log("loading plugin");
movingUtil.init(this.app);
settingsIO.init(this.app);
/**
* 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.automaticMoving();
// negative ifs for easier reading and debugging
if (!this.areMovingTriggersEnabled()) return;
if (!this.areThereRulesToApply()) return;
if (!this.areRulesValid()) return;
if (this.settings.moveOnOpen) {
this.registerEvent(
this.app.workspace.on("file-open", (file: obsidian.TFile) => {
if (file == null || file.path == null) return;
if (this.isFileExcluded(file)) return;
this.matchAndMoveFile(file);
}),
);
}
this.registerEvent(
this.app.workspace.on("file-open", async (file: obsidian.TFile) => {
if (!this.settings.moveOnOpen) return;
if (file == null || file.path == null) return;
if (this.isFileExcluded(file)) return;
const result = await this.matchAndMoveFile(file);
if (result === "moved") loggerUtil.infoNotice("1 file moved.");
else if (result === "duplicate")
loggerUtil.infoNotice("1 file not moved - duplicate name.");
else if (result === "invalid_path" || result === "error")
loggerUtil.infoNotice("1 file failed to move.");
}),
);
this.registerEvent(
// since i am defining my own event, ts-lint is crying about it but it works in the end
this.app.workspace.on("AutoMover:automatic-moving-update", () => {
loggerUtil.debug("Automatic moving update");
this.automaticMoving();
}),
);
this.addCommand({
id: "AutoMover:move-files",
name: "Move files",
callback: () => {
this.goThroughAllFiles();
},
});
this.addRibbonIcon("file-input", "AutoMover: Move files", () => {
this.goThroughAllFiles();
});
}
/**
* Reocurring function that will be called by the timer
*
* @returns void
*/
automaticMoving() {
// console.log("Automatic moving run");
if (!this.settings.automaticMoving) return;
if (this.settings.timer == null || this.settings.timer <= 0) return;
this.goThroughAllFiles({ silentIfUnchanged: true });
timerUtil.startTimer(this.automaticMoving.bind(this), this.settings.timer);
}
/**
@ -42,16 +89,36 @@ export default class AutoMoverPlugin extends obsidian.Plugin {
*
* @returns void
*/
goThroughAllFiles() {
const files = this.app.vault.getFiles();
for (const file of files) {
async goThroughAllFiles(options: { silentIfUnchanged?: boolean } = {}) {
// console.log("Going through all files");
const candidates: obsidian.TFile[] = [];
for (const file of this.app.vault.getFiles()) {
if (file == null || file.path == null) continue;
console.log("Going through file: ", file.path);
console.log("File excluded: ", this.isFileExcluded(file));
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"));
}
/**
@ -64,45 +131,132 @@ export default class AutoMoverPlugin extends obsidian.Plugin {
if (!this.areThereExcludedFolders()) return false;
if (!this.areThereRulesToApply()) return false;
return exclusionMatcherUtil.isFilePathExcluded(
file,
this.settings.exclusionRules,
);
return exclusionMatcherUtil.isFilePathExcluded(file, this.settings.exclusionRules);
}
/**
* Matches the file to the rule and moves it to the destination folder
* Matches the file to the first applicable rule (project, then name, then tag)
* and moves it.
*
* @param file - File to be matched and moved
* @returns void
* @returns the MoveOutcome from the matched rule, or null if no rule matched
*/
matchAndMoveFile(file: obsidian.TFile): void {
console.log("Moving file: ", file.path);
const rule = ruleMatcherUtil.getMatchingRule(
file,
this.settings.movingRules,
);
if (rule == null || rule.folder == null) return;
async matchAndMoveFile(file: obsidian.TFile): Promise<MoveOutcome | null> {
// console.log("Moving file: ", file.path);
const projectResult = await this.matchAndMoveByProject(file);
if (projectResult !== null) return projectResult;
const nameResult = await this.matchAndMoveByName(file);
if (nameResult !== null) return nameResult;
return this.matchAndMoveByTag(file);
}
/**
* Matches the file by name and moves it to the destination folder.
*
* @param file - File to be matched and moved
* @returns the MoveOutcome, or null if no rule matched
*/
async matchAndMoveByName(file: obsidian.TFile): Promise<MoveOutcome | null> {
const rule = ruleMatcherUtil.getMatchingRuleByName(file, this.settings.movingRules);
if (rule == null || rule.folder == null) return null;
if (ruleMatcherUtil.isRegexGrouped(rule)) {
const matches = ruleMatcherUtil.getGroupMatches(file, rule);
const finalDestinationPath = ruleMatcherUtil.constructFinalDesinationPath(
rule,
matches!,
);
movingUtil.moveFile(file, finalDestinationPath);
} else {
movingUtil.moveFile(file, rule.folder);
const finalDestinationPath = ruleMatcherUtil.constructFinalDesinationPath(rule, matches!);
return movingUtil.moveFile(file, finalDestinationPath);
}
return movingUtil.moveFile(file, rule.folder);
}
/**
* Matches the file by tags it contains and moves it to the destination folder.
*
* @param file - File to be matched and moved
* @returns the MoveOutcome, or null if no rule matched
*/
async matchAndMoveByTag(file: obsidian.TFile): Promise<MoveOutcome | null> {
const cache = this.app.metadataCache.getFileCache(file);
if (cache == null) return null;
const tags = obsidian.getAllTags(cache);
if (tags == null || tags.length === 0) return null;
const tagRule = ruleMatcherUtil.getMatchingRuleByTag(tags, this.settings.tagRules);
if (tagRule == null || tagRule.folder == null) return null;
if (ruleMatcherUtil.isRegexGrouped(tagRule)) {
const matches = ruleMatcherUtil.getGroupMatchesForTags(tags, tagRule);
// console.log("File: ", file.path);
// console.log("Tag rule: ", tagRule);
// console.log("Tag matches: ", matches);
const finalDestinationPath = ruleMatcherUtil.constructFinalDesinationPath(tagRule, matches!);
return movingUtil.moveFile(file, finalDestinationPath);
}
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() {
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() {
// console.log("unloading plugin");
}
@ -122,10 +276,22 @@ export default class AutoMoverPlugin extends obsidian.Plugin {
*/
areThereRulesToApply(): boolean {
return (
this.settings.movingRules.length > 0 &&
this.settings.movingRules.every(
(rule) => rule.regex !== "" && rule.folder !== "",
)
(this.settings.movingRules.length > 0 &&
this.settings.movingRules.some((rule) => rule.regex !== "" && rule.folder !== "")) ||
(this.settings.tagRules.length > 0 &&
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 !== "")
);
}
@ -142,8 +308,9 @@ export default class AutoMoverPlugin extends obsidian.Plugin {
* @returns boolean
*/
areRulesValid(): boolean {
return this.settings.movingRules.every(
(rule) => rule.regex !== "" && rule.folder !== "",
return (
this.settings.movingRules.every((rule) => rule.regex !== "" && rule.folder !== "") &&
this.settings.tagRules.every((rule) => rule.regex !== "" && rule.folder !== "")
);
}

View file

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

View file

@ -1,7 +1,7 @@
{
"name": "obsidian-sample-plugin",
"version": "1.0.0",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"name": "AutoMover",
"version": "1.0.9",
"description": "AutoMover: Move files and notes with specified names into their designated folders according to rules you define.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",

View file

@ -8,58 +8,118 @@ If your plugin does not need CSS, delete this file.
*/
.moving_rules_container {
padding: 0.75em 0;
border-top: 1px solid var(--background-modifier-border);
display: flex;
flex-direction: column;
padding: 0.75em 0;
border-top: 1px solid var(--background-modifier-border);
display: flex;
flex-direction: column;
}
.rule {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 0.5em;
justify-content: space-evenly;
gap: 0.5em;
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 0.5em;
justify-content: space-evenly;
gap: 0.5em;
}
.rule_header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
}
.rule_title {
padding: 0.5em;
width: 100%;
font-weight: bold;
padding: 0.5em;
width: 100%;
font-weight: bold;
}
.rule_input {
width: 100%;
padding: 0.5em;
border: 1px solid var(--background-modifier-border);
border-radius: 0.25em;
width: 100%;
padding: 0.5em;
border: 1px solid var(--background-modifier-border);
border-radius: 0.25em;
}
.rule_button {
cursor: pointer;
cursor: pointer;
}
.rule_button:hover {
background-color: var(--color-green);
background-color: var(--color-green);
}
.rule_button:active {
background-color: var(--background-modifier-active);
background-color: var(--background-modifier-active);
}
.rule_button_remove:hover {
background-color: var(--color-red) !important;
background-color: var(--color-red) !important;
}
.rule_button_duplicate:hover {
background-color: var(--color-blue) !important;
background-color: var(--color-blue) !important;
}
/* Style for timer-setting input field */
.setting-item.timer-setting input[type="text"] {
width: auto;
min-width: 8ch;
/* Minimum for hh:mm:ss */
max-width: 16ch;
/* Allows up to ~9999:59:59 */
/* padding: 2px 6px; */
font-family: monospace;
text-align: center;
/* 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.0": "0.15.0"
"1.0.8": "0.15.0",
"1.0.9": "0.15.0"
}