From 1791d59567a36358465c7c9edfb291d89a00e8b6 Mon Sep 17 00:00:00 2001 From: John Alberts Date: Mon, 6 Apr 2026 06:52:34 -0700 Subject: [PATCH] Add auto-archive feature (#21) --- README.md | 30 ++ SettingsTab.ts | 316 +++++++++++++ autoarchive/AutoArchiveContextMenu.ts | 22 + autoarchive/AutoArchiveLifecycle.ts | 61 +++ autoarchive/AutoArchiveService.ts | 618 ++++++++++++++++++++++++++ autoarchive/AutoArchiveSettings.ts | 96 ++++ autoarchive/AutoArchiveTypes.ts | 42 ++ autoarchive/index.ts | 31 ++ main.ts | 343 ++++++++------ modals.ts | 405 +++++++++++++++++ styles.css | 82 ++++ 11 files changed, 1922 insertions(+), 124 deletions(-) create mode 100644 SettingsTab.ts create mode 100644 autoarchive/AutoArchiveContextMenu.ts create mode 100644 autoarchive/AutoArchiveLifecycle.ts create mode 100644 autoarchive/AutoArchiveService.ts create mode 100644 autoarchive/AutoArchiveSettings.ts create mode 100644 autoarchive/AutoArchiveTypes.ts create mode 100644 autoarchive/index.ts create mode 100644 modals.ts diff --git a/README.md b/README.md index 70ab4b2..922c21b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SettingsTab.ts b/SettingsTab.ts new file mode 100644 index 0000000..addd862 --- /dev/null +++ b/SettingsTab.ts @@ -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(); + } +} diff --git a/autoarchive/AutoArchiveContextMenu.ts b/autoarchive/AutoArchiveContextMenu.ts new file mode 100644 index 0000000..a101df6 --- /dev/null +++ b/autoarchive/AutoArchiveContextMenu.ts @@ -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); +} diff --git a/autoarchive/AutoArchiveLifecycle.ts b/autoarchive/AutoArchiveLifecycle.ts new file mode 100644 index 0000000..9bd7139 --- /dev/null +++ b/autoarchive/AutoArchiveLifecycle.ts @@ -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, + isFileArchived: (file: TAbstractFile) => boolean, + persistLastRunAt: (lastRunAt: number) => Promise, +): 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(); +} diff --git a/autoarchive/AutoArchiveService.ts b/autoarchive/AutoArchiveService.ts new file mode 100644 index 0000000..2926bec --- /dev/null +++ b/autoarchive/AutoArchiveService.ts @@ -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; + private isFileArchived: (file: TAbstractFile) => boolean; + private persistLastAutoArchiveRunAt: (lastRunAt: number) => Promise; + 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, + isFileArchived: (file: TAbstractFile) => boolean, + persistLastAutoArchiveRunAt: (lastRunAt: number) => Promise, + ) { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + ); + } + } +} diff --git a/autoarchive/AutoArchiveSettings.ts b/autoarchive/AutoArchiveSettings.ts new file mode 100644 index 0000000..deeb79a --- /dev/null +++ b/autoarchive/AutoArchiveSettings.ts @@ -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 = { + 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 = {}; + + 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 | null | undefined, +): AutoArchiveRuntimeData { + const lastAutoArchiveRunAt = runtimeData?.lastAutoArchiveRunAt; + + if ( + !Number.isFinite(lastAutoArchiveRunAt) || + (lastAutoArchiveRunAt ?? 0) < 0 + ) { + return { ...AUTO_ARCHIVE_DEFAULT_RUNTIME_DATA }; + } + + return { + lastAutoArchiveRunAt: lastAutoArchiveRunAt ?? 0, + }; +} diff --git a/autoarchive/AutoArchiveTypes.ts b/autoarchive/AutoArchiveTypes.ts new file mode 100644 index 0000000..06cbad2 --- /dev/null +++ b/autoarchive/AutoArchiveTypes.ts @@ -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[]; + }; +} diff --git a/autoarchive/index.ts b/autoarchive/index.ts new file mode 100644 index 0000000..d471d53 --- /dev/null +++ b/autoarchive/index.ts @@ -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"; diff --git a/main.ts b/main.ts index 5ae15a0..359549f 100644 --- a/main.ts +++ b/main.ts @@ -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 { 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 { 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 { - 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 & { + 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, - cancelCallback: () => Promise - ) { - 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 { + 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 { + 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; + 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; } } diff --git a/modals.ts b/modals.ts new file mode 100644 index 0000000..ece0a6b --- /dev/null +++ b/modals.ts @@ -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, + cancelCallback: () => Promise, + ) { + 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; + onCancel?: () => Promise; + folderPathInput: HTMLInputElement; + validationErrorEl: HTMLDivElement; + closeReason: "none" | "saved" | "cancelled" = "none"; + + constructor( + app: App, + plugin: SimpleArchiver, + rule: AutoArchiveRule, + onSave: () => Promise, + onCancel?: () => Promise, + ) { + 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 { + 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(); + } +} diff --git a/styles.css b/styles.css index 71cc60f..799167a 100644 --- a/styles.css +++ b/styles.css @@ -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); +}