Add auto-archive feature (#21)

This commit is contained in:
John Alberts 2026-04-06 06:52:34 -07:00 committed by GitHub
parent 92085da3a7
commit 1791d59567
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1922 additions and 124 deletions

View file

@ -15,6 +15,36 @@ Unarchiving can be done via:
- `Move out of archive` file menu item
- `Move all out of archive` multi-file menu item
## Auto-Archive
Auto-archive lets you define rules that periodically move matching files into your archive folder. Each rule targets a folder (optionally by regex), can include subfolders, and requires at least one condition. Conditions can be based on file age (days since last modified) and/or a file name regex. When a rule matches, files are archived to the same relative path under your archive folder.
How it works:
- Rules are evaluated on a schedule (default every 60 minutes).
- The last auto-archive run time is saved, so short Obsidian sessions can still trigger catch-up runs on startup.
- On startup, auto-archive waits for a configurable delay (default 30 seconds) before checking whether a run is due.
- Multiple conditions can be combined with AND or OR.
- Files already in the archive folder are skipped.
How to use it:
- Open Settings -> Simple Archiver -> Auto-Archive.
- Set the auto-archive frequency and startup delay, and optionally click "Auto Archive Now" to run immediately.
- Click "Add Rule", choose a folder path (or enable regex), decide whether to apply recursively, and add one or more conditions.
- Optional: Right-click a folder and choose "Auto-archive" -> "Add rule" to prefill the folder path.
- Optional: Right-click a folder and choose "Auto-archive" -> "Edit rule" to edit an existing rule.
Example rule:
```text
Folder: Notes/Daily
Apply recursively: No
Conditions (AND):
- File age >= 30 days
- File name regex: ^\d{4}-\d{2}-\d{2}.*\.md$
```
## Planned Improvements
- Archiving a folder that already exists in the archive merges the contents

316
SettingsTab.ts Normal file
View file

@ -0,0 +1,316 @@
import {
App,
normalizePath,
Notice,
PluginSettingTab,
Setting,
} from "obsidian";
import { AutoArchiveService } from "./autoarchive/AutoArchiveService";
import type { AutoArchiveRule } from "./autoarchive/AutoArchiveTypes";
import type SimpleArchiver from "./main";
import { AutoArchiveRuleModal } from "./modals";
export class SimpleArchiverSettingsTab extends PluginSettingTab {
plugin: SimpleArchiver;
autoArchiveService: AutoArchiveService;
activeTab: "general" | "autoArchive" = "general";
constructor(
app: App,
plugin: SimpleArchiver,
autoArchiveService: AutoArchiveService,
) {
super(app, plugin);
this.plugin = plugin;
this.autoArchiveService = autoArchiveService;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
const tabContainer = containerEl.createDiv({
cls: "setting-tab-container",
});
const tabButtonContainer = tabContainer.createDiv({
cls: "setting-tab-buttons",
});
const generalTabButton = tabButtonContainer.createEl("button", {
text: "General",
cls:
this.activeTab === "general"
? "setting-tab-button active"
: "setting-tab-button",
});
generalTabButton.addEventListener("click", () => {
this.activeTab = "general";
this.display();
});
const autoArchiveTabButton = tabButtonContainer.createEl("button", {
text: "Auto-Archive",
cls:
this.activeTab === "autoArchive"
? "setting-tab-button active"
: "setting-tab-button",
});
autoArchiveTabButton.addEventListener("click", () => {
this.activeTab = "autoArchive";
this.display();
});
const tabContent = containerEl.createDiv({
cls: "setting-tab-content",
});
if (this.activeTab === "general") {
this.displayGeneralSettings(tabContent);
} else {
this.displayAutoArchiveSettings(tabContent);
}
}
private displayGeneralSettings(containerEl: HTMLElement): void {
new Setting(containerEl)
.setName("Archive folder")
.setDesc(
"The folder to use as the Archive. If the folder doesn't exist, it will be created when archiving a " +
'note. Folder names must not contain "\\", "/" or ":" and must not start with ".".',
)
.addText((text) =>
text
.setPlaceholder("Archive folder")
.setValue(normalizePath(this.plugin.settings.archiveFolder))
.onChange(async (value) => {
if (this.setArchiveFolder(value)) {
await this.plugin.saveSettings();
} else {
text.setValue(this.plugin.settings.archiveFolder);
}
}),
);
}
private displayAutoArchiveSettings(containerEl: HTMLElement): void {
const frequencySetting = new Setting(containerEl)
.setName("Auto-archive frequency")
.setDesc("How often to check and process auto-archive rules");
const lastRunLineEl = frequencySetting.descEl.createDiv({
cls: "setting-item-description",
});
const refreshLastRunLine = () => {
lastRunLineEl.setText(
`Last auto-archive run: ${this.getLastAutoArchiveRunText()}`,
);
};
refreshLastRunLine();
frequencySetting
.addButton((button) =>
button
.setButtonText("Auto Archive Now")
.setTooltip("Process auto-archive rules immediately")
.onClick(async () => {
button.setDisabled(true);
button.setButtonText("Processing...");
await this.autoArchiveService.processAutoArchiveRules();
refreshLastRunLine();
this.autoArchiveService.scheduleAutoArchive();
button.setButtonText("Auto Archive Now");
button.setDisabled(false);
new Notice("Auto-archive rules processed");
}),
)
.addDropdown((dropdown) =>
dropdown
.addOption("5", "Every 5 minutes")
.addOption("15", "Every 15 minutes")
.addOption("30", "Every 30 minutes")
.addOption("60", "Every 60 minutes")
.addOption("360", "Every 6 hours")
.addOption("720", "Every 12 hours")
.addOption("1440", "Every 24 hours")
.addOption("2880", "Every 48 hours")
.setValue(
this.plugin.settings.autoArchiveFrequency.toString(),
)
.onChange(async (value) => {
this.plugin.settings.autoArchiveFrequency = parseInt(
value,
10,
);
await this.plugin.saveSettings();
this.autoArchiveService.scheduleAutoArchive();
}),
);
new Setting(containerEl)
.setName("Auto-archive startup delay")
.setDesc(
"How long to wait after Obsidian starts before checking if auto-archive should run",
)
.addDropdown((dropdown) =>
dropdown
.addOption("5", "5 seconds")
.addOption("10", "10 seconds")
.addOption("30", "30 seconds")
.addOption("60", "60 seconds")
.addOption("120", "2 minutes")
.addOption("300", "5 minutes")
.setValue(
this.plugin.settings.autoArchiveStartupDelaySeconds.toString(),
)
.onChange(async (value) => {
this.plugin.settings.autoArchiveStartupDelaySeconds =
parseInt(value, 10);
await this.plugin.saveSettings();
this.autoArchiveService.scheduleStartupAutoArchiveCheck();
}),
);
new Setting(containerEl)
.setName("Auto-archive rules")
.setDesc(
"Define rules for automatically archiving files in specific folders",
)
.addButton((button) =>
button.setButtonText("Add Rule").onClick(() => {
this.addAutoArchiveRule();
}),
);
const rulesContainer = containerEl.createDiv({
cls: "auto-archive-rules-container",
});
this.displayAutoArchiveRules(rulesContainer);
}
private displayAutoArchiveRules(containerEl: HTMLElement): void {
if (this.plugin.settings.autoArchiveRules.length === 0) {
containerEl.createEl("p", {
text: "No auto-archive rules configured yet.",
cls: "setting-item-description",
});
return;
}
for (const rule of this.plugin.settings.autoArchiveRules) {
this.displayAutoArchiveRule(containerEl, rule);
}
}
private displayAutoArchiveRule(
containerEl: HTMLElement,
rule: AutoArchiveRule,
): void {
const ruleContainer = containerEl.createDiv({
cls: "auto-archive-rule",
});
new Setting(ruleContainer)
.setName(`Folder: ${rule.folderPath || "(not set)"}`)
.setClass("auto-archive-rule-header")
.addToggle((toggle) =>
toggle.setValue(rule.enabled).onChange(async (value) => {
rule.enabled = value;
await this.plugin.saveSettings();
}),
)
.addButton((button) =>
button
.setButtonText("Edit")
.setClass("mod-cta")
.onClick(() => {
this.editAutoArchiveRule(rule);
}),
)
.addButton((button) =>
button
.setButtonText("Delete")
.setWarning()
.onClick(async () => {
this.plugin.settings.autoArchiveRules =
this.plugin.settings.autoArchiveRules.filter(
(r) => r.id !== rule.id,
);
await this.plugin.saveSettings();
this.display();
}),
);
const conditionsEl = ruleContainer.createDiv({
cls: "auto-archive-rule-conditions",
});
if (rule.conditions.length === 0) {
conditionsEl.createEl("span", {
text: "No conditions set",
cls: "setting-item-description",
});
} else {
if (rule.conditions.length > 1) {
conditionsEl.createEl("div", {
text: `Logic: ${rule.logicOperator || "AND"}`,
cls: "auto-archive-rule-logic",
});
}
for (const condition of rule.conditions) {
const conditionText =
this.autoArchiveService.getConditionText(condition);
conditionsEl.createEl("div", {
text: `${conditionText}`,
cls: "auto-archive-rule-condition",
});
}
}
}
private addAutoArchiveRule(): void {
const newRule: AutoArchiveRule = {
id: crypto.randomUUID(),
enabled: true,
folderPath: "",
useFolderRegex: false,
applyRecursively: false,
conditions: [],
logicOperator: "AND",
};
this.plugin.settings.autoArchiveRules.push(newRule);
this.editAutoArchiveRule(newRule);
}
private editAutoArchiveRule(rule: AutoArchiveRule): void {
new AutoArchiveRuleModal(this.app, this.plugin, rule, async () => {
await this.plugin.saveSettings();
this.display();
}).open();
}
private validateArchiveFolderName(value: string): boolean {
return !/^\.|[:/\\]\.|:/.test(value);
}
private setArchiveFolder(value: string): boolean {
if (!this.validateArchiveFolderName(value)) {
return false;
}
this.plugin.settings.archiveFolder = value;
return true;
}
private getLastAutoArchiveRunText(): string {
const lastRunAt =
this.plugin.autoArchiveRuntimeData.lastAutoArchiveRunAt;
if (!Number.isFinite(lastRunAt) || lastRunAt <= 0) {
return "never";
}
return new Date(lastRunAt).toLocaleString();
}
}

View file

@ -0,0 +1,22 @@
import { Workspace } from "obsidian";
import { AutoArchiveService } from "./AutoArchiveService";
import type SimpleArchiver from "../main";
/**
* Bootstraps the auto-archive context menu UI.
*
* Registers folder context menu items that allow users to add, edit,
* and manage auto-archive rules at the folder level.
*
* @param service The AutoArchiveService instance
* @param workspace The Obsidian Workspace instance
* @param plugin The SimpleArchiver plugin instance
*/
export function setupAutoArchiveContextMenu(
service: AutoArchiveService,
workspace: Workspace,
plugin: SimpleArchiver,
): void {
service.setupContextMenu(workspace, plugin);
}

View file

@ -0,0 +1,61 @@
import { App, TAbstractFile } from "obsidian";
import { AutoArchiveService } from "./AutoArchiveService";
import type {
ArchiveResult,
AutoArchiveRuntimeData,
SimpleArchiverSettings,
} from "./AutoArchiveTypes";
/**
* Factory function to create and configure an AutoArchiveService instance.
*
* Encapsulates the complex wiring of dependencies (settings getter, archive callbacks, etc.)
* that the service needs to function.
*
* @param app Obsidian App instance
* @param getSettings Getter function to retrieve current plugin settings
* @param archiveFile Callback to archive a file
* @param isFileArchived Callback to check if a file is already archived
* @param persistLastRunAt Callback to persist the last auto-archive run timestamp
* @returns Configured AutoArchiveService instance
*/
export function createAutoArchiveService(
app: App,
getSettings: () => SimpleArchiverSettings & AutoArchiveRuntimeData,
archiveFile: (file: TAbstractFile) => Promise<ArchiveResult>,
isFileArchived: (file: TAbstractFile) => boolean,
persistLastRunAt: (lastRunAt: number) => Promise<void>,
): AutoArchiveService {
return new AutoArchiveService(
app,
getSettings,
archiveFile,
isFileArchived,
persistLastRunAt,
);
}
/**
* Starts the auto-archive scheduler.
*
* Invokes both the startup-delay check (which may run immediately if conditions are met)
* and the recurring interval schedule.
*
* @param service The AutoArchiveService instance
*/
export function startAutoArchive(service: AutoArchiveService): void {
service.scheduleStartupAutoArchiveCheck();
service.scheduleAutoArchive();
}
/**
* Stops the auto-archive scheduler.
*
* Clears all pending timers and intervals.
*
* @param service The AutoArchiveService instance
*/
export function stopAutoArchiveScheduler(service: AutoArchiveService): void {
service.stopAutoArchive();
}

View file

@ -0,0 +1,618 @@
import {
App,
normalizePath,
PluginSettingTab,
TAbstractFile,
TFile,
TFolder,
Workspace,
} from "obsidian";
import type {
AutoArchiveRuntimeData,
AutoArchiveCondition,
AutoArchiveRule,
SimpleArchiverSettings,
ArchiveResult,
ObsidianInternalApis,
} from "./AutoArchiveTypes";
import type SimpleArchiver from "../main";
import { AutoArchiveRuleModal } from "../modals";
import { SimpleArchiverSettingsTab } from "../SettingsTab";
// Constants extracted from magic numbers
const SETTINGS_TAB_RENDER_DELAY_MS = 200;
const MAX_FILES_PER_CYCLE = 500;
const DEFAULT_REGEX_TIMEOUT_MS = 100;
const MAX_REGEX_PATTERN_LENGTH = 512;
const MAX_REGEX_VALIDATION_INPUT_LENGTH = 1024;
type MenuEntry = {
setTitle(title: string): MenuEntry;
setIcon(icon: string): MenuEntry;
onClick(callback: () => void): MenuEntry;
setSubmenu?: () => MenuGroup;
};
type MenuGroup = {
addItem(callback: (item: MenuEntry) => void): void;
};
export class AutoArchiveService {
private app: App;
private getSettings: () => SimpleArchiverSettings & AutoArchiveRuntimeData;
private archiveFile: (file: TAbstractFile) => Promise<ArchiveResult>;
private isFileArchived: (file: TAbstractFile) => boolean;
private persistLastAutoArchiveRunAt: (lastRunAt: number) => Promise<void>;
private autoArchiveInterval: number | null = null;
private startupAutoArchiveTimeout: number | null = null;
private filesProcessedInCycle = 0;
private isProcessingAutoArchiveRules = false;
constructor(
app: App,
getSettings: () => SimpleArchiverSettings & AutoArchiveRuntimeData,
archiveFile: (file: TAbstractFile) => Promise<ArchiveResult>,
isFileArchived: (file: TAbstractFile) => boolean,
persistLastAutoArchiveRunAt: (lastRunAt: number) => Promise<void>,
) {
this.app = app;
this.getSettings = getSettings;
this.archiveFile = archiveFile;
this.isFileArchived = isFileArchived;
this.persistLastAutoArchiveRunAt = persistLastAutoArchiveRunAt;
}
scheduleStartupAutoArchiveCheck(): void {
if (this.startupAutoArchiveTimeout !== null) {
window.clearTimeout(this.startupAutoArchiveTimeout);
}
const settings = this.getSettings();
const startupDelayMs = settings.autoArchiveStartupDelaySeconds * 1000;
this.startupAutoArchiveTimeout = window.setTimeout(() => {
this.startupAutoArchiveTimeout = null;
void this.runStartupAutoArchiveCheck();
}, startupDelayMs);
}
private async runStartupAutoArchiveCheck(): Promise<void> {
const settings = this.getSettings();
const intervalMs = settings.autoArchiveFrequency * 60 * 1000;
const elapsedSinceLastRun = Date.now() - settings.lastAutoArchiveRunAt;
if (elapsedSinceLastRun >= intervalMs) {
await this.processAutoArchiveRules();
}
}
private async persistLastRunTimestamp(timestamp: number): Promise<void> {
try {
await this.persistLastAutoArchiveRunAt(timestamp);
} catch (error) {
console.error(
"Failed to persist last auto-archive run time:",
error,
);
}
}
/**
* Safely compiles and validates a regex pattern.
* This uses conservative static checks and adversarial test input to reduce
* ReDoS risk. It intentionally rejects patterns that look unsafe.
*/
private validateRegexPattern(pattern: string): RegExp | null {
if (pattern.length > MAX_REGEX_PATTERN_LENGTH) {
console.error(
`Regex pattern is too long (${pattern.length} chars): ${pattern}`,
);
return null;
}
if (this.isLikelyUnsafeRegex(pattern)) {
console.error(`Unsafe regex pattern rejected: ${pattern}`);
return null;
}
try {
const regex = new RegExp(pattern);
for (const testInput of this.getRegexValidationInputs()) {
const elapsedMs = this.measureRegexTestTime(regex, testInput);
if (
elapsedMs === null ||
elapsedMs > DEFAULT_REGEX_TIMEOUT_MS
) {
console.error(
`Regex pattern failed performance validation on adversarial input: ${pattern}`,
);
return null;
}
}
return regex;
} catch (error) {
console.error(`Invalid regex pattern: ${pattern}`, error);
return null;
}
}
/**
* Detects common high-risk regex constructs associated with catastrophic
* backtracking. This is intentionally conservative.
*/
private isLikelyUnsafeRegex(pattern: string): boolean {
const hasBackreferences = /(^|[^\\])\\[1-9]/.test(pattern);
if (hasBackreferences) {
return true;
}
const nestedQuantifierPatterns = [
/\((?:[^()\\]|\\.)*[+*](?:[^()\\]|\\.)*\)\s*[+*{]/,
/\((?:[^()\\]|\\.)*\{\d+,?\d*\}(?:[^()\\]|\\.)*\)\s*[+*{]/,
/\((?:[^()\\]|\\.)*\.[+*](?:[^()\\]|\\.)*\)\s*[+*{]/,
];
return nestedQuantifierPatterns.some((regex) => regex.test(pattern));
}
private getRegexValidationInputs(): string[] {
const repeatedA = "a".repeat(MAX_REGEX_VALIDATION_INPUT_LENGTH);
const repeatedPathSegment = "segment/".repeat(
Math.floor(MAX_REGEX_VALIDATION_INPUT_LENGTH / 8),
);
return [
`${repeatedA}!`,
`/${repeatedPathSegment}end`,
`${repeatedA}.md`,
"test_file_name.md",
];
}
private measureRegexTestTime(regex: RegExp, input: string): number | null {
try {
const start = performance.now();
regex.test(input);
return performance.now() - start;
} catch {
return null;
}
}
scheduleAutoArchive(): void {
if (this.autoArchiveInterval !== null) {
window.clearInterval(this.autoArchiveInterval);
}
const settings = this.getSettings();
const intervalMs = settings.autoArchiveFrequency * 60 * 1000;
this.autoArchiveInterval = window.setInterval(() => {
void this.processAutoArchiveRules().catch((error) => {
console.error("Auto-archive cycle failed:", error);
});
}, intervalMs);
}
stopAutoArchive(): void {
if (this.autoArchiveInterval !== null) {
window.clearInterval(this.autoArchiveInterval);
this.autoArchiveInterval = null;
}
if (this.startupAutoArchiveTimeout !== null) {
window.clearTimeout(this.startupAutoArchiveTimeout);
this.startupAutoArchiveTimeout = null;
}
}
async processAutoArchiveRules(): Promise<void> {
if (this.isProcessingAutoArchiveRules) {
console.warn(
"Auto-archive cycle skipped because a previous cycle is still running.",
);
return;
}
this.isProcessingAutoArchiveRules = true;
try {
const settings = this.getSettings();
const enabledRules = settings.autoArchiveRules.filter(
(rule) => rule.enabled,
);
if (enabledRules.length === 0) {
return;
}
let totalArchived = 0;
this.filesProcessedInCycle = 0;
for (const rule of enabledRules) {
// Check if we've hit the rate limit
if (this.filesProcessedInCycle >= MAX_FILES_PER_CYCLE) {
console.warn(
`Auto-archive cycle reached maximum file limit (${MAX_FILES_PER_CYCLE}). Remaining rules skipped to prevent performance issues.`,
);
break;
}
let foldersToProcess: TFolder[] = [];
if (rule.useFolderRegex) {
const regex = this.validateRegexPattern(rule.folderPath);
if (!regex) {
console.error(
`Skipping rule with invalid regex pattern: ${rule.folderPath}`,
);
continue;
}
const allFolders = this.app.vault.getAllFolders();
foldersToProcess = allFolders.filter((folder) =>
regex.test(folder.path),
);
} else {
const folder = this.app.vault.getFolderByPath(
normalizePath(rule.folderPath),
);
if (!folder) {
console.warn(
`Auto-archive rule references non-existent folder: ${rule.folderPath}`,
);
continue;
}
foldersToProcess = [folder];
}
const filesToArchive = [];
for (const folder of foldersToProcess) {
if (!folder) continue; // Additional safety check
const files = this.getFilesFromFolder(
folder,
rule.applyRecursively || false,
);
for (const file of files) {
if (this.filesProcessedInCycle >= MAX_FILES_PER_CYCLE) {
break;
}
if (await this.evaluateAutoArchiveRule(file, rule)) {
filesToArchive.push(file);
this.filesProcessedInCycle++;
}
}
}
for (const file of filesToArchive) {
try {
const result = await this.archiveFile(file);
if (result.success) {
totalArchived++;
}
} catch (error) {
console.error(
`Auto-archive failed for file: ${file.path}`,
error,
);
}
}
}
if (totalArchived > 0) {
console.log(`Auto-archive: ${totalArchived} files archived`);
}
} catch (error) {
console.error("Auto-archive cycle failed:", error);
} finally {
await this.persistLastRunTimestamp(Date.now());
this.isProcessingAutoArchiveRules = false;
}
}
private getFilesFromFolder(folder: TFolder, recursive: boolean): TFile[] {
const files: TFile[] = [];
if (!folder || !folder.children) {
return files;
}
for (const child of folder.children) {
if (child instanceof TFile) {
files.push(child);
} else if (recursive && child instanceof TFolder) {
files.push(...this.getFilesFromFolder(child, recursive));
}
}
return files;
}
private async evaluateAutoArchiveRule(
file: TAbstractFile,
rule: AutoArchiveRule,
): Promise<boolean> {
if (this.isFileArchived(file)) {
return false;
}
if (rule.conditions.length === 0) {
return false;
}
const operator = rule.logicOperator || "AND";
if (operator === "OR") {
for (const condition of rule.conditions) {
if (await this.evaluateCondition(file, condition)) {
return true;
}
}
return false;
} else {
for (const condition of rule.conditions) {
if (!(await this.evaluateCondition(file, condition))) {
return false;
}
}
return true;
}
}
private async evaluateCondition(
file: TAbstractFile,
condition: AutoArchiveCondition,
): Promise<boolean> {
if (condition.type === "fileAge") {
const ageInDays = parseInt(condition.value);
if (isNaN(ageInDays)) {
return false;
}
const stats = await this.app.vault.adapter.stat(file.path);
if (!stats) {
return false;
}
const fileAgeMs = Date.now() - stats.mtime;
const fileAgeDays = fileAgeMs / (1000 * 60 * 60 * 24);
return fileAgeDays >= ageInDays;
} else if (condition.type === "regexPattern") {
const regex = this.validateRegexPattern(condition.value);
if (!regex) {
console.error(
`Invalid regex pattern in auto-archive rule: ${condition.value}`,
);
return false;
}
return regex.test(file.name);
}
return false;
}
getRulesForFolder(folderPath: string): AutoArchiveRule[] {
const settings = this.getSettings();
return settings.autoArchiveRules.filter((rule) => {
if (rule.useFolderRegex) {
const regex = this.validateRegexPattern(rule.folderPath);
if (!regex) {
return false;
}
return regex.test(folderPath);
}
return rule.folderPath === folderPath;
});
}
/**
* Generates human-readable text for a condition (shared utility).
*/
getConditionText(condition: AutoArchiveCondition): string {
if (condition.type === "fileAge") {
return `File age ≥ ${condition.value} days`;
} else if (condition.type === "regexPattern") {
return `File name matches: ${condition.value}`;
}
return "Unknown condition";
}
getRuleDisplayText(rule: AutoArchiveRule): string {
const icon = rule.enabled ? "✓" : "○";
if (rule.conditions.length === 0) {
return `${icon} (no conditions)`;
}
if (rule.conditions.length === 1) {
const conditionText = this.getConditionText(rule.conditions[0]);
return `${icon} ${conditionText}`;
}
// Multiple conditions: join with logic operator
const operator = rule.logicOperator === "OR" ? " OR " : " AND ";
const conditionsText = rule.conditions
.map((c) => this.getConditionText(c))
.join(operator);
// Truncate if too long
const fullText = `${icon} ${conditionsText}`;
const MAX_LENGTH = 60;
if (fullText.length > MAX_LENGTH) {
const firstCondition = this.getConditionText(rule.conditions[0]);
return `${icon} ${firstCondition} ${operator}... (${rule.conditions.length} total)`;
}
return fullText;
}
setupContextMenu(workspace: Workspace, plugin: SimpleArchiver): void {
plugin.registerEvent(
workspace.on("file-menu", (menu, file) => {
// Only show for folders
if (!(file instanceof TFolder)) {
return;
}
menu.addItem((item) => {
item.setTitle("Auto-archive").setIcon("clock");
// Add submenu with proper type safety
const submenu = this.getSubmenu(item);
if (!submenu) {
console.error("Failed to create submenu");
return;
}
// Add "Add rule" item
submenu.addItem((subitem: MenuEntry) => {
subitem
.setTitle("Add rule")
.setIcon("plus")
.onClick(() => {
this.openAutoArchiveRuleForFolder(
plugin,
file.path,
);
});
});
// Check if there are existing rules for this folder
const existingRules = this.getRulesForFolder(file.path);
if (existingRules.length > 0) {
// Add "Edit Rule" submenu item
submenu.addItem((subitem: MenuEntry) => {
subitem.setTitle("Edit Rule").setIcon("pencil");
const editSubmenu = this.getSubmenu(subitem);
if (!editSubmenu) return;
// Add each rule as a submenu item
for (const rule of existingRules) {
editSubmenu.addItem((ruleItem: MenuEntry) => {
const displayText =
this.getRuleDisplayText(rule);
const icon = rule.enabled
? "check"
: "circle";
ruleItem
.setTitle(displayText)
.setIcon(icon)
.onClick(() => {
new AutoArchiveRuleModal(
this.app,
plugin,
rule,
async () => {
await plugin.saveSettings();
},
).open();
});
});
}
});
}
});
}),
);
}
/**
* Safely retrieves submenu from menu item with type safety.
*/
private getSubmenu(item: MenuEntry): MenuGroup | null {
try {
return typeof item.setSubmenu === "function"
? item.setSubmenu()
: null;
} catch (error) {
console.error("Failed to create submenu:", error);
return null;
}
}
private openAutoArchiveRuleForFolder(
plugin: SimpleArchiver,
folderPath: string,
): void {
try {
const obsidianApis = this.app as unknown as ObsidianInternalApis;
if (!obsidianApis.setting) {
console.error("Unable to access settings API");
return;
}
obsidianApis.setting.open();
const pluginId = plugin.manifest.id;
obsidianApis.setting.openTabById(pluginId);
window.setTimeout(() => {
const tabs = obsidianApis.setting
.pluginTabs as PluginSettingTab[];
for (const tab of tabs) {
if (tab instanceof SimpleArchiverSettingsTab) {
tab.activeTab = "autoArchive";
tab.display();
const newRule: AutoArchiveRule = {
id: crypto.randomUUID(),
enabled: true,
folderPath,
useFolderRegex: false,
applyRecursively: false,
conditions: [],
logicOperator: "AND",
};
plugin.settings.autoArchiveRules.push(newRule);
try {
new AutoArchiveRuleModal(
this.app,
plugin,
newRule,
async () => {
await plugin.saveSettings();
tab.display();
},
async () => {
plugin.settings.autoArchiveRules =
plugin.settings.autoArchiveRules.filter(
(r) => r.id !== newRule.id,
);
await plugin.saveSettings();
tab.display();
},
).open();
} catch (error) {
plugin.settings.autoArchiveRules =
plugin.settings.autoArchiveRules.filter(
(r) => r.id !== newRule.id,
);
console.error(
"Failed to open auto-archive rule modal:",
error,
);
}
break;
}
}
}, SETTINGS_TAB_RENDER_DELAY_MS);
} catch (error) {
console.error(
"Failed to open auto-archive rule for folder:",
error,
);
}
}
}

View file

@ -0,0 +1,96 @@
import type {
AutoArchiveRuntimeData,
AutoArchiveRule,
SimpleArchiverSettings,
} from "./AutoArchiveTypes";
/**
* Default settings for auto-archive functionality.
* These are composed into the plugin's overall DEFAULT_SETTINGS.
*/
export const AUTO_ARCHIVE_DEFAULT_SETTINGS: Partial<SimpleArchiverSettings> = {
autoArchiveRules: [],
autoArchiveFrequency: 60,
autoArchiveStartupDelaySeconds: 30,
};
export const AUTO_ARCHIVE_DEFAULT_RUNTIME_DATA: AutoArchiveRuntimeData = {
lastAutoArchiveRunAt: 0,
};
/**
* Migrates auto-archive settings from loaded data, ensuring backward compatibility
* and normalizing all required fields to safe defaults.
*
* @param settings The loaded settings object (may be partial from old configs)
* @returns Object with normalized settings and a flag indicating if changes were made
*/
export function migrateAutoArchiveSettings(settings: SimpleArchiverSettings): {
settings: SimpleArchiverSettings;
changed: boolean;
} {
let changed = false;
// Migrate per-rule defaults
if (settings.autoArchiveRules) {
settings.autoArchiveRules = settings.autoArchiveRules.map((rule) => {
const updates: Partial<AutoArchiveRule> = {};
if (!rule.logicOperator) {
changed = true;
updates.logicOperator = "AND" as "AND" | "OR";
}
if (rule.useFolderRegex === undefined) {
changed = true;
updates.useFolderRegex = false;
}
if (rule.applyRecursively === undefined) {
changed = true;
updates.applyRecursively = false;
}
return { ...rule, ...updates };
});
}
// Normalize startup delay seconds
if (
!Number.isFinite(settings.autoArchiveStartupDelaySeconds) ||
settings.autoArchiveStartupDelaySeconds < 0
) {
changed = true;
settings.autoArchiveStartupDelaySeconds =
AUTO_ARCHIVE_DEFAULT_SETTINGS.autoArchiveStartupDelaySeconds ?? 30;
}
// Normalize auto-archive frequency (minutes)
if (
!Number.isFinite(settings.autoArchiveFrequency) ||
settings.autoArchiveFrequency <= 0
) {
changed = true;
settings.autoArchiveFrequency =
AUTO_ARCHIVE_DEFAULT_SETTINGS.autoArchiveFrequency ?? 60;
}
return { settings, changed };
}
export function normalizeAutoArchiveRuntimeData(
runtimeData: Partial<AutoArchiveRuntimeData> | null | undefined,
): AutoArchiveRuntimeData {
const lastAutoArchiveRunAt = runtimeData?.lastAutoArchiveRunAt;
if (
!Number.isFinite(lastAutoArchiveRunAt) ||
(lastAutoArchiveRunAt ?? 0) < 0
) {
return { ...AUTO_ARCHIVE_DEFAULT_RUNTIME_DATA };
}
return {
lastAutoArchiveRunAt: lastAutoArchiveRunAt ?? 0,
};
}

View file

@ -0,0 +1,42 @@
export interface AutoArchiveCondition {
type: "fileAge" | "regexPattern";
value: string;
}
export interface AutoArchiveRule {
id: string;
enabled: boolean;
folderPath: string;
useFolderRegex: boolean;
applyRecursively: boolean;
conditions: AutoArchiveCondition[];
logicOperator: "AND" | "OR";
}
export interface SimpleArchiverSettings {
archiveFolder: string;
autoArchiveRules: AutoArchiveRule[];
autoArchiveFrequency: number;
autoArchiveStartupDelaySeconds: number;
}
export interface AutoArchiveRuntimeData {
lastAutoArchiveRunAt: number;
}
export interface ArchiveResult {
success: boolean;
message: string;
}
/**
* Interface for Obsidian's internal settings API.
* Used for type-safe access to settings functionality.
*/
export interface ObsidianInternalApis {
setting: {
open(): void;
openTabById(pluginId: string): void;
pluginTabs: unknown[];
};
}

31
autoarchive/index.ts Normal file
View file

@ -0,0 +1,31 @@
/**
* Auto-Archive Module Barrel Export
*
* This file consolidates all public APIs from the auto-archive subsystem,
* allowing main.ts to import everything via a single import statement.
*/
export {
AUTO_ARCHIVE_DEFAULT_SETTINGS,
AUTO_ARCHIVE_DEFAULT_RUNTIME_DATA,
migrateAutoArchiveSettings,
normalizeAutoArchiveRuntimeData,
} from "./AutoArchiveSettings";
export {
createAutoArchiveService,
startAutoArchive,
stopAutoArchiveScheduler,
} from "./AutoArchiveLifecycle";
export { setupAutoArchiveContextMenu } from "./AutoArchiveContextMenu";
export { AutoArchiveService } from "./AutoArchiveService";
export type {
ArchiveResult,
AutoArchiveRuntimeData,
AutoArchiveCondition,
AutoArchiveRule,
SimpleArchiverSettings,
} from "./AutoArchiveTypes";

343
main.ts
View file

@ -1,31 +1,40 @@
import {
App,
Editor,
MarkdownView,
Modal,
normalizePath,
Notice,
Plugin,
PluginSettingTab,
Setting,
TAbstractFile,
} from "obsidian";
interface SimpleArchiverSettings {
archiveFolder: string;
}
import {
AUTO_ARCHIVE_DEFAULT_SETTINGS,
AUTO_ARCHIVE_DEFAULT_RUNTIME_DATA,
migrateAutoArchiveSettings,
normalizeAutoArchiveRuntimeData,
createAutoArchiveService,
startAutoArchive,
stopAutoArchiveScheduler,
setupAutoArchiveContextMenu,
AutoArchiveService,
type AutoArchiveRuntimeData,
type ArchiveResult,
type SimpleArchiverSettings,
} from "./autoarchive";
import { SimpleArchiverPromptModal } from "./modals";
import { SimpleArchiverSettingsTab } from "./SettingsTab";
interface ArchiveResult {
success: boolean;
message: string;
}
const AUTO_ARCHIVE_DATA_FILE = "autoarchive_data.json";
const DEFAULT_SETTINGS: SimpleArchiverSettings = {
archiveFolder: "Archive",
};
...AUTO_ARCHIVE_DEFAULT_SETTINGS,
} as SimpleArchiverSettings;
export default class SimpleArchiver extends Plugin {
settings: SimpleArchiverSettings;
autoArchiveRuntimeData: AutoArchiveRuntimeData;
private autoArchiveService: AutoArchiveService;
async onload() {
await this.loadSettings();
@ -36,7 +45,7 @@ export default class SimpleArchiver extends Plugin {
editorCheckCallback: (
checking: boolean,
editor: Editor,
view: MarkdownView
view: MarkdownView,
) => {
const canBeArchived =
view.file && !this.isFileArchived(view.file);
@ -61,7 +70,7 @@ export default class SimpleArchiver extends Plugin {
editorCheckCallback: (
checking: boolean,
editor: Editor,
view: MarkdownView
view: MarkdownView,
) => {
const canBeUnarchived =
view.file && this.isFileArchived(view.file);
@ -80,7 +89,37 @@ export default class SimpleArchiver extends Plugin {
},
});
this.addSettingTab(new SimpleArchiverSettingsTab(this.app, this));
this.autoArchiveService = createAutoArchiveService(
this.app,
() => ({
...this.settings,
lastAutoArchiveRunAt:
this.autoArchiveRuntimeData.lastAutoArchiveRunAt,
}),
(file) => this.archiveFile(file),
(file) => this.isFileArchived(file),
async (lastRunAt) => {
await this.saveAutoArchiveRuntimeData({
lastAutoArchiveRunAt: lastRunAt,
});
},
);
startAutoArchive(this.autoArchiveService);
this.addSettingTab(
new SimpleArchiverSettingsTab(
this.app,
this,
this.autoArchiveService,
),
);
// Setup auto-archive context menu
setupAutoArchiveContextMenu(
this.autoArchiveService,
this.app.workspace,
this,
);
// Archive file context menu
this.registerEvent(
@ -98,11 +137,12 @@ export default class SimpleArchiver extends Plugin {
if (result.success) {
new Notice(result.message);
} else {
new Error(result.message);
new Notice(`Error: ${result.message}`);
console.error(result.message);
}
});
});
})
}),
);
this.registerEvent(
@ -118,7 +158,7 @@ export default class SimpleArchiver extends Plugin {
await this.archiveAllFiles(files);
});
});
})
}),
);
// Unarchive file context menu
@ -137,11 +177,12 @@ export default class SimpleArchiver extends Plugin {
if (result.success) {
new Notice(result.message);
} else {
new Error(result.message);
new Notice(`Error: ${result.message}`);
console.error(result.message);
}
});
});
})
}),
);
this.registerEvent(
@ -157,7 +198,7 @@ export default class SimpleArchiver extends Plugin {
await this.unarchiveAllFiles(files);
});
});
})
}),
);
}
@ -165,13 +206,30 @@ export default class SimpleArchiver extends Plugin {
return file.path.startsWith(this.settings.archiveFolder);
}
/**
* Safely resolves the original file path from an archived path.
* Handles edge cases like files at vault root or missing parent folders.
*/
private resolveOriginalPath(archivedPath: string): string | null {
const archiveFolderPrefix = this.settings.archiveFolder + "/";
// Check that the file is actually in the archive folder
if (!archivedPath.startsWith(archiveFolderPrefix)) {
console.error(`File is not in archive folder: ${archivedPath}`);
return null;
}
// Remove the archive folder prefix to get the original path
return archivedPath.substring(archiveFolderPrefix.length);
}
private async archiveFile(file: TAbstractFile): Promise<ArchiveResult> {
if (this.isFileArchived(file)) {
return { success: false, message: "Item is already archived" };
}
const destinationFilePath = normalizePath(
`${this.settings.archiveFolder}/${file.path}`
`${this.settings.archiveFolder}/${file.path}`,
);
const existingItem =
@ -197,7 +255,7 @@ export default class SimpleArchiver extends Plugin {
success: false,
message: "Archive operation cancelled",
});
}
},
);
prompt.open();
});
@ -221,10 +279,10 @@ export default class SimpleArchiver extends Plugin {
}
private async moveFileToArchive(
file: TAbstractFile
file: TAbstractFile,
): Promise<ArchiveResult> {
const destinationPath = normalizePath(
`${this.settings.archiveFolder}/${file.parent?.path}`
`${this.settings.archiveFolder}/${file.parent?.path}`,
);
const destinationFolder =
@ -235,7 +293,7 @@ export default class SimpleArchiver extends Plugin {
}
const destinationFilePath = normalizePath(
`${destinationPath}/${file.name}`
`${destinationPath}/${file.name}`,
);
try {
@ -245,43 +303,69 @@ export default class SimpleArchiver extends Plugin {
message: `${file.name} archived successfully`,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
success: false,
message: `Unable to archive ${file.name}: ${error}`,
message: `Unable to archive ${file.name}: ${errorMessage}`,
};
}
}
private async moveFileOutOfArchive(
file: TAbstractFile
file: TAbstractFile,
): Promise<ArchiveResult> {
const originalPath = file.path.substring(
this.settings.archiveFolder.length + 1
);
const originalParentPath = originalPath.substring(
0,
originalPath.lastIndexOf("/")
);
const originalPath = this.resolveOriginalPath(file.path);
if (!originalPath) {
return {
success: false,
message: `Unable to unarchive: Invalid archive path ${file.path}`,
};
}
// Extract parent folder path by finding the last slash
const lastSlashIndex = originalPath.lastIndexOf("/");
const originalParentPath =
lastSlashIndex > 0
? originalPath.substring(0, lastSlashIndex)
: null;
if (originalParentPath) {
const originalFolder =
this.app.vault.getFolderByPath(originalParentPath);
if (originalFolder == null) {
await this.app.vault.createFolder(normalizePath(originalParentPath));
if (!originalFolder) {
try {
await this.app.vault.createFolder(
normalizePath(originalParentPath),
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
success: false,
message: `Unable to create folder: ${errorMessage}`,
};
}
}
}
try {
await this.app.fileManager.renameFile(file, normalizePath(originalPath));
await this.app.fileManager.renameFile(
file,
normalizePath(originalPath),
);
return {
success: true,
message: `${file.name} unarchived successfully`,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
success: false,
message: `Unable to unarchive ${file.name}: ${error}`,
message: `Unable to unarchive ${file.name}: ${errorMessage}`,
};
}
}
@ -291,9 +375,14 @@ export default class SimpleArchiver extends Plugin {
return { success: false, message: "Item is not archived" };
}
const originalPath = file.path.substring(
this.settings.archiveFolder.length + 1
);
const originalPath = this.resolveOriginalPath(file.path);
if (!originalPath) {
return {
success: false,
message: `Unable to unarchive: Invalid archive path`,
};
}
const existingItem = this.app.vault.getAbstractFileByPath(originalPath);
@ -316,7 +405,7 @@ export default class SimpleArchiver extends Plugin {
success: false,
message: "Unarchive operation cancelled",
});
}
},
);
prompt.open();
});
@ -338,98 +427,104 @@ export default class SimpleArchiver extends Plugin {
new Notice(`${unarchived} files unarchived`);
}
onunload() {
stopAutoArchiveScheduler(this.autoArchiveService);
}
private async loadSettings() {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
await this.loadData()
);
const loadedData = await this.loadData();
const lastAutoArchiveRunAt =
this.getLegacyLastAutoArchiveRunAt(loadedData);
this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedData);
delete (
this.settings as Partial<SimpleArchiverSettings> & {
lastAutoArchiveRunAt?: number;
}
).lastAutoArchiveRunAt;
this.autoArchiveRuntimeData =
await this.loadAutoArchiveRuntimeData(lastAutoArchiveRunAt);
// Migrate auto-archive settings for backward compatibility
const { changed } = migrateAutoArchiveSettings(this.settings);
// Persist the migration if any changes were made
if (changed || lastAutoArchiveRunAt !== null) {
await this.saveSettings();
}
}
async saveSettings() {
await this.saveData(this.settings);
}
}
class SimpleArchiverPromptModal extends Modal {
constructor(
app: App,
title: string,
message: string,
yesButtonText: string,
noButtonText: string,
callback: () => Promise<void>,
cancelCallback: () => Promise<void>
) {
super(app);
this.setTitle(title);
this.setContent(message);
new Setting(this.contentEl)
.addButton((btn) =>
btn
.setButtonText(yesButtonText)
.setWarning()
.onClick(() => {
callback();
this.close();
})
)
.addButton((btn) =>
btn.setButtonText(noButtonText).onClick(() => {
cancelCallback();
this.close();
})
);
}
}
class SimpleArchiverSettingsTab extends PluginSettingTab {
plugin: SimpleArchiver;
constructor(app: App, plugin: SimpleArchiver) {
super(app, plugin);
this.plugin = plugin;
async saveAutoArchiveRuntimeData(runtimeData: AutoArchiveRuntimeData) {
this.autoArchiveRuntimeData =
normalizeAutoArchiveRuntimeData(runtimeData);
await this.app.vault.adapter.write(
`${this.manifest.dir}/${AUTO_ARCHIVE_DATA_FILE}`,
JSON.stringify(this.autoArchiveRuntimeData, null, 2),
);
}
display(): void {
const { containerEl } = this;
private async loadAutoArchiveRuntimeData(
legacyLastAutoArchiveRunAt: number | null,
): Promise<AutoArchiveRuntimeData> {
const existingRuntimeData = await this.readAutoArchiveRuntimeDataFile();
containerEl.empty();
new Setting(containerEl)
.setName("Archive folder")
.setDesc(
"The folder to use as the Archive. If the folder doesn't exist, it will be created when archiving a " +
'note. Folder names must not contain "\\", "/" or ":" and must not start with ".".'
)
.addText((text) =>
text
.setPlaceholder("Archive folder")
.setValue(normalizePath(this.plugin.settings.archiveFolder))
.onChange(async (value) => {
if (this.setArchiveFolder(value)) {
await this.plugin.saveSettings();
} else {
text.setValue(this.plugin.settings.archiveFolder);
}
})
);
}
private validateArchiveFolderName(value: string): boolean {
// Validate folder does not start with '.', contain ':' or contain a relative path
return !/^\.|[:/\\]\.|:/.test(value);
}
private setArchiveFolder(value: string): boolean {
if (!this.validateArchiveFolderName(value)) {
return false;
if (existingRuntimeData !== null) {
return existingRuntimeData;
}
this.plugin.settings.archiveFolder = value;
return true;
const migratedRuntimeData = normalizeAutoArchiveRuntimeData({
lastAutoArchiveRunAt:
legacyLastAutoArchiveRunAt ??
AUTO_ARCHIVE_DEFAULT_RUNTIME_DATA.lastAutoArchiveRunAt,
});
if (legacyLastAutoArchiveRunAt !== null) {
await this.saveAutoArchiveRuntimeData(migratedRuntimeData);
}
return migratedRuntimeData;
}
private async readAutoArchiveRuntimeDataFile(): Promise<AutoArchiveRuntimeData | null> {
const runtimeDataPath = `${this.manifest.dir}/${AUTO_ARCHIVE_DATA_FILE}`;
if (!(await this.app.vault.adapter.exists(runtimeDataPath))) {
return null;
}
try {
const content = await this.app.vault.adapter.read(runtimeDataPath);
const parsedData = JSON.parse(
content,
) as Partial<AutoArchiveRuntimeData>;
return normalizeAutoArchiveRuntimeData(parsedData);
} catch (error) {
console.error("Failed to load auto-archive runtime data:", error);
return { ...AUTO_ARCHIVE_DEFAULT_RUNTIME_DATA };
}
}
private getLegacyLastAutoArchiveRunAt(loadedData: unknown): number | null {
if (
loadedData === null ||
typeof loadedData !== "object" ||
!("lastAutoArchiveRunAt" in loadedData)
) {
return null;
}
const { lastAutoArchiveRunAt } = loadedData as {
lastAutoArchiveRunAt?: unknown;
};
return typeof lastAutoArchiveRunAt === "number"
? lastAutoArchiveRunAt
: null;
}
}

405
modals.ts Normal file
View file

@ -0,0 +1,405 @@
import { App, Modal, normalizePath, Setting } from "obsidian";
import type {
AutoArchiveCondition,
AutoArchiveRule,
} from "./autoarchive/AutoArchiveTypes";
import type SimpleArchiver from "./main";
export class SimpleArchiverPromptModal extends Modal {
constructor(
app: App,
title: string,
message: string,
yesButtonText: string,
noButtonText: string,
callback: () => Promise<void>,
cancelCallback: () => Promise<void>,
) {
super(app);
this.setTitle(title);
this.setContent(message);
new Setting(this.contentEl)
.addButton((btn) =>
btn
.setButtonText(yesButtonText)
.setWarning()
.onClick(() => {
callback();
this.close();
}),
)
.addButton((btn) =>
btn.setButtonText(noButtonText).onClick(() => {
cancelCallback();
this.close();
}),
);
}
}
export class AutoArchiveRuleModal extends Modal {
plugin: SimpleArchiver;
rule: AutoArchiveRule;
draftRule: AutoArchiveRule;
onSave: () => Promise<void>;
onCancel?: () => Promise<void>;
folderPathInput: HTMLInputElement;
validationErrorEl: HTMLDivElement;
closeReason: "none" | "saved" | "cancelled" = "none";
constructor(
app: App,
plugin: SimpleArchiver,
rule: AutoArchiveRule,
onSave: () => Promise<void>,
onCancel?: () => Promise<void>,
) {
super(app);
this.plugin = plugin;
this.rule = rule;
this.draftRule = this.cloneRule(rule);
this.onSave = onSave;
this.onCancel = onCancel;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
this.setTitle("Edit Auto-Archive Rule");
this.validationErrorEl = contentEl.createDiv({
cls: "auto-archive-rule-validation-error",
});
this.validationErrorEl.style.display = "none";
this.validationErrorEl.style.color = "var(--text-error)";
this.validationErrorEl.style.fontWeight = "600";
this.validationErrorEl.style.marginBottom = "12px";
new Setting(contentEl)
.setName("Folder path")
.setDesc(
"The folder to apply this rule to (e.g., 'Projects' or 'Notes/Daily')",
)
.addText((text) => {
this.folderPathInput = text.inputEl;
text.setPlaceholder("folder/path")
.setValue(this.draftRule.folderPath)
.onChange((value) => {
this.draftRule.folderPath = value;
});
});
new Setting(contentEl)
.setName("Use folder path as regular expression")
.setDesc(
"When enabled, the folder path will be treated as a regular expression pattern to match multiple folders",
)
.addToggle((toggle) =>
toggle
.setValue(this.draftRule.useFolderRegex || false)
.onChange((value) => {
this.draftRule.useFolderRegex = value;
}),
);
new Setting(contentEl)
.setName("Apply recursively to subfolders")
.setDesc(
"When enabled, the rule will be applied to all files in subfolders as well",
)
.addToggle((toggle) =>
toggle
.setValue(this.draftRule.applyRecursively || false)
.onChange((value) => {
this.draftRule.applyRecursively = value;
}),
);
new Setting(contentEl)
.setName("Logic operator")
.setDesc("How to combine multiple conditions")
.addDropdown((dropdown) =>
dropdown
.addOption("AND", "AND (all conditions must match)")
.addOption("OR", "OR (any condition must match)")
.setValue(this.draftRule.logicOperator || "AND")
.onChange((value) => {
this.draftRule.logicOperator = value as "AND" | "OR";
}),
);
contentEl.createEl("h3", { text: "Conditions" });
const conditionsContainer = contentEl.createDiv({
cls: "auto-archive-conditions-container",
});
this.displayConditions(conditionsContainer);
new Setting(contentEl).addButton((button) =>
button.setButtonText("Add Condition").onClick(() => {
this.addCondition();
}),
);
new Setting(contentEl)
.addButton((button) =>
button
.setButtonText("Save")
.setCta()
.onClick(async () => {
this.clearValidationError();
const validationMessage = this.validateRule();
if (validationMessage) {
this.showValidationError(validationMessage);
return;
}
this.applyDraftToRule();
await this.onSave();
this.closeReason = "saved";
this.close();
}),
)
.addButton((button) =>
button.setButtonText("Cancel").onClick(async () => {
await this.handleCancel();
this.closeReason = "cancelled";
this.close();
}),
);
}
private displayConditions(containerEl: HTMLElement): void {
containerEl.empty();
if (this.draftRule.conditions.length === 0) {
containerEl.createEl("p", {
text: "No conditions added yet. Add at least one condition.",
cls: "setting-item-description",
});
return;
}
for (let i = 0; i < this.draftRule.conditions.length; i++) {
const condition = this.draftRule.conditions[i];
this.displayCondition(containerEl, condition, i);
}
}
private displayCondition(
containerEl: HTMLElement,
condition: AutoArchiveCondition,
index: number,
): void {
const conditionEl = containerEl.createDiv({
cls: "auto-archive-condition",
});
new Setting(conditionEl)
.setName(`Condition ${index + 1}`)
.addDropdown((dropdown) =>
dropdown
.addOption("fileAge", "File age (days)")
.addOption("regexPattern", "File name regex")
.setValue(condition.type)
.onChange((value) => {
const conditionType = value as
| "fileAge"
| "regexPattern";
if (
conditionType === "fileAge" ||
conditionType === "regexPattern"
) {
condition.type = conditionType;
condition.value = "";
this.displayConditions(containerEl);
}
}),
)
.addText((text) =>
text
.setPlaceholder(
condition.type === "fileAge"
? "Number of days"
: "Regular expression",
)
.setValue(condition.value)
.onChange((value) => {
condition.value = value;
}),
)
.addButton((button) =>
button
.setButtonText("Remove")
.setWarning()
.onClick(() => {
this.draftRule.conditions.splice(index, 1);
this.displayConditions(containerEl);
}),
);
}
private addCondition(): void {
this.draftRule.conditions.push({
type: "fileAge",
value: "",
});
const conditionsContainer = this.contentEl.querySelector(
".auto-archive-conditions-container",
) as HTMLElement;
if (conditionsContainer) {
this.displayConditions(conditionsContainer);
}
}
private validateRule(): string | null {
const folderPath = (this.draftRule.folderPath ?? "").trim();
if (!folderPath) {
return "Folder path is required.";
}
if (this.draftRule.conditions.length === 0) {
return "Cannot save rule with no conditions.";
}
for (let index = 0; index < this.draftRule.conditions.length; index++) {
const condition = this.draftRule.conditions[index];
const conditionValue = (condition.value ?? "").trim();
if (!conditionValue) {
return `Condition ${index + 1} requires a value.`;
}
if (condition.type === "fileAge") {
const dayCount = Number(conditionValue);
if (!Number.isInteger(dayCount) || dayCount <= 0) {
return `Condition ${index + 1} must be a positive whole number of days.`;
}
}
if (condition.type === "regexPattern") {
try {
new RegExp(conditionValue);
} catch {
return `Condition ${index + 1} has an invalid regular expression.`;
}
}
}
const archiveFolder = this.normalizeRulePath(
this.plugin.settings.archiveFolder,
);
if (!archiveFolder) {
return null;
}
if (this.draftRule.useFolderRegex) {
try {
const folderRegex = new RegExp(this.draftRule.folderPath);
if (folderRegex.test(archiveFolder)) {
return "Rule folder path cannot match the archive folder path.";
}
} catch {
return "Folder path regex is invalid.";
}
return null;
}
const ruleFolderPath = this.normalizeRulePath(
this.draftRule.folderPath,
);
if (this.isArchiveFolderOverlap(ruleFolderPath, archiveFolder)) {
return "Rule folder path cannot match or overlap with the archive folder path.";
}
return null;
}
private showValidationError(message: string): void {
this.validationErrorEl.setText(message);
this.validationErrorEl.style.display = "block";
}
private clearValidationError(): void {
this.validationErrorEl.empty();
this.validationErrorEl.style.display = "none";
}
private normalizeRulePath(path: string): string {
const trimmedPath = (path ?? "").trim();
if (!trimmedPath) {
return "";
}
return normalizePath(trimmedPath).replace(/^\/+|\/+$/g, "");
}
private isArchiveFolderOverlap(
ruleFolderPath: string,
archiveFolderPath: string,
): boolean {
if (!ruleFolderPath) {
return true;
}
if (ruleFolderPath === archiveFolderPath) {
return true;
}
if (ruleFolderPath.startsWith(`${archiveFolderPath}/`)) {
return true;
}
return archiveFolderPath.startsWith(`${ruleFolderPath}/`);
}
private cloneRule(rule: AutoArchiveRule): AutoArchiveRule {
return {
...rule,
conditions: rule.conditions.map((condition) => ({
...condition,
})),
};
}
private applyDraftToRule(): void {
this.rule.enabled = this.draftRule.enabled;
this.rule.folderPath = this.draftRule.folderPath;
this.rule.useFolderRegex = this.draftRule.useFolderRegex;
this.rule.applyRecursively = this.draftRule.applyRecursively;
this.rule.logicOperator = this.draftRule.logicOperator;
this.rule.conditions = this.draftRule.conditions.map((condition) => ({
...condition,
}));
}
private async handleCancel(): Promise<void> {
if (this.onCancel) {
await this.onCancel();
return;
}
if (!this.rule.folderPath) {
this.plugin.settings.autoArchiveRules =
this.plugin.settings.autoArchiveRules.filter(
(r) => r.id !== this.rule.id,
);
await this.plugin.saveSettings();
}
}
onClose() {
const { contentEl } = this;
if (this.closeReason === "none") {
void this.handleCancel();
}
contentEl.empty();
}
}

View file

@ -6,3 +6,85 @@ available in the app when your plugin is enabled.
If your plugin does not need CSS, delete this file.
*/
/* Auto-archive settings styles */
.setting-tab-container {
width: 100%;
}
.setting-tab-content {
padding-top: 12px;
}
.setting-tab-buttons {
display: flex;
gap: 8px;
margin-bottom: 20px;
border-bottom: 1px solid var(--background-modifier-border);
}
.setting-tab-button {
padding: 8px 16px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 14px;
color: var(--text-muted);
}
.setting-tab-button.active {
color: var(--text-normal);
border-bottom-color: var(--interactive-accent);
}
.setting-tab-button:hover {
color: var(--text-normal);
}
.auto-archive-rules-container {
margin-top: 20px;
}
.auto-archive-rule {
padding: 16px;
margin-bottom: 16px;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
background-color: var(--background-secondary);
}
.auto-archive-rule-header {
border-bottom: 1px solid var(--background-modifier-border);
padding-bottom: 8px;
margin-bottom: 8px;
}
.auto-archive-rule-conditions {
padding-left: 16px;
font-size: 0.9em;
color: var(--text-muted);
}
.auto-archive-rule-logic {
margin-top: 4px;
margin-bottom: 8px;
font-weight: 600;
color: var(--text-accent);
}
.auto-archive-rule-condition {
margin-top: 4px;
}
.auto-archive-conditions-container {
margin: 16px 0;
}
.auto-archive-condition {
margin-bottom: 12px;
padding: 12px;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
background-color: var(--background-primary);
}