mirror of
https://github.com/al0cam/AutoMover.git
synced 2026-07-22 12:10:26 +00:00
Compare commits
No commits in common. "master" and "1.0.7" have entirely different histories.
14 changed files with 119 additions and 545 deletions
99
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
99
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -1,99 +0,0 @@
|
|||
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
59
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
|
|
@ -1,59 +0,0 @@
|
|||
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.
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import type { AutoMoverSettings } from "Settings/Settings";
|
||||
import loggerUtil from "Utils/LoggerUtil";
|
||||
import { TFile } from "obsidian";
|
||||
import { Notice, TFile } from "obsidian";
|
||||
import type { App } from "obsidian";
|
||||
|
||||
class SettingsIO {
|
||||
|
|
@ -9,11 +8,6 @@ class SettingsIO {
|
|||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Returns the singleton instance of SettingsIO, creating it on first access.
|
||||
*
|
||||
* @returns SettingsIO
|
||||
*/
|
||||
public static getInstance(): SettingsIO {
|
||||
if (!SettingsIO.instance) {
|
||||
SettingsIO.instance = new SettingsIO();
|
||||
|
|
@ -21,12 +15,6 @@ class SettingsIO {
|
|||
return SettingsIO.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the Obsidian app reference used for vault and dialog access.
|
||||
*
|
||||
* @param app - The Obsidian App instance
|
||||
* @returns void
|
||||
*/
|
||||
public init(app: App): void {
|
||||
this.app = app;
|
||||
}
|
||||
|
|
@ -69,10 +57,11 @@ class SettingsIO {
|
|||
const fs = require("node:fs");
|
||||
fs.writeFileSync(filePath, settingsData);
|
||||
|
||||
loggerUtil.infoNotice("Settings exported successfully");
|
||||
new Notice("Settings exported successfully");
|
||||
return true;
|
||||
} catch (error) {
|
||||
loggerUtil.errorNotice("Failed to export settings", error);
|
||||
console.error("Failed to export settings:", error);
|
||||
new Notice("Failed to export settings");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -88,10 +77,11 @@ class SettingsIO {
|
|||
try {
|
||||
const filename = "AutoMover_settings.json";
|
||||
await this.app.vault.create(filename, settingsData);
|
||||
loggerUtil.infoNotice(`Settings exported to vault: ${filename}`);
|
||||
new Notice(`Settings exported to vault: ${filename}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
loggerUtil.errorNotice("Failed to export settings to vault", error);
|
||||
console.error("Failed to export to vault:", error);
|
||||
new Notice("Failed to export settings to vault");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -133,14 +123,15 @@ class SettingsIO {
|
|||
const importedSettings = JSON.parse(fileContent);
|
||||
|
||||
if (!this.validateSettings(importedSettings)) {
|
||||
loggerUtil.warnNotice("Invalid settings file format");
|
||||
new Notice("Invalid settings file format");
|
||||
return null;
|
||||
}
|
||||
|
||||
loggerUtil.infoNotice("Settings imported successfully");
|
||||
new Notice("Settings imported successfully");
|
||||
return importedSettings;
|
||||
} catch (error) {
|
||||
loggerUtil.errorNotice("Failed to import settings", error);
|
||||
console.error("Failed to import settings:", error);
|
||||
new Notice("Failed to import settings");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -157,7 +148,7 @@ class SettingsIO {
|
|||
const file = this.app.vault.getAbstractFileByPath(filename);
|
||||
|
||||
if (!file || !(file instanceof TFile)) {
|
||||
loggerUtil.warnNotice(`Could not find ${filename} in vault`);
|
||||
new Notice(`Could not find ${filename} in vault`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -165,14 +156,15 @@ class SettingsIO {
|
|||
const importedSettings = JSON.parse(fileContent);
|
||||
|
||||
if (!this.validateSettings(importedSettings)) {
|
||||
loggerUtil.warnNotice("Invalid settings file format");
|
||||
new Notice("Invalid settings file format");
|
||||
return null;
|
||||
}
|
||||
|
||||
loggerUtil.infoNotice("Settings imported from vault successfully");
|
||||
new Notice("Settings imported from vault successfully");
|
||||
return importedSettings;
|
||||
} catch (error) {
|
||||
loggerUtil.errorNotice("Failed to import settings from vault", error);
|
||||
console.error("Failed to import from vault:", error);
|
||||
new Notice("Failed to import settings from vault");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -1,7 +1,7 @@
|
|||
# AutoMover Plugin
|
||||
|
||||
This plugin is used for designating folders in which your files will be moved automatically.
|
||||
It seeks to be an alternative to the https://github.com/farux/obsidian-auto-note-mover plugin from farux.
|
||||
It seeks to be an alternative to the "https://github.com/farux/obsidian-auto-note-mover" plugin from farux.
|
||||
The problem I had with that plugin was the lack of support for regex and regex groups in the destination paths.
|
||||
|
||||
Therefore, this plugin supports regex and regex groups to create the destination paths unless they already exist.
|
||||
|
|
@ -19,11 +19,11 @@ Therefore, this plugin supports regex and regex groups to create the destination
|
|||
|
||||
## Documentation
|
||||
|
||||
- [UI Guide](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
|
||||
- [UI Guide](docs/ui-guide.md) - Complete overview of the plugin interface and settings
|
||||
- [Moving Rules](docs/moving-rules.md) - Filename-based rules with regex examples
|
||||
- [Tag Rules](docs/tag-rules.md) - Filename-based rules with regex examples
|
||||
- [Project Rules](docs/project-rules.md) - Organize files by project using frontmatter
|
||||
- [Exclusion Rules](docs/exclusion-rules.md) - Protect specific files and folders
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ export interface AutoMoverSettings {
|
|||
projectRules: ProjectRule[];
|
||||
automaticMoving: boolean;
|
||||
timer: number | null; // in miliseconds
|
||||
debugLogging: boolean;
|
||||
collapseSections: {
|
||||
tutorial: boolean;
|
||||
movingRules: boolean;
|
||||
|
|
@ -31,7 +30,6 @@ export const DEFAULT_SETTINGS: Partial<AutoMoverSettings> = {
|
|||
projectRules: [],
|
||||
automaticMoving: false,
|
||||
timer: null,
|
||||
debugLogging: false,
|
||||
collapseSections: {
|
||||
tutorial: false,
|
||||
movingRules: false,
|
||||
|
|
@ -41,12 +39,6 @@ export const DEFAULT_SETTINGS: Partial<AutoMoverSettings> = {
|
|||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads the plugin settings by merging persisted data on top of DEFAULT_SETTINGS.
|
||||
*
|
||||
* @param AutoMoverPlugin - The plugin instance used to read persisted data
|
||||
* @returns Partial<AutoMoverSettings>
|
||||
*/
|
||||
function loadSettings(AutoMoverPlugin: AutoMoverPlugin): Partial<AutoMoverSettings> {
|
||||
return Object.assign({}, DEFAULT_SETTINGS, AutoMoverPlugin.loadData());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,18 +96,6 @@ export class SettingsTab extends PluginSettingTab {
|
|||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Debug logging")
|
||||
.setDesc(
|
||||
"Log verbose debug messages to the developer console (Ctrl+Shift+I). Leave off unless diagnosing an issue.",
|
||||
)
|
||||
.addToggle((cb) =>
|
||||
cb.setValue(this.plugin.settings.debugLogging).onChange(async (value) => {
|
||||
this.plugin.settings.debugLogging = value;
|
||||
await this.plugin.saveData(this.plugin.settings);
|
||||
}),
|
||||
);
|
||||
|
||||
// TUTORIAL START
|
||||
/**
|
||||
* Instead of using the default .setting-item class I created a custom class to add other styling,
|
||||
|
|
@ -173,6 +161,5 @@ export class SettingsTab extends PluginSettingTab {
|
|||
tagSection(containerEl, this.plugin, this.display);
|
||||
exclusionSection(containerEl, this.plugin, this.display);
|
||||
projectSection(containerEl, this.plugin, this.display);
|
||||
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
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,18 +1,6 @@
|
|||
import loggerUtil from "Utils/LoggerUtil";
|
||||
import { TFile, TFolder } from "obsidian";
|
||||
import { Notice, TFile, TFolder } from "obsidian";
|
||||
import type { App } from "obsidian";
|
||||
|
||||
/**
|
||||
* Result of a single `MovingUtil.moveFile` call.
|
||||
*
|
||||
* - `moved` — file was renamed successfully
|
||||
* - `no_op` — file is already at the destination, nothing to do
|
||||
* - `duplicate` — a different file already exists at the destination path
|
||||
* - `invalid_path` — destination folder didn't exist and could not be created
|
||||
* - `error` — `vault.rename` rejected for any other reason (logged to console)
|
||||
*/
|
||||
export type MoveOutcome = "moved" | "no_op" | "duplicate" | "invalid_path" | "error";
|
||||
|
||||
class MovingUtil {
|
||||
private static instance: MovingUtil;
|
||||
private app: App;
|
||||
|
|
@ -52,46 +40,26 @@ class MovingUtil {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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"`.
|
||||
* Moves the file to the destination path
|
||||
*
|
||||
* @param file - File to be moved
|
||||
* @param newPath - Destination folder (the file's own name is appended automatically)
|
||||
* @returns a {@link MoveOutcome} describing what happened
|
||||
* @param newPath - Destination path
|
||||
* @returns void
|
||||
*/
|
||||
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";
|
||||
public moveFile(file: TFile, newPath: string): void {
|
||||
if (this.isFolder(newPath)) {
|
||||
this.app.vault.rename(file, `${newPath}/${file.name}`);
|
||||
} else {
|
||||
new Notice(
|
||||
`Invalid destination path\n${newPath} is not a folder!\nCreating requested folder.`,
|
||||
5000,
|
||||
);
|
||||
console.error(
|
||||
`Invalid destination path\n${newPath} is not a folder!\nCreating requested folder.`,
|
||||
);
|
||||
this.createMissingFolders(newPath).then(() => {
|
||||
this.app.vault.rename(file, `${newPath}/${file.name}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -106,7 +74,8 @@ class MovingUtil {
|
|||
if (this.isFolder(newPath)) {
|
||||
this.app.vault.rename(folder, `${newPath}/${folder.name}`);
|
||||
} else {
|
||||
loggerUtil.errorNotice(`Invalid destination path\n${newPath} is not a folder!`);
|
||||
new Notice(`Invalid destination path\n${newPath} is not a folder!`, 5000);
|
||||
console.error(`Invalid destination path\n${newPath} is not a folder!`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -122,7 +91,8 @@ class MovingUtil {
|
|||
return folder;
|
||||
});
|
||||
} else {
|
||||
loggerUtil.errorNotice("Folder already exists");
|
||||
new Notice("Folder already exists", 5000);
|
||||
console.error("Folder already exists");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { MovingRule } from "Models/MovingRule";
|
||||
import { ProjectRule } from "Models/ProjectRule";
|
||||
import loggerUtil from "Utils/LoggerUtil";
|
||||
import type { TFile } from "obsidian";
|
||||
import type { TagCache, TFile } from "obsidian";
|
||||
|
||||
class RuleMatcherUtil {
|
||||
private static instance: RuleMatcherUtil;
|
||||
|
|
@ -9,11 +8,6 @@ class RuleMatcherUtil {
|
|||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Returns the singleton instance of RuleMatcherUtil, creating it on first access.
|
||||
*
|
||||
* @returns RuleMatcherUtil
|
||||
*/
|
||||
public static getInstance(): RuleMatcherUtil {
|
||||
if (!RuleMatcherUtil.instance) {
|
||||
RuleMatcherUtil.instance = new RuleMatcherUtil();
|
||||
|
|
@ -44,15 +38,15 @@ class RuleMatcherUtil {
|
|||
public getMatchingRuleByName(file: TFile, rules: MovingRule[]): MovingRule | null {
|
||||
for (const rule of rules) {
|
||||
if (rule.regex == null || rule.regex === "") {
|
||||
loggerUtil.error("Rule does not have a regex: ", rule);
|
||||
console.error("Rule does not have a regex: ", rule);
|
||||
continue;
|
||||
}
|
||||
if (rule.folder == null || rule.folder === "") {
|
||||
loggerUtil.error("Rule does not have a destination folder: ", rule);
|
||||
console.error("Rule does not have a destination folder: ", rule);
|
||||
continue;
|
||||
}
|
||||
if (!this.isValidRegex(rule.regex)) {
|
||||
loggerUtil.error("Rule has an invalid regex: ", rule);
|
||||
console.error("Rule has an invalid regex: ", rule);
|
||||
continue;
|
||||
}
|
||||
const regex = this.getCompiledRegex(rule.regex);
|
||||
|
|
@ -70,24 +64,24 @@ class RuleMatcherUtil {
|
|||
* @param rules
|
||||
* @returns MovingRule | null
|
||||
*/
|
||||
public getMatchingRuleByTag(tags: string[], rules: MovingRule[]): MovingRule | null {
|
||||
public getMatchingRuleByTag(tags: TagCache[], 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);
|
||||
console.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);
|
||||
console.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);
|
||||
console.error("Tag Rule has an invalid regex: ", rule);
|
||||
continue;
|
||||
}
|
||||
|
||||
const regex = this.getCompiledRegex(rule.regex);
|
||||
for (const tag of tags) {
|
||||
if (regex.test(tag)) {
|
||||
if (regex.test(tag.tag)) {
|
||||
return rule;
|
||||
}
|
||||
}
|
||||
|
|
@ -105,24 +99,16 @@ class RuleMatcherUtil {
|
|||
*/
|
||||
public getGroupMatches(file: TFile, rule: MovingRule): RegExpMatchArray | null {
|
||||
const regex = this.getCompiledRegex(rule.regex);
|
||||
// console.log("Compiled regex: ", regex);
|
||||
console.log("Compiled regex: ", regex);
|
||||
const matches = file.name.match(regex);
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
public getGroupMatchesForTags(tags: TagCache[], rule: MovingRule): RegExpMatchArray | null {
|
||||
const regex = this.getCompiledRegex(rule.regex);
|
||||
// console.log("Compiled regex: ", regex);
|
||||
console.log("Compiled regex: ", regex);
|
||||
for (const tag of tags) {
|
||||
const matches = tag.match(regex);
|
||||
const matches = tag.tag.match(regex);
|
||||
if (matches) {
|
||||
return matches;
|
||||
}
|
||||
|
|
@ -139,12 +125,12 @@ class RuleMatcherUtil {
|
|||
*/
|
||||
private isValidRegex(pattern: string): boolean {
|
||||
try {
|
||||
// console.log("Validating regex pattern: ", pattern);
|
||||
console.log("Validating regex pattern: ", pattern);
|
||||
new RegExp(pattern);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) {
|
||||
loggerUtil.error(`Invalid regex pattern: ${e.message}`);
|
||||
console.error(`Invalid regex pattern: ${e.message}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,25 +20,6 @@ The numbers are elaborate below the image.
|
|||
11. **Duplicate rule button**: This button will duplicate the selected rule from the list of rules.
|
||||
|
||||
|
||||
## Notifications for the Moving Toggles
|
||||
|
||||
Each of the three moving triggers above (on-open, manual run, automatic timer) reports back differently:
|
||||
|
||||
- **On-open**: when opening a file actually triggers a move, a short notice is shown - `1 file moved.`, `1 file not moved - duplicate name.`, or `1 file failed to move.`. Opening a file that doesn't match any rule stays silent so normal navigation isn't interrupted.
|
||||
- **Manual run** (ribbon icon or `AutoMover: Move files` command): always reports a summary, including `No files needed to be moved.` when nothing matched. This is the path to use when you want explicit confirmation of what happened.
|
||||
- **Automatic timer**: only shows a notice when at least one file was actually moved on that tick. Duplicate-name skips and move failures are intentionally suppressed here, because those conditions persist across ticks - a filename that collides today will still collide on the next tick, and a rule with a bad destination will keep failing - so reporting them every interval would spam notices indefinitely. If you need the full breakdown of duplicates and failures, trigger a manual run.
|
||||
|
||||
|
||||
## Debug Logging Toggle
|
||||
|
||||
Directly below the moving toggles there is a **Debug logging** toggle. It is placed there on purpose - the rule lists further down can grow long, and burying a developer-facing switch at the very bottom of the settings tab makes it awkward to reach. It is independent of the moving toggles above and controls verbose console output, not Obsidian notices.
|
||||
|
||||
- When **off** (the default), only error-level messages and the user-facing notices described above are produced. This is the right setting for normal use.
|
||||
- When **on**, the plugin writes extra `debug` and `info` messages to the developer console (open it with `Ctrl+Shift+I` in Obsidian). These include things like which rule matched a given file, why a move was skipped as a duplicate, and the path a file was renamed to. None of this appears as a notice in the editor - you have to open the dev tools to see it.
|
||||
|
||||
Leave this off unless you are diagnosing an issue or filing a bug report. The verbose output is meant to make problems reproducible, not to be read during regular use.
|
||||
|
||||
|
||||
## Export and Import
|
||||
|
||||
By default, if you are using some way of syncing your obsidian vaults, this doesn't provide anyting new for you.
|
||||
|
|
|
|||
163
main.ts
163
main.ts
|
|
@ -2,8 +2,7 @@ import settingsIO from "IO/SettingsIO";
|
|||
import * as Settings from "Settings/Settings";
|
||||
import { SettingsTab } from "Settings/SettingsTab";
|
||||
import exclusionMatcherUtil from "Utils/ExclusionMatcherUtil";
|
||||
import loggerUtil from "Utils/LoggerUtil";
|
||||
import movingUtil, { type MoveOutcome } from "Utils/MovingUtil";
|
||||
import movingUtil from "Utils/MovingUtil";
|
||||
import ruleMatcherUtil from "Utils/RuleMatcherUtil";
|
||||
import timerUtil from "Utils/TimerUtil";
|
||||
import * as obsidian from "obsidian";
|
||||
|
|
@ -12,13 +11,6 @@ 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);
|
||||
|
|
@ -27,33 +19,25 @@ export default class AutoMoverPlugin extends obsidian.Plugin {
|
|||
* Loading settings and creating the settings tab
|
||||
*/
|
||||
this.settings = Object.assign({}, Settings.DEFAULT_SETTINGS, await this.loadData());
|
||||
loggerUtil.init(this);
|
||||
this.addSettingTab(new SettingsTab(this.app, this));
|
||||
|
||||
this.automaticMoving();
|
||||
|
||||
// negative ifs for easier reading and debugging
|
||||
if (!this.areMovingTriggersEnabled()) return;
|
||||
if (!this.areThereRulesToApply()) return;
|
||||
|
||||
this.registerEvent(
|
||||
this.app.workspace.on("file-open", async (file: obsidian.TFile) => {
|
||||
this.app.workspace.on("file-open", (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.matchAndMoveFile(file);
|
||||
}),
|
||||
);
|
||||
|
||||
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");
|
||||
// console.log("Automatic moving update");
|
||||
this.automaticMoving();
|
||||
}),
|
||||
);
|
||||
|
|
@ -80,7 +64,7 @@ export default class AutoMoverPlugin extends obsidian.Plugin {
|
|||
// console.log("Automatic moving run");
|
||||
if (!this.settings.automaticMoving) return;
|
||||
if (this.settings.timer == null || this.settings.timer <= 0) return;
|
||||
this.goThroughAllFiles({ silentIfUnchanged: true });
|
||||
this.goThroughAllFiles();
|
||||
timerUtil.startTimer(this.automaticMoving.bind(this), this.settings.timer);
|
||||
}
|
||||
|
||||
|
|
@ -89,36 +73,15 @@ export default class AutoMoverPlugin extends obsidian.Plugin {
|
|||
*
|
||||
* @returns void
|
||||
*/
|
||||
async goThroughAllFiles(options: { silentIfUnchanged?: boolean } = {}) {
|
||||
goThroughAllFiles() {
|
||||
// console.log("Going through all files");
|
||||
const candidates: obsidian.TFile[] = [];
|
||||
for (const file of this.app.vault.getFiles()) {
|
||||
const files = this.app.vault.getFiles();
|
||||
for (const file of files) {
|
||||
if (file == null || file.path == null) continue;
|
||||
if (this.isFileExcluded(file)) continue;
|
||||
candidates.push(file);
|
||||
this.matchAndMoveFile(file);
|
||||
}
|
||||
|
||||
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"));
|
||||
new obsidian.Notice("All files moved!", 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -135,100 +98,90 @@ export default class AutoMoverPlugin extends obsidian.Plugin {
|
|||
}
|
||||
|
||||
/**
|
||||
* Matches the file to the first applicable rule (project, then name, then tag)
|
||||
* and moves it.
|
||||
* Matches the file to the rule and moves it to the destination folder
|
||||
*
|
||||
* @param file - File to be matched and moved
|
||||
* @returns the MoveOutcome from the matched rule, or null if no rule matched
|
||||
* @returns void
|
||||
*/
|
||||
async matchAndMoveFile(file: obsidian.TFile): Promise<MoveOutcome | null> {
|
||||
matchAndMoveFile(file: obsidian.TFile): void {
|
||||
// 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);
|
||||
if (this.matchAndMoveByProject(file)) return;
|
||||
else if (this.matchAndMoveByName(file)) return;
|
||||
else this.matchAndMoveByTag(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches the file by name and moves it to the destination folder.
|
||||
* Matches the file by name and moves it to the destination folder
|
||||
*
|
||||
* @param file - File to be matched and moved
|
||||
* @returns the MoveOutcome, or null if no rule matched
|
||||
* @returns true if the file was moved, false otherwise
|
||||
*/
|
||||
async matchAndMoveByName(file: obsidian.TFile): Promise<MoveOutcome | null> {
|
||||
matchAndMoveByName(file: obsidian.TFile): boolean {
|
||||
const rule = ruleMatcherUtil.getMatchingRuleByName(file, this.settings.movingRules);
|
||||
if (rule == null || rule.folder == null) return null;
|
||||
if (rule == null || rule.folder == null) return false;
|
||||
|
||||
if (ruleMatcherUtil.isRegexGrouped(rule)) {
|
||||
const matches = ruleMatcherUtil.getGroupMatches(file, rule);
|
||||
const finalDestinationPath = ruleMatcherUtil.constructFinalDesinationPath(rule, matches!);
|
||||
return movingUtil.moveFile(file, finalDestinationPath);
|
||||
movingUtil.moveFile(file, finalDestinationPath);
|
||||
} else {
|
||||
movingUtil.moveFile(file, rule.folder);
|
||||
}
|
||||
return movingUtil.moveFile(file, rule.folder);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches the file by tags it contains and moves it to the destination folder.
|
||||
* Matches the file by tags it contains and moves it to the destination folder
|
||||
*
|
||||
* @param file - File to be matched and moved
|
||||
* @returns the MoveOutcome, or null if no rule matched
|
||||
* @returns true if the file was moved, false otherwise
|
||||
*/
|
||||
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;
|
||||
matchAndMoveByTag(file: obsidian.TFile): boolean {
|
||||
const tags = this.app.metadataCache.getFileCache(file)?.tags;
|
||||
if (tags == null || tags.length === 0) return false;
|
||||
|
||||
const tagRule = ruleMatcherUtil.getMatchingRuleByTag(tags, this.settings.tagRules);
|
||||
|
||||
if (tagRule == null || tagRule.folder == null) return null;
|
||||
if (tagRule == null || tagRule.folder == null) return false;
|
||||
|
||||
if (ruleMatcherUtil.isRegexGrouped(tagRule)) {
|
||||
const matches = ruleMatcherUtil.getGroupMatchesForTags(tags, tagRule);
|
||||
// console.log("File: ", file.path);
|
||||
// console.log("Tag rule: ", tagRule);
|
||||
// console.log("Tag matches: ", matches);
|
||||
console.log("File: ", file.path);
|
||||
console.log("Tag rule: ", tagRule);
|
||||
console.log("Tag matches: ", matches);
|
||||
const finalDestinationPath = ruleMatcherUtil.constructFinalDesinationPath(tagRule, matches!);
|
||||
return movingUtil.moveFile(file, finalDestinationPath);
|
||||
movingUtil.moveFile(file, finalDestinationPath);
|
||||
} else {
|
||||
movingUtil.moveFile(file, tagRule.folder);
|
||||
}
|
||||
return movingUtil.moveFile(file, tagRule.folder);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
matchAndMoveByProject(file: obsidian.TFile): boolean {
|
||||
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;
|
||||
if (cache == null) return false;
|
||||
if (cache.frontmatter == null) return false;
|
||||
if (cache.frontmatter.Project == null && cache.frontmatter.project == null) return false;
|
||||
|
||||
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 (projectRule == null || projectRule.folder == null) return false;
|
||||
|
||||
// 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);
|
||||
console.log("No rules defined, moving to project root");
|
||||
movingUtil.moveFile(file, projectRule.folder);
|
||||
return true;
|
||||
}
|
||||
|
||||
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("No matching rule or './' destination, moving to project root");
|
||||
movingUtil.moveFile(file, projectRule.folder);
|
||||
return true;
|
||||
}
|
||||
|
||||
// console.log("Project rule's moving rule found: ", rule);
|
||||
|
|
@ -237,26 +190,20 @@ export default class AutoMoverPlugin extends obsidian.Plugin {
|
|||
const matches = ruleMatcherUtil.getGroupMatches(file, rule);
|
||||
const ruleDesinationPath = ruleMatcherUtil.constructFinalDesinationPath(rule, matches!);
|
||||
const finalDestinationPath = projectMatcherUtil.constructProjectDestinationPath(projectRule, ruleDesinationPath);
|
||||
return movingUtil.moveFile(file, finalDestinationPath);
|
||||
|
||||
movingUtil.moveFile(file, finalDestinationPath);
|
||||
} else {
|
||||
const finalDestinationPath = projectMatcherUtil.constructProjectDestinationPath(projectRule, rule.folder);
|
||||
|
||||
movingUtil.moveFile(file, finalDestinationPath);
|
||||
}
|
||||
const finalDestinationPath = projectMatcherUtil.constructProjectDestinationPath(projectRule, rule.folder);
|
||||
return movingUtil.moveFile(file, finalDestinationPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the plugin settings from disk, merged on top of DEFAULT_SETTINGS.
|
||||
*
|
||||
* @returns void
|
||||
*/
|
||||
async asyncloadSettings() {
|
||||
this.settings = Object.assign({}, Settings.DEFAULT_SETTINGS, await this.loadData());
|
||||
}
|
||||
|
||||
/**
|
||||
* Obsidian lifecycle hook called when the plugin is disabled or reloaded.
|
||||
*
|
||||
* @returns void
|
||||
*/
|
||||
async onunload() {
|
||||
// console.log("unloading plugin");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"id": "auto-mover",
|
||||
"name": "AutoMover",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.7",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Move files and notes with specified names into their designated folders according to rules you define.",
|
||||
"description": "Moves files with specified names into the same folder.",
|
||||
"author": "Al0cam",
|
||||
"authorUrl": "https://github.com/al0cam",
|
||||
"fundingUrl": "",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "AutoMover",
|
||||
"version": "1.0.9",
|
||||
"description": "AutoMover: Move files and notes with specified names into their designated folders according to rules you define.",
|
||||
"version": "1.0.7",
|
||||
"description": "AutoMover: Moves files with specified names/tags where you want.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
{
|
||||
"1.0.8": "0.15.0",
|
||||
"1.0.9": "0.15.0"
|
||||
"1.0.7": "0.15.0"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue