mirror of
https://github.com/al0cam/AutoMover.git
synced 2026-07-22 05:41:25 +00:00
Compare commits
55 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66f4c6c0a7 | ||
|
|
a24a9b4b68 | ||
|
|
bb7ef8f192 | ||
|
|
2689d53573 | ||
|
|
c3d9e388a0 | ||
|
|
a31fdaf936 | ||
|
|
450605fd66 | ||
|
|
30dbc37693 | ||
|
|
eaa35a746b | ||
|
|
b3d3bb2f27 | ||
|
|
0c79ab38e3 | ||
|
|
641c3172e5 | ||
|
|
c9d7548cc9 | ||
|
|
9f779588e4 | ||
|
|
226d74f7bd | ||
|
|
c2cb621b7a | ||
|
|
ed3c23fe14 | ||
|
|
ecba33e549 | ||
|
|
f875abfec8 | ||
|
|
fa556ce751 | ||
|
|
bdfe7d4549 | ||
|
|
e577323446 | ||
|
|
3a7410d070 | ||
|
|
3927556663 | ||
|
|
7135c5181e | ||
|
|
b12444aeff | ||
|
|
ff721f4811 | ||
|
|
a97fa797b4 | ||
|
|
6e440b65b8 | ||
|
|
0459f5f5cf | ||
|
|
7f271e8046 | ||
|
|
4a1a0950cb | ||
|
|
c886373038 | ||
|
|
c3144528b3 | ||
|
|
c881ce7a4a | ||
|
|
20363baab6 | ||
|
|
79c9de8af8 | ||
|
|
6c303e7391 | ||
|
|
bde43b95a7 | ||
|
|
555a224900 | ||
|
|
ed5637692e | ||
|
|
2bc63e3872 | ||
|
|
1e616ac1a3 | ||
|
|
050278aa5b | ||
|
|
dda385882c | ||
|
|
ee079d1107 | ||
|
|
4d0d293c23 | ||
|
|
a058b61952 | ||
|
|
5ad3814661 | ||
|
|
6f4cc32364 | ||
|
|
8bd4d68467 | ||
|
|
7e96fdf1bf | ||
|
|
b347b1fd7d | ||
|
|
fca87af73e | ||
|
|
6a18d0a8fc |
30 changed files with 2115 additions and 399 deletions
99
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
99
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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.
|
||||
59
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
59
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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
20
IO/RuleIO.ts
Normal 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
202
IO/SettingsIO.ts
Normal 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;
|
||||
|
|
@ -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
31
Models/ProjectRule.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
112
README.md
112
README.md
|
|
@ -1,74 +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
|
||||
|
||||

|
||||
## 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)
|
||||
|
||||
### Example without regex
|
||||
## Installation
|
||||
|
||||
#### Important note 1: The text is case sensitive.
|
||||
#### Important note 2: Use the "/" character to separate folders.
|
||||
### From Obsidian Community Plugins (Recommended)
|
||||
1. Open Settings → Community Plugins
|
||||
2. Browse and search for "AutoMover"
|
||||
3. Click Install, then Enable
|
||||
|
||||
Lets imagine you have three files called Scroll 1, Scroll 2 and Scroll 3.
|
||||
Such as you can see below:
|
||||
### 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
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
### 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:
|
||||
|
||||

|
||||
|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
|
||||
#### Writing Regex: The best tool for writing regex is Regex101 (https://regex101.com/)
|
||||
|
||||
## 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.
|
||||
|
|
@ -79,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
|
||||
|
|
|
|||
85
Settings/ExclusionSection.ts
Normal file
85
Settings/ExclusionSection.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
92
Settings/MovingRuleSection.ts
Normal file
92
Settings/MovingRuleSection.ts
Normal 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
159
Settings/ProjectSection.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +1,52 @@
|
|||
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;
|
||||
moveOnClose: boolean;
|
||||
moveOnCreate: boolean;
|
||||
// moveOnSave: boolean;
|
||||
movingRules: MovingRule[];
|
||||
excludedFolders: ExclusionRule[];
|
||||
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> = {
|
||||
moveOnOpen: true,
|
||||
moveOnSave: true,
|
||||
moveOnClose: false,
|
||||
moveOnCreate: false,
|
||||
// moveOnSave: true,
|
||||
movingRules: [],
|
||||
excludedFolders: [],
|
||||
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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 excludedFoldersContainer = containerEl.createDiv({
|
||||
cls: "moving_rules_container",
|
||||
});
|
||||
new obsidian.Setting(excludedFoldersContainer)
|
||||
.setName("Exclusion rules")
|
||||
.setHeading();
|
||||
|
||||
const exclusionList = excludedFoldersContainer.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.excludedFolders.push(new ExclusionRule());
|
||||
this.display();
|
||||
});
|
||||
|
||||
/**
|
||||
* List of excluded folders
|
||||
*/
|
||||
for (const exclusion of this.plugin.settings.excludedFolders) {
|
||||
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.excludedFolders.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.excludedFolders.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.excludedFolders =
|
||||
this.plugin.settings.excludedFolders.filter((r) => r !== exclusion);
|
||||
this.display();
|
||||
});
|
||||
}
|
||||
// EXCLUSION FOLDERS END
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
92
Settings/TagSection.ts
Normal file
92
Settings/TagSection.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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
122
Utils/LoggerUtil.ts
Normal 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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
48
Utils/ProjectMatcherUtil.ts
Normal file
48
Utils/ProjectMatcherUtil.ts
Normal 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;
|
||||
|
|
@ -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
44
Utils/TimerUtil.ts
Normal 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;
|
||||
18
biome.json
18
biome.json
|
|
@ -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
52
docs/exclusion-rules.md
Normal 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:
|
||||
|
||||

|
||||
|
||||
And the rules you want to apply to them look like this:
|
||||
|
||||

|
||||
|
||||
After moving them you are left with this:
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
And the result would look like this:
|
||||
|
||||

|
||||
|
||||
## 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:
|
||||
|
||||

|
||||
|
||||
And you decided you want to exclude those that have numbers as a suffix.
|
||||
Then your rules can look like this:
|
||||
|
||||

|
||||
|
||||
And the result would look like this:
|
||||
|
||||

|
||||
|
||||
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
39
docs/moving-rules.md
Normal 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:
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
## 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:
|
||||
|
||||

|
||||
|
||||
|
||||
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:
|
||||
|
||||

|
||||
54
docs/project-rules.md
Normal file
54
docs/project-rules.md
Normal 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
80
docs/tag-rules.md
Normal 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
136
docs/ui-guide.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
291
main.ts
291
main.ts
|
|
@ -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,12 +89,36 @@ export default class AutoMoverPlugin extends obsidian.Plugin {
|
|||
*
|
||||
* @returns void
|
||||
*/
|
||||
goThroughAllFiles() {
|
||||
const files = this.app.vault.getFiles();
|
||||
for (const file of files) {
|
||||
this.matchAndMoveFile(file);
|
||||
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;
|
||||
if (this.isFileExcluded(file)) continue;
|
||||
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"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -60,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.excludedFolders,
|
||||
);
|
||||
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");
|
||||
}
|
||||
|
|
@ -108,12 +266,8 @@ export default class AutoMoverPlugin extends obsidian.Plugin {
|
|||
* @returns boolean
|
||||
*/
|
||||
areMovingTriggersEnabled(): boolean {
|
||||
return (
|
||||
this.settings.moveOnClose ||
|
||||
this.settings.moveOnCreate ||
|
||||
this.settings.moveOnOpen ||
|
||||
this.settings.moveOnSave
|
||||
);
|
||||
return this.settings.moveOnOpen;
|
||||
// || this.settings.moveOnSave
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -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 !== "")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -134,7 +300,7 @@ export default class AutoMoverPlugin extends obsidian.Plugin {
|
|||
* @returns boolean
|
||||
*/
|
||||
areThereExcludedFolders(): boolean {
|
||||
return this.settings.excludedFolders.length > 0;
|
||||
return this.settings.exclusionRules.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -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 !== "")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -152,6 +319,6 @@ export default class AutoMoverPlugin extends obsidian.Plugin {
|
|||
* @returns boolean
|
||||
*/
|
||||
areExcludedFoldersValid(): boolean {
|
||||
return this.settings.excludedFolders.every((rule) => rule.regex !== "");
|
||||
return this.settings.exclusionRules.every((rule) => rule.regex !== "");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"id": "auto-mover",
|
||||
"name": "AutoMover",
|
||||
"version": "1.0.2",
|
||||
"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": "",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
114
styles.css
114
styles.css
|
|
@ -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 '▼' */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
{
|
||||
"1.0.0": "0.15.0"
|
||||
"1.0.8": "0.15.0",
|
||||
"1.0.9": "0.15.0"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue