Compare commits

...

19 commits
0.3.0 ... main

Author SHA1 Message Date
Mike Farr
a7428dcbea
Version 0.6.2 2026-05-17 06:59:23 -04:00
Mike Farr
bd5494721e
Address issues surfaced by Obsidian plugin review (PR #27)
Address issues raised by plugin review at https://community.obsidian.md/plugins/simple-archiver.

Resolves #26
2026-05-17 06:34:29 -04:00
Mike Farr
36b85192b5
Add Claude Code GitHub Workflow (#25)
* "Update Claude PR Assistant workflow"

* "Update Claude Code Review workflow"
2026-04-11 11:41:58 -04:00
Mike Farr
f0a66bf194
Version 0.6.1 2026-04-09 07:37:31 -04:00
Mike Farr
22e54c1381
Version 0.6.0 2026-04-06 10:00:47 -04:00
John Alberts
1791d59567
Add auto-archive feature (#21) 2026-04-06 09:52:34 -04:00
Mike Farr
92085da3a7
Add claude GitHub actions 1774542556683 (#22)
* "Claude PR Assistant workflow"

* "Claude Code Review workflow"
2026-03-26 12:35:20 -04:00
Mike Farr
7fd371c2e3
Version 0.5.2 2026-02-28 08:25:37 -05:00
Gareth Evans
aac53c74a3
Fix leading zero preservation in unarchive operation (#16)
Resolves issue where unarchiving files from folders with leading zeros
(e.g., 00-inbox) incorrectly stripped the leading zero, causing files
to be restored to wrong locations (e.g., 0-inbox).

Root cause: Unarchive operation was not normalizing paths before passing
them to Obsidian's file management APIs, unlike the archive operation
which consistently normalized all paths.

Changes:
- Added normalizePath() to folder creation in moveFileOutOfArchive()
- Added normalizePath() to file rename in moveFileOutOfArchive()

This ensures consistent path handling between archive and unarchive
operations, preventing leading zeros from being stripped.

Fixes #15

Tested scenarios:
- Single leading zero (00-inbox, 01-projects)
- Multiple leading zeros (000-archive)
- Single zero folders (0-temp)
- Deeply nested numeric folders
- Regular folders (regression test)
2025-10-21 07:30:40 -04:00
Mike Farr
e0af8a11a9
Bump version to v0.5.1 2025-07-01 10:00:38 -04:00
Mike Farr
2d0e15754d
Version 0.5.0 2025-06-29 06:47:17 -04:00
Nicholas Lau
1f3fa3a026
Feature: Implement unarchive functionality for files (#13) 2025-06-29 06:27:41 -04:00
Mike Farr
90dd0c9ce8
Update README 2025-04-18 08:26:45 -04:00
Mike Farr
a51cca64ec
Update README 2025-04-18 08:25:03 -04:00
Mike Farr
293f83b41a
Version 0.4.0 2025-04-18 08:09:48 -04:00
Mike Farr
76d3d74748
Provide option to overwrite existing archived files when archiving (#12)
Resolves #4
2025-04-18 08:07:10 -04:00
Mike Farr
beeeaaa7d7
Version 0.3.1 2025-04-04 09:07:27 -06:00
Mike Farr
6d0061ad49
Normalize destination file path (#10)
Fixes path issue when attempting to archive an item from the root of the vault by normalizing the destination file path.

Resolves #9
2025-04-04 09:05:39 -06:00
Mike Farr
d16c56bd1a
Update README 2025-04-04 07:11:38 -06:00
21 changed files with 2630 additions and 337 deletions

View file

@ -1,10 +1,15 @@
# top-most EditorConfig file
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 4
tab_width = 4
# top-most EditorConfig file
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 4
tab_width = 4
[*.{yml,yaml}]
indent_style = space
indent_size = 2
tab_width = 2

View file

@ -0,0 +1,43 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: "https://github.com/anthropics/claude-code.git"
plugins: "code-review@claude-code-plugins"
prompt: "/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}"
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options

50
.github/workflows/claude.yml vendored Normal file
View file

@ -0,0 +1,50 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'

View file

@ -1,35 +1,48 @@
name: Release Obsidian plugin
on:
push:
tags:
- "*"
push:
tags:
- "*"
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v3
build:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
attestations: write
artifact-metadata: write
steps:
- uses: actions/checkout@v6
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: "18.x"
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
cache: "npm"
- name: Build plugin
run: |
npm install
npm run build
- name: Build plugin
run: |
npm install
npm run build
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${GITHUB_REF#refs/tags/}"
- name: Attest
uses: actions/attest@v4
with:
subject-path: |
main.js
manifest.json
styles.css
gh release create "$tag" \
--title="$tag" \
--draft \
main.js manifest.json styles.css
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${GITHUB_REF#refs/tags/}"
gh release create "$tag" \
--title="$tag" \
--draft \
main.js manifest.json styles.css

47
.gitignore vendored
View file

@ -1,22 +1,25 @@
# vscode
.vscode
# Intellij
*.iml
.idea
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
main.js
# Exclude sourcemaps
*.map
# obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
# vscode
.vscode
# Intellij
*.iml
.idea
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
main.js
# Exclude sourcemaps
*.map
# obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
# Exclude pnpm lock file, using package-lock.json as ground-truth
pnpm-lock.yaml

57
CLAUDE.md Normal file
View file

@ -0,0 +1,57 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
npm run dev # Development build with watch mode (esbuild)
npm run build # Type-check (tsc --noEmit) + production bundle
npm run version # Bump version, updates manifest.json and versions.json
```
No test runner is configured. Linting is via ESLint (`npx eslint .`).
## Architecture
This is an **Obsidian plugin** ("Simple Archiver") that moves notes and folders to a configurable archive folder, with an optional auto-archive feature that runs rules on a schedule.
### Core Modules
**`main.ts`** — Plugin entry point (`SimpleArchiverPlugin extends Plugin`). Owns:
- Settings load/save via Obsidian's `loadData()`/`saveData()`
- Command registration (`move-to-archive`, `move-out-of-archive`)
- Context menu items for files and folders
- Core `archiveFile()` / `unarchiveFile()` logic (conflict resolution, path normalization)
**`autoarchive/`** — Auto-archive subsystem:
- `AutoArchiveService.ts` — Rule evaluation engine and scheduler. Uses `setInterval()` for periodic runs, `setTimeout()` for startup delay. Evaluates conditions (file age, filename regex) with AND/OR logic. Rate-limited to 500 files/cycle. Regex patterns validated with 100ms timeout to prevent ReDoS.
- `AutoArchiveTypes.ts` — Type definitions (`AutoArchiveRule`, `AutoArchiveCondition`, `SimpleArchiverSettings`, `ArchiveResult`)
- `AutoArchiveSettings.ts` — Default settings and backward-compatibility migrations
- `AutoArchiveLifecycle.ts` — Factory functions for creating/controlling the service
- `index.ts` — Barrel export
**`SettingsTab.ts`** — Tabbed settings UI (General tab: archive folder config; Auto-Archive tab: schedule and rule management).
**`modals.ts`** — `SimpleArchiverPromptModal` (yes/no confirmations) and `AutoArchiveRuleModal` (rule editor with validation).
### Data Flow
```
User command/menu → archiveFile() → conflict check → Obsidian vault.rename() → saveSettings()
Scheduler → AutoArchiveService.processAutoArchiveRules()
→ enabled rules → matching folders → evaluate conditions → archiveFile()
```
### Build Output
esbuild bundles `main.ts``main.js`. Obsidian, Electron, and all `@codemirror/*` / `@lezer/*` packages are marked external (provided by Obsidian at runtime).
### Plugin Manifest
`manifest.json` and `versions.json` track plugin versioning for the Obsidian plugin marketplace. The release workflow (`.github/workflows/release.yml`) auto-creates GitHub releases when version tags are pushed.
## Community Plugin Listing
https://community.obsidian.md/plugins/simple-archiver

100
README.md
View file

@ -2,20 +2,102 @@
> _Move old, stinky notes and folders to an archive, where they belong!_
Simple Archiver moves files or an entire folder to an archive folder that you specify in the plugin's settings. The items are moved to the same relative path in the archive. If the base archive folder doesn't exist when you send something to the archive, it will be created automatically.
Simple Archiver moves files or an entire folder to an archive folder that you configure. The items are moved to the same relative path in the archive folder. Conversely, items that have been archived can be moved out of the archive to their original location.
Archiving can be done via:
- `Simple Archive: Move to archive` command
- `Move to archive` file menu item
- `Move all to archive` multi-file menu item
- `Simple Archive: Move to archive` command
- `Move to archive` file menu item
- `Move all to archive` multi-file menu item
## Known Issues & Limitations
Unarchiving can be done via:
- Archiving a file or folder that already exists at the same path in the archive will fail
- Archive folder setting value is not checked for validity
- `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 archive merges the contents
- Unarchive
- Archiving a folder that already exists in the archive merges the contents
- Archiving a file that already exists in the archive gives the option to rename
## Contributing
Contributions to this repo are welcome, **but**, please reach out by submitting an issue before submitting a massive PR for a feature that I might not be interested in maintaining.
### AI Contributions
AI-assisted contributions will be considered with discretion. Obviously vibe-coded contributions will receive little of my time and less of my patience.
### Contributors
- [nicholaslck](https://github.com/nicholaslck)
- [ggfevans](https://github.com/ggfevans)
- [albertsj1](https://github.com/albertsj1)
## Release Notes
### v0.6.2
- **Fix**: Address issues identified on Obsidian community plugin
### v0.6.1
- **New**: Add auto-archiving functionality. Thanks to [albertsj1](https://github.com/albertsj1)!
### v0.5.2
- **Fix**: Resolve issue when archiving files from folders with leading 0s. Thanks [ggfevans](https://github.com/ggfevans)!
### v0.5.1
- **New**: Add `Move out of archive` functionality to files/folders that exist in the archive. Items will be moved out of the archive into their original location (issue #5). Thank you to [nicholaslck](https://github.com/nicholaslck)!
### v0.4.0
- **New**: Add "replace" option when attempting to archive a file or folder when an item with the same name and path already exists in the archive.
### v0.3.1
- **Fix**: Unable to archive files/folders in the vault root
### v0.3.1
- **New**: Validate archive folder name setting before saving
### v0.2.0
- **New**: Allow multiple files to be archived
### v0.1.0
- **New**: Basic archive functionality

316
SettingsTab.ts Normal file
View file

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

View file

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

View file

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

View file

@ -0,0 +1,611 @@
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;
}
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 {
await this.archiveFile(file);
} catch (error) {
console.error(
`Auto-archive failed for file: ${file.path}`,
error,
);
}
}
}
} catch (error) {
console.error("Auto-archive cycle failed:", error);
} finally {
await this.persistLastRunTimestamp(Date.now());
this.isProcessingAutoArchiveRules = false;
}
}
private getFilesFromFolder(folder: TFolder, recursive: boolean): TFile[] {
const files: TFile[] = [];
if (!folder || !folder.children) {
return files;
}
for (const child of folder.children) {
if (child instanceof TFile) {
files.push(child);
} else if (recursive && child instanceof TFolder) {
files.push(...this.getFilesFromFolder(child, recursive));
}
}
return files;
}
private async evaluateAutoArchiveRule(
file: TAbstractFile,
rule: AutoArchiveRule,
): Promise<boolean> {
if (this.isFileArchived(file)) {
return false;
}
if (rule.conditions.length === 0) {
return false;
}
const operator = rule.logicOperator || "AND";
if (operator === "OR") {
for (const condition of rule.conditions) {
if (await this.evaluateCondition(file, condition)) {
return true;
}
}
return false;
} else {
for (const condition of rule.conditions) {
if (!(await this.evaluateCondition(file, condition))) {
return false;
}
}
return true;
}
}
private async evaluateCondition(
file: TAbstractFile,
condition: AutoArchiveCondition,
): Promise<boolean> {
if (condition.type === "fileAge") {
const ageInDays = parseInt(condition.value);
if (isNaN(ageInDays)) {
return false;
}
const stats = await this.app.vault.adapter.stat(file.path);
if (!stats) {
return false;
}
const fileAgeMs = Date.now() - stats.mtime;
const fileAgeDays = fileAgeMs / (1000 * 60 * 60 * 24);
return fileAgeDays >= ageInDays;
} else if (condition.type === "regexPattern") {
const regex = this.validateRegexPattern(condition.value);
if (!regex) {
console.error(
`Invalid regex pattern in auto-archive rule: ${condition.value}`,
);
return false;
}
return regex.test(file.name);
}
return false;
}
getRulesForFolder(folderPath: string): AutoArchiveRule[] {
const settings = this.getSettings();
return settings.autoArchiveRules.filter((rule) => {
if (rule.useFolderRegex) {
const regex = this.validateRegexPattern(rule.folderPath);
if (!regex) {
return false;
}
return regex.test(folderPath);
}
return rule.folderPath === folderPath;
});
}
/**
* Generates human-readable text for a condition (shared utility).
*/
getConditionText(condition: AutoArchiveCondition): string {
if (condition.type === "fileAge") {
return `File age ≥ ${condition.value} days`;
} else if (condition.type === "regexPattern") {
return `File name matches: ${condition.value}`;
}
return "Unknown condition";
}
getRuleDisplayText(rule: AutoArchiveRule): string {
const icon = rule.enabled ? "✓" : "○";
if (rule.conditions.length === 0) {
return `${icon} (no conditions)`;
}
if (rule.conditions.length === 1) {
const conditionText = this.getConditionText(rule.conditions[0]);
return `${icon} ${conditionText}`;
}
// Multiple conditions: join with logic operator
const operator = rule.logicOperator === "OR" ? " OR " : " AND ";
const conditionsText = rule.conditions
.map((c) => this.getConditionText(c))
.join(operator);
// Truncate if too long
const fullText = `${icon} ${conditionsText}`;
const MAX_LENGTH = 60;
if (fullText.length > MAX_LENGTH) {
const firstCondition = this.getConditionText(rule.conditions[0]);
return `${icon} ${firstCondition} ${operator}... (${rule.conditions.length} total)`;
}
return fullText;
}
setupContextMenu(workspace: Workspace, plugin: SimpleArchiver): void {
plugin.registerEvent(
workspace.on("file-menu", (menu, file) => {
// Only show for folders
if (!(file instanceof TFolder)) {
return;
}
menu.addItem((item) => {
item.setTitle("Auto-archive").setIcon("clock");
// Add submenu with proper type safety
const submenu = this.getSubmenu(item);
if (!submenu) {
console.error("Failed to create submenu");
return;
}
// Add "Add rule" item
submenu.addItem((subitem: MenuEntry) => {
subitem
.setTitle("Add rule")
.setIcon("plus")
.onClick(() => {
this.openAutoArchiveRuleForFolder(
plugin,
file.path,
);
});
});
// Check if there are existing rules for this folder
const existingRules = this.getRulesForFolder(file.path);
if (existingRules.length > 0) {
// Add "Edit Rule" submenu item
submenu.addItem((subitem: MenuEntry) => {
subitem.setTitle("Edit Rule").setIcon("pencil");
const editSubmenu = this.getSubmenu(subitem);
if (!editSubmenu) return;
// Add each rule as a submenu item
for (const rule of existingRules) {
editSubmenu.addItem((ruleItem: MenuEntry) => {
const displayText =
this.getRuleDisplayText(rule);
const icon = rule.enabled
? "check"
: "circle";
ruleItem
.setTitle(displayText)
.setIcon(icon)
.onClick(() => {
new AutoArchiveRuleModal(
this.app,
plugin,
rule,
async () => {
await plugin.saveSettings();
},
).open();
});
});
}
});
}
});
}),
);
}
/**
* Safely retrieves submenu from menu item with type safety.
*/
private getSubmenu(item: MenuEntry): MenuGroup | null {
try {
return typeof item.setSubmenu === "function"
? item.setSubmenu()
: null;
} catch (error) {
console.error("Failed to create submenu:", error);
return null;
}
}
private openAutoArchiveRuleForFolder(
plugin: SimpleArchiver,
folderPath: string,
): void {
try {
const obsidianApis = this.app as unknown as ObsidianInternalApis;
if (!obsidianApis.setting) {
console.error("Unable to access settings API");
return;
}
obsidianApis.setting.open();
const pluginId = plugin.manifest.id;
obsidianApis.setting.openTabById(pluginId);
window.setTimeout(() => {
const tabs = obsidianApis.setting
.pluginTabs as PluginSettingTab[];
for (const tab of tabs) {
if (tab instanceof SimpleArchiverSettingsTab) {
tab.activeTab = "autoArchive";
tab.display();
const newRule: AutoArchiveRule = {
id: crypto.randomUUID(),
enabled: true,
folderPath,
useFolderRegex: false,
applyRecursively: false,
conditions: [],
logicOperator: "AND",
};
plugin.settings.autoArchiveRules.push(newRule);
try {
new AutoArchiveRuleModal(
this.app,
plugin,
newRule,
async () => {
await plugin.saveSettings();
tab.display();
},
async () => {
plugin.settings.autoArchiveRules =
plugin.settings.autoArchiveRules.filter(
(r) => r.id !== newRule.id,
);
await plugin.saveSettings();
tab.display();
},
).open();
} catch (error) {
plugin.settings.autoArchiveRules =
plugin.settings.autoArchiveRules.filter(
(r) => r.id !== newRule.id,
);
console.error(
"Failed to open auto-archive rule modal:",
error,
);
}
break;
}
}
}, SETTINGS_TAB_RENDER_DELAY_MS);
} catch (error) {
console.error(
"Failed to open auto-archive rule for folder:",
error,
);
}
}
}

View file

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

View file

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

31
autoarchive/index.ts Normal file
View file

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

View file

@ -1,6 +1,6 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import { builtinModules } from "module";
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
@ -30,7 +30,7 @@ const context = await esbuild.context({
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins,
...builtinModules,
],
format: "cjs",
target: "es2018",

529
main.ts
View file

@ -1,25 +1,40 @@
import {
App,
Editor,
MarkdownView,
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";
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();
@ -29,16 +44,24 @@ export default class SimpleArchiver extends Plugin {
name: "Move to archive",
editorCheckCallback: (
checking: boolean,
editor: Editor,
view: MarkdownView
_editor: Editor,
view: MarkdownView,
) => {
const canBeArchived = !view.file?.path.startsWith(
this.settings.archiveFolder
);
const canBeArchived =
view.file && !this.isFileArchived(view.file);
if (canBeArchived && view.file != null) {
if (!checking) {
this.moveToArchive(view.file);
void this.archiveFile(view.file)
.then((result) => {
new Notice(result.message);
})
.catch((e: unknown) => {
const msg =
e instanceof Error ? e.message : String(e);
new Notice(`Error: ${msg}`);
console.error(msg);
});
}
return true;
@ -48,11 +71,74 @@ export default class SimpleArchiver extends Plugin {
},
});
this.addSettingTab(new SimpleArchiverSettingsTab(this.app, this));
this.addCommand({
id: "move-out-of-archive",
name: "Move out of archive",
editorCheckCallback: (
checking: boolean,
_editor: Editor,
view: MarkdownView,
) => {
const canBeUnarchived =
view.file && this.isFileArchived(view.file);
if (canBeUnarchived && view.file != null) {
if (!checking) {
void this.unarchiveFile(view.file)
.then((result) => {
new Notice(result.message);
})
.catch((e: unknown) => {
const msg =
e instanceof Error ? e.message : String(e);
new Notice(`Error: ${msg}`);
console.error(msg);
});
}
return true;
}
return false;
},
});
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(
this.app.workspace.on("file-menu", (menu, file) => {
if (file.path.startsWith(this.settings.archiveFolder)) {
if (this.isFileArchived(file)) {
return;
}
@ -60,45 +146,158 @@ export default class SimpleArchiver extends Plugin {
item.setTitle("Move to archive")
.setIcon("archive")
.onClick(async () => {
if (await this.moveToArchive(file)) {
new Notice(`${file.name} archived`);
const result = await this.archiveFile(file);
if (result.success) {
new Notice(result.message);
} else {
new Notice(`Error: ${result.message}`);
console.error(result.message);
}
});
});
})
}),
);
this.registerEvent(
this.app.workspace.on("files-menu", (menu, files) => {
if (files.some((file) => this.isFileArchived(file))) {
return;
}
menu.addItem((item) => {
item.setTitle("Move all to archive")
.setIcon("archive")
.onClick(async () => {
await this.moveAllToArchive(files);
await this.archiveAllFiles(files);
});
});
})
}),
);
// Unarchive file context menu
this.registerEvent(
this.app.workspace.on("file-menu", (menu, file) => {
if (!this.isFileArchived(file)) {
return;
}
menu.addItem((item) => {
item.setTitle("Move out of archive")
.setIcon("archive-restore")
.onClick(async () => {
const result = await this.unarchiveFile(file);
if (result.success) {
new Notice(result.message);
} else {
new Notice(`Error: ${result.message}`);
console.error(result.message);
}
});
});
}),
);
this.registerEvent(
this.app.workspace.on("files-menu", (menu, files) => {
if (files.some((file) => !this.isFileArchived(file))) {
return;
}
menu.addItem((item) => {
item.setTitle("Move all out of archive")
.setIcon("archive-restore")
.onClick(async () => {
await this.unarchiveAllFiles(files);
});
});
}),
);
}
private async moveToArchive(file: TAbstractFile): Promise<boolean> {
if (file.path.startsWith(this.settings.archiveFolder)) {
return false;
private isFileArchived(file: TAbstractFile): boolean {
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;
}
const existingItem = this.app.vault.getAbstractFileByPath(
`${this.settings.archiveFolder}/${file.path}`
// 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}`,
);
if (existingItem != null) {
new Notice(
`Unable to archive ${file.name}, item already exists in archive`
);
const existingItem =
this.app.vault.getAbstractFileByPath(destinationFilePath);
return false;
if (existingItem != null) {
// Same item exists in archive, prompt to replace
return new Promise<ArchiveResult>((resolve) => {
const prompt = new SimpleArchiverPromptModal(
this.app,
"Replace archived item?",
`An item called "${file.name}" already exists in the destination folder in the archive. Would you like to replace it?`,
"Replace",
"Cancel",
async () => {
await this.app.fileManager.trashFile(existingItem);
const response = await this.moveFileToArchive(file);
resolve(response);
},
async () => {
resolve({
success: false,
message: "Archive operation cancelled",
});
},
);
prompt.open();
});
}
const destinationPath = `${this.settings.archiveFolder}/${file.parent?.path}`;
// If no existing item, proceed with archiving
const response = await this.moveFileToArchive(file);
return response;
}
private async archiveAllFiles(files: TAbstractFile[]) {
let archived = 0;
for (const file of files) {
if ((await this.archiveFile(file)).success) {
archived++;
}
}
new Notice(`${archived} files archived`);
}
private async moveFileToArchive(
file: TAbstractFile,
): Promise<ArchiveResult> {
const destinationPath = normalizePath(
`${this.settings.archiveFolder}/${file.parent?.path}`,
);
const destinationFolder =
this.app.vault.getFolderByPath(destinationPath);
@ -107,83 +306,239 @@ export default class SimpleArchiver extends Plugin {
await this.app.vault.createFolder(destinationPath);
}
await this.app.fileManager.renameFile(
file,
`${this.settings.archiveFolder}/${file.path}`
const destinationFilePath = normalizePath(
`${destinationPath}/${file.name}`,
);
return true;
try {
await this.app.fileManager.renameFile(file, destinationFilePath);
return {
success: true,
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}: ${errorMessage}`,
};
}
}
private async moveAllToArchive(files: TAbstractFile[]) {
let archived = 0;
private async moveFileOutOfArchive(
file: TAbstractFile,
): Promise<ArchiveResult> {
const originalPath = this.resolveOriginalPath(file.path);
for (const file of files) {
if (await this.moveToArchive(file)) {
archived++;
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) {
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}`,
};
}
}
}
new Notice(`${archived} files archived`);
try {
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}: ${errorMessage}`,
};
}
}
private async unarchiveFile(file: TAbstractFile): Promise<ArchiveResult> {
if (!this.isFileArchived(file)) {
return { success: false, message: "Item is not archived" };
}
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);
if (existingItem != null) {
return new Promise<ArchiveResult>((resolve) => {
const prompt = new SimpleArchiverPromptModal(
this.app,
"Replace existing item?",
`An item called "${file.name}" already exists in the original location. Would you like to replace it?`,
"Replace",
"Cancel",
async () => {
await this.app.fileManager.trashFile(existingItem);
const response = await this.moveFileOutOfArchive(file);
resolve(response);
},
async () => {
resolve({
success: false,
message: "Unarchive operation cancelled",
});
},
);
prompt.open();
});
}
const response = await this.moveFileOutOfArchive(file);
return response;
}
private async unarchiveAllFiles(files: TAbstractFile[]) {
let unarchived = 0;
for (const file of files) {
if ((await this.unarchiveFile(file)).success) {
unarchived++;
}
}
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 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;
}
}

View file

@ -1,7 +1,7 @@
{
"id": "simple-archiver",
"name": "Simple Archiver",
"version": "0.3.0",
"version": "0.6.2",
"minAppVersion": "1.8.0",
"description": "Move old, stinky notes and folders to an archive, where they belong.",
"author": "Mike Farr",

401
modals.ts Normal file
View file

@ -0,0 +1,401 @@
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(() => {
void callback();
this.close();
}),
)
.addButton((btn) =>
btn.setButtonText(noButtonText).onClick(() => {
void 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",
});
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.addClass("is-visible");
}
private clearValidationError(): void {
this.validationErrorEl.empty();
this.validationErrorEl.removeClass("is-visible");
}
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();
}
}

369
package-lock.json generated
View file

@ -1,18 +1,17 @@
{
"name": "obsidian-simple-archiver",
"version": "0.0.1",
"name": "simple-archiver",
"version": "0.6.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-simple-archiver",
"version": "0.0.1",
"name": "simple-archiver",
"version": "0.6.2",
"license": "GPL-3.0-only",
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "^0.25.0",
"obsidian": "latest",
"tslib": "2.4.0",
@ -20,9 +19,9 @@
}
},
"node_modules/@codemirror/state": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz",
"integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==",
"dev": true,
"license": "MIT",
"peer": true,
@ -31,22 +30,23 @@
}
},
"node_modules/@codemirror/view": {
"version": "6.36.4",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.36.4.tgz",
"integrity": "sha512-ZQ0V5ovw/miKEXTvjgzRyjnrk9TwriUB1k4R5p7uNnHR9Hus+D1SXHGdJshijEzPFjU25xea/7nhIeSqYFKdbA==",
"version": "6.38.6",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz",
"integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz",
"integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
@ -61,9 +61,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz",
"integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
@ -78,9 +78,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz",
"integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
@ -95,9 +95,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz",
"integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
@ -112,9 +112,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz",
"integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
@ -129,9 +129,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz",
"integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
@ -146,9 +146,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz",
"integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
@ -163,9 +163,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz",
"integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
@ -180,9 +180,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz",
"integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
@ -197,9 +197,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz",
"integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
@ -214,9 +214,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz",
"integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
@ -231,9 +231,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz",
"integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
@ -248,9 +248,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz",
"integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
@ -265,9 +265,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz",
"integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
@ -282,9 +282,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz",
"integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
@ -299,9 +299,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz",
"integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
@ -316,9 +316,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz",
"integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
@ -333,9 +333,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz",
"integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
@ -350,9 +350,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz",
"integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
@ -367,9 +367,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz",
"integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
@ -384,9 +384,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz",
"integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
@ -400,10 +400,27 @@
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz",
"integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
@ -418,9 +435,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz",
"integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
@ -435,9 +452,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz",
"integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
@ -452,9 +469,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz",
"integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
@ -469,9 +486,9 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz",
"integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==",
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"dev": true,
"license": "MIT",
"peer": true,
@ -489,9 +506,9 @@
}
},
"node_modules/@eslint-community/regexpp": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
"integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"dev": true,
"license": "MIT",
"peer": true,
@ -633,9 +650,9 @@
}
},
"node_modules/@types/estree": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
@ -864,9 +881,9 @@
"peer": true
},
"node_modules/acorn": {
"version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
@ -889,9 +906,9 @@
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"peer": true,
@ -961,9 +978,9 @@
"peer": true
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"peer": true,
@ -985,19 +1002,6 @@
"node": ">=8"
}
},
"node_modules/builtin-modules": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz",
"integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@ -1057,6 +1061,14 @@
"license": "MIT",
"peer": true
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -1074,9 +1086,9 @@
}
},
"node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1127,9 +1139,9 @@
}
},
"node_modules/esbuild": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz",
"integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@ -1140,31 +1152,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.0",
"@esbuild/android-arm": "0.25.0",
"@esbuild/android-arm64": "0.25.0",
"@esbuild/android-x64": "0.25.0",
"@esbuild/darwin-arm64": "0.25.0",
"@esbuild/darwin-x64": "0.25.0",
"@esbuild/freebsd-arm64": "0.25.0",
"@esbuild/freebsd-x64": "0.25.0",
"@esbuild/linux-arm": "0.25.0",
"@esbuild/linux-arm64": "0.25.0",
"@esbuild/linux-ia32": "0.25.0",
"@esbuild/linux-loong64": "0.25.0",
"@esbuild/linux-mips64el": "0.25.0",
"@esbuild/linux-ppc64": "0.25.0",
"@esbuild/linux-riscv64": "0.25.0",
"@esbuild/linux-s390x": "0.25.0",
"@esbuild/linux-x64": "0.25.0",
"@esbuild/netbsd-arm64": "0.25.0",
"@esbuild/netbsd-x64": "0.25.0",
"@esbuild/openbsd-arm64": "0.25.0",
"@esbuild/openbsd-x64": "0.25.0",
"@esbuild/sunos-x64": "0.25.0",
"@esbuild/win32-arm64": "0.25.0",
"@esbuild/win32-ia32": "0.25.0",
"@esbuild/win32-x64": "0.25.0"
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/escape-string-regexp": {
@ -1344,9 +1357,9 @@
}
},
"node_modules/esquery": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
"integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
"dev": true,
"license": "BSD-3-Clause",
"peer": true,
@ -1467,9 +1480,9 @@
"peer": true
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
"integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
@ -1538,9 +1551,9 @@
}
},
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC",
"peer": true
@ -1564,7 +1577,7 @@
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"license": "ISC",
"peer": true,
@ -1767,9 +1780,9 @@
"peer": true
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"peer": true,
@ -1880,9 +1893,9 @@
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"peer": true,
@ -1919,9 +1932,9 @@
"peer": true
},
"node_modules/obsidian": {
"version": "1.8.7",
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.8.7.tgz",
"integrity": "sha512-h4bWwNFAGRXlMlMAzdEiIM2ppTGlrh7uGOJS6w4gClrsjc+ei/3YAtU2VdFUlCiPuTHpY4aBpFJJW75S1Tl/JA==",
"version": "1.12.3",
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz",
"integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1929,8 +1942,8 @@
"moment": "2.29.4"
},
"peerDependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
"@codemirror/state": "6.5.0",
"@codemirror/view": "6.38.6"
}
},
"node_modules/once": {
@ -2055,9 +2068,9 @@
}
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@ -2188,9 +2201,9 @@
}
},
"node_modules/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
@ -2264,9 +2277,9 @@
}
},
"node_modules/style-mod": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz",
"integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==",
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"dev": true,
"license": "MIT",
"peer": true

View file

@ -1,6 +1,6 @@
{
"name": "simple-archiver",
"version": "0.3.0",
"version": "0.6.2",
"description": "Send old, stinky notes to the archive, where they belong.",
"main": "main.js",
"scripts": {
@ -17,7 +17,6 @@
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "^0.25.0",
"obsidian": "latest",
"tslib": "2.4.0",

View file

@ -6,3 +6,96 @@ 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);
}
.auto-archive-rule-validation-error {
display: none;
color: var(--text-error);
font-weight: 600;
margin-bottom: 12px;
}
.auto-archive-rule-validation-error.is-visible {
display: block;
}