mirror of
https://github.com/mfarr/obsidian-archive.git
synced 2026-07-22 05:41:33 +00:00
Add auto-archive feature (#21)
This commit is contained in:
parent
92085da3a7
commit
1791d59567
11 changed files with 1922 additions and 124 deletions
30
README.md
30
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
|
||||
|
|
|
|||
316
SettingsTab.ts
Normal file
316
SettingsTab.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
22
autoarchive/AutoArchiveContextMenu.ts
Normal file
22
autoarchive/AutoArchiveContextMenu.ts
Normal 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);
|
||||
}
|
||||
61
autoarchive/AutoArchiveLifecycle.ts
Normal file
61
autoarchive/AutoArchiveLifecycle.ts
Normal 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();
|
||||
}
|
||||
618
autoarchive/AutoArchiveService.ts
Normal file
618
autoarchive/AutoArchiveService.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
96
autoarchive/AutoArchiveSettings.ts
Normal file
96
autoarchive/AutoArchiveSettings.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
42
autoarchive/AutoArchiveTypes.ts
Normal file
42
autoarchive/AutoArchiveTypes.ts
Normal 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
31
autoarchive/index.ts
Normal 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
343
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<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
405
modals.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
82
styles.css
82
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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue