mirror of
https://github.com/pandanocturne/obsidian-password-manager.git
synced 2026-07-22 06:53:40 +00:00
初始化
This commit is contained in:
commit
bcf294b8c2
48 changed files with 13169 additions and 0 deletions
10
.editorconfig
Normal file
10
.editorconfig
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# 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
|
||||
28
.github/workflows/lint.yml
vendored
Normal file
28
.github/workflows/lint.yml
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
name: Node.js build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x, 22.x]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: npm run build --if-present
|
||||
- run: npm run lint
|
||||
|
||||
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# 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
|
||||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
tag-version-prefix=""
|
||||
5
.specstory/.project.json
Normal file
5
.specstory/.project.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"workspace_id": "d3fd-d121-1130-b9a5",
|
||||
"workspace_id_at": "2026-04-26T02:29:55Z",
|
||||
"project_name": "obsidian-sample-plugin-master"
|
||||
}
|
||||
79
.specstory/cli/config.toml
Normal file
79
.specstory/cli/config.toml
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# SpecStory CLI Configuration
|
||||
#
|
||||
# This is the project-level config file for SpecStory CLI.
|
||||
# All settings here apply to this project unless overridden by CLI flags.
|
||||
#
|
||||
# Uncomment (remove the #) the line and edit any setting below to change the default behavior.
|
||||
# For more information, see: https://docs.specstory.com/integrations/terminal-coding-agents/usage
|
||||
|
||||
[local_sync]
|
||||
# Write markdown files locally. (default: true)
|
||||
# enabled = false # equivalent to --only-cloud-sync
|
||||
|
||||
# Custom output directory for markdown files.
|
||||
# Default: ./.specstory/history (relative to the project directory)
|
||||
# output_dir = "~/.specstory/history" # equivalent to --output-dir "~/.specstory/history"
|
||||
|
||||
# Use local timezone for file name and content timestamps (default: false, UTC)
|
||||
# local_time_zone = true # equivalent to --local-time-zone
|
||||
|
||||
[cloud_sync]
|
||||
# Sync session data to SpecStory Cloud. (default: true, when logged in to SpecStory Cloud)
|
||||
# enabled = false # equivalent to --no-cloud-sync
|
||||
|
||||
[logging]
|
||||
# Write logs to .specstory/debug/debug.log (default: false)
|
||||
# log = true # equivalent to --log
|
||||
|
||||
# Debug-level output, requires console or log (default: false)
|
||||
# debug = true # equivalent to --debug
|
||||
|
||||
# Custom output directory for debug data.
|
||||
# Default: ./.specstory/debug (relative to the project directory)
|
||||
# debug_dir = "~/.specstory/debug" # equivalent to --debug-dir "~/.specstory/debug"
|
||||
|
||||
# Error/warn/info output to stdout (default: false)
|
||||
# console = true # equivalent to --console
|
||||
|
||||
# Suppress all non-error output (default: false)
|
||||
# silent = true # equivalent to --silent
|
||||
|
||||
[version_check]
|
||||
# Check for new versions of the CLI on startup.
|
||||
# Default: true
|
||||
# enabled = false # equivalent to --no-version-check
|
||||
|
||||
[analytics]
|
||||
# Send anonymous product usage analytics to help improve SpecStory.
|
||||
# Default: true
|
||||
# enabled = false # equivalent to --no-usage-analytics
|
||||
|
||||
[telemetry]
|
||||
# OTLP gRPC collector endpoint (e.g., "localhost:4317" or "http://localhost:4317")
|
||||
# endpoint = "localhost:4317"
|
||||
|
||||
# Override the default service name (default: "specstory-cli")
|
||||
# service_name = "my-service-name"
|
||||
|
||||
# Include user prompt text in telemetry spans (default: true)
|
||||
# prompts = false
|
||||
|
||||
[providers]
|
||||
# Agent execution commands by provider (used by specstory run)
|
||||
# Pass custom flags (e.g. claude_cmd = "claude --allow-dangerously-skip-permissions")
|
||||
# Use of these is equivalent to -c "custom command"
|
||||
|
||||
# Claude Code command
|
||||
# claude_cmd = "claude"
|
||||
|
||||
# Codex CLI command
|
||||
# codex_cmd = "codex"
|
||||
|
||||
# Cursor CLI command
|
||||
# cursor_cmd = "cursor-agent"
|
||||
|
||||
# Droid CLI command
|
||||
# droid_cmd = "droid"
|
||||
|
||||
# Gemini CLI command
|
||||
# gemini_cmd = "gemini"
|
||||
22
.specstory/statistics.json
Normal file
22
.specstory/statistics.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"sessions": {
|
||||
"1246e1fb-57c6-479d-a924-3eae01c6975e": {
|
||||
"user_message_count": 1,
|
||||
"agent_message_count": 17,
|
||||
"start_timestamp": "2026-04-26T10:39:19+08:00",
|
||||
"end_timestamp": "2026-04-26T10:39:19+08:00",
|
||||
"markdown_size_bytes": 8910,
|
||||
"provider": "cursoride",
|
||||
"last_updated": "2026-04-26T02:40:22Z"
|
||||
},
|
||||
"4f85f374-f2dc-46e4-8736-8fdaaef10b01": {
|
||||
"user_message_count": 1,
|
||||
"agent_message_count": 4,
|
||||
"start_timestamp": "2026-04-26T10:29:52+08:00",
|
||||
"end_timestamp": "2026-04-26T10:29:52+08:00",
|
||||
"markdown_size_bytes": 3210,
|
||||
"provider": "cursoride",
|
||||
"last_updated": "2026-04-26T02:36:50Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
251
AGENTS.md
Normal file
251
AGENTS.md
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
# Obsidian community plugin
|
||||
|
||||
## Project overview
|
||||
|
||||
- Target: Obsidian Community Plugin (TypeScript → bundled JavaScript).
|
||||
- Entry point: `main.ts` compiled to `main.js` and loaded by Obsidian.
|
||||
- Required release artifacts: `main.js`, `manifest.json`, and optional `styles.css`.
|
||||
|
||||
## Environment & tooling
|
||||
|
||||
- Node.js: use current LTS (Node 18+ recommended).
|
||||
- **Package manager: npm** (required for this sample - `package.json` defines npm scripts and dependencies).
|
||||
- **Bundler: esbuild** (required for this sample - `esbuild.config.mjs` and build scripts depend on it). Alternative bundlers like Rollup or webpack are acceptable for other projects if they bundle all external dependencies into `main.js`.
|
||||
- Types: `obsidian` type definitions.
|
||||
|
||||
**Note**: This sample project has specific technical dependencies on npm and esbuild. If you're creating a plugin from scratch, you can choose different tools, but you'll need to replace the build configuration accordingly.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Dev (watch)
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Production build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Linting
|
||||
|
||||
- To use eslint install eslint from terminal: `npm install -g eslint`
|
||||
- To use eslint to analyze this project use this command: `eslint main.ts`
|
||||
- eslint will then create a report with suggestions for code improvement by file and line number.
|
||||
- If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder: `eslint ./src/`
|
||||
|
||||
## File & folder conventions
|
||||
|
||||
- **Organize code into multiple files**: Split functionality across separate modules rather than putting everything in `main.ts`.
|
||||
- Source lives in `src/`. Keep `main.ts` small and focused on plugin lifecycle (loading, unloading, registering commands).
|
||||
- **Example file structure**:
|
||||
```
|
||||
src/
|
||||
main.ts # Plugin entry point, lifecycle management
|
||||
settings.ts # Settings interface and defaults
|
||||
commands/ # Command implementations
|
||||
command1.ts
|
||||
command2.ts
|
||||
ui/ # UI components, modals, views
|
||||
modal.ts
|
||||
view.ts
|
||||
utils/ # Utility functions, helpers
|
||||
helpers.ts
|
||||
constants.ts
|
||||
types.ts # TypeScript interfaces and types
|
||||
```
|
||||
- **Do not commit build artifacts**: Never commit `node_modules/`, `main.js`, or other generated files to version control.
|
||||
- Keep the plugin small. Avoid large dependencies. Prefer browser-compatible packages.
|
||||
- Generated output should be placed at the plugin root or `dist/` depending on your build setup. Release artifacts must end up at the top level of the plugin folder in the vault (`main.js`, `manifest.json`, `styles.css`).
|
||||
|
||||
## Manifest rules (`manifest.json`)
|
||||
|
||||
- Must include (non-exhaustive):
|
||||
- `id` (plugin ID; for local dev it should match the folder name)
|
||||
- `name`
|
||||
- `version` (Semantic Versioning `x.y.z`)
|
||||
- `minAppVersion`
|
||||
- `description`
|
||||
- `isDesktopOnly` (boolean)
|
||||
- Optional: `author`, `authorUrl`, `fundingUrl` (string or map)
|
||||
- Never change `id` after release. Treat it as stable API.
|
||||
- Keep `minAppVersion` accurate when using newer APIs.
|
||||
- Canonical requirements are coded here: https://github.com/obsidianmd/obsidian-releases/blob/master/.github/workflows/validate-plugin-entry.yml
|
||||
|
||||
## Testing
|
||||
|
||||
- Manual install for testing: copy `main.js`, `manifest.json`, `styles.css` (if any) to:
|
||||
```
|
||||
<Vault>/.obsidian/plugins/<plugin-id>/
|
||||
```
|
||||
- Reload Obsidian and enable the plugin in **Settings → Community plugins**.
|
||||
|
||||
## Commands & settings
|
||||
|
||||
- Any user-facing commands should be added via `this.addCommand(...)`.
|
||||
- If the plugin has configuration, provide a settings tab and sensible defaults.
|
||||
- Persist settings using `this.loadData()` / `this.saveData()`.
|
||||
- Use stable command IDs; avoid renaming once released.
|
||||
|
||||
## Versioning & releases
|
||||
|
||||
- Bump `version` in `manifest.json` (SemVer) and update `versions.json` to map plugin version → minimum app version.
|
||||
- Create a GitHub release whose tag exactly matches `manifest.json`'s `version`. Do not use a leading `v`.
|
||||
- Attach `manifest.json`, `main.js`, and `styles.css` (if present) to the release as individual assets.
|
||||
- After the initial release, follow the process to add/update your plugin in the community catalog as required.
|
||||
|
||||
## Security, privacy, and compliance
|
||||
|
||||
Follow Obsidian's **Developer Policies** and **Plugin Guidelines**. In particular:
|
||||
|
||||
- Default to local/offline operation. Only make network requests when essential to the feature.
|
||||
- No hidden telemetry. If you collect optional analytics or call third-party services, require explicit opt-in and document clearly in `README.md` and in settings.
|
||||
- Never execute remote code, fetch and eval scripts, or auto-update plugin code outside of normal releases.
|
||||
- Minimize scope: read/write only what's necessary inside the vault. Do not access files outside the vault.
|
||||
- Clearly disclose any external services used, data sent, and risks.
|
||||
- Respect user privacy. Do not collect vault contents, filenames, or personal information unless absolutely necessary and explicitly consented.
|
||||
- Avoid deceptive patterns, ads, or spammy notifications.
|
||||
- Register and clean up all DOM, app, and interval listeners using the provided `register*` helpers so the plugin unloads safely.
|
||||
|
||||
## UX & copy guidelines (for UI text, commands, settings)
|
||||
|
||||
- Prefer sentence case for headings, buttons, and titles.
|
||||
- Use clear, action-oriented imperatives in step-by-step copy.
|
||||
- Use **bold** to indicate literal UI labels. Prefer "select" for interactions.
|
||||
- Use arrow notation for navigation: **Settings → Community plugins**.
|
||||
- Keep in-app strings short, consistent, and free of jargon.
|
||||
|
||||
## Performance
|
||||
|
||||
- Keep startup light. Defer heavy work until needed.
|
||||
- Avoid long-running tasks during `onload`; use lazy initialization.
|
||||
- Batch disk access and avoid excessive vault scans.
|
||||
- Debounce/throttle expensive operations in response to file system events.
|
||||
|
||||
## Coding conventions
|
||||
|
||||
- TypeScript with `"strict": true` preferred.
|
||||
- **Keep `main.ts` minimal**: Focus only on plugin lifecycle (onload, onunload, addCommand calls). Delegate all feature logic to separate modules.
|
||||
- **Split large files**: If any file exceeds ~200-300 lines, consider breaking it into smaller, focused modules.
|
||||
- **Use clear module boundaries**: Each file should have a single, well-defined responsibility.
|
||||
- Bundle everything into `main.js` (no unbundled runtime deps).
|
||||
- Avoid Node/Electron APIs if you want mobile compatibility; set `isDesktopOnly` accordingly.
|
||||
- Prefer `async/await` over promise chains; handle errors gracefully.
|
||||
|
||||
## Mobile
|
||||
|
||||
- Where feasible, test on iOS and Android.
|
||||
- Don't assume desktop-only behavior unless `isDesktopOnly` is `true`.
|
||||
- Avoid large in-memory structures; be mindful of memory and storage constraints.
|
||||
|
||||
## Agent do/don't
|
||||
|
||||
**Do**
|
||||
- Add commands with stable IDs (don't rename once released).
|
||||
- Provide defaults and validation in settings.
|
||||
- Write idempotent code paths so reload/unload doesn't leak listeners or intervals.
|
||||
- Use `this.register*` helpers for everything that needs cleanup.
|
||||
|
||||
**Don't**
|
||||
- Introduce network calls without an obvious user-facing reason and documentation.
|
||||
- Ship features that require cloud services without clear disclosure and explicit opt-in.
|
||||
- Store or transmit vault contents unless essential and consented.
|
||||
|
||||
## Common tasks
|
||||
|
||||
### Organize code across multiple files
|
||||
|
||||
**main.ts** (minimal, lifecycle only):
|
||||
```ts
|
||||
import { Plugin } from "obsidian";
|
||||
import { MySettings, DEFAULT_SETTINGS } from "./settings";
|
||||
import { registerCommands } from "./commands";
|
||||
|
||||
export default class MyPlugin extends Plugin {
|
||||
settings: MySettings;
|
||||
|
||||
async onload() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
registerCommands(this);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**settings.ts**:
|
||||
```ts
|
||||
export interface MySettings {
|
||||
enabled: boolean;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: MySettings = {
|
||||
enabled: true,
|
||||
apiKey: "",
|
||||
};
|
||||
```
|
||||
|
||||
**commands/index.ts**:
|
||||
```ts
|
||||
import { Plugin } from "obsidian";
|
||||
import { doSomething } from "./my-command";
|
||||
|
||||
export function registerCommands(plugin: Plugin) {
|
||||
plugin.addCommand({
|
||||
id: "do-something",
|
||||
name: "Do something",
|
||||
callback: () => doSomething(plugin),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Add a command
|
||||
|
||||
```ts
|
||||
this.addCommand({
|
||||
id: "your-command-id",
|
||||
name: "Do the thing",
|
||||
callback: () => this.doTheThing(),
|
||||
});
|
||||
```
|
||||
|
||||
### Persist settings
|
||||
|
||||
```ts
|
||||
interface MySettings { enabled: boolean }
|
||||
const DEFAULT_SETTINGS: MySettings = { enabled: true };
|
||||
|
||||
async onload() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
```
|
||||
|
||||
### Register listeners safely
|
||||
|
||||
```ts
|
||||
this.registerEvent(this.app.workspace.on("file-open", f => { /* ... */ }));
|
||||
this.registerDomEvent(window, "resize", () => { /* ... */ });
|
||||
this.registerInterval(window.setInterval(() => { /* ... */ }, 1000));
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Plugin doesn't load after build: ensure `main.js` and `manifest.json` are at the top level of the plugin folder under `<Vault>/.obsidian/plugins/<plugin-id>/`.
|
||||
- Build issues: if `main.js` is missing, run `npm run build` or `npm run dev` to compile your TypeScript source code.
|
||||
- Commands not appearing: verify `addCommand` runs after `onload` and IDs are unique.
|
||||
- Settings not persisting: ensure `loadData`/`saveData` are awaited and you re-render the UI after changes.
|
||||
- Mobile-only issues: confirm you're not using desktop-only APIs; check `isDesktopOnly` and adjust.
|
||||
|
||||
## References
|
||||
|
||||
- Obsidian sample plugin: https://github.com/obsidianmd/obsidian-sample-plugin
|
||||
- API documentation: https://docs.obsidian.md
|
||||
- Developer policies: https://docs.obsidian.md/Developer+policies
|
||||
- Plugin guidelines: https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines
|
||||
- Style guide: https://help.obsidian.md/style-guide
|
||||
5
LICENSE
Normal file
5
LICENSE
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Copyright (C) 2020-2025 by Dynalist Inc.
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
58
README.md
Normal file
58
README.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Obsidian Password Manager
|
||||
|
||||
[中文说明](./README.zh-CN.md)
|
||||
|
||||
> A lightweight password manager for Obsidian that helps you store accounts, passwords, and links locally, with encryption, backup, trash, and import/export support.
|
||||
|
||||

|
||||
|
||||
## Introduction
|
||||
|
||||
**Obsidian Password Manager** is a lightweight plugin for Obsidian designed to keep everyday account and password records inside your vault.
|
||||
|
||||
It stores data in a JSON-based structure and provides a three-pane management interface so you can switch quickly between groups, entries, and details. For common use cases such as website logins, software credentials, and simple key records, this workflow stays lightweight and easy to manage alongside your notes.
|
||||
|
||||
## Features
|
||||
|
||||
- **Encrypted storage** – Sensitive fields such as passwords are encrypted locally with symmetric encryption, and the key remains under your control.
|
||||
- **Group management** – Organize entries with custom groups such as default items, website keys, or personal profiles.
|
||||
- **Fast search** – Search entries in real time by title, username, link, note, or group name.
|
||||
- **Backup and restore** – Export all data as a JSON snapshot and restore it when needed.
|
||||
- **Import / export** – Supports CSV and JSON formats for migration and interoperability.
|
||||
- **Trash** – Deleted entries are moved to trash first, where they can be restored or permanently removed.
|
||||
|
||||
## Installation
|
||||
|
||||
### Install with BRAT
|
||||
|
||||
1. Install the [BRAT](https://github.com/TfTHacker/obsidian-brat) plugin.
|
||||
2. In BRAT settings, add `https://github.com/PandaNocturne/obsidian-password-manager`.
|
||||
3. Enable the plugin.
|
||||
|
||||
### Manual installation
|
||||
|
||||
- Download the latest `main.js`, `manifest.json`, and `styles.css` into `.obsidian/plugins/obsidian-password-manager/` inside your vault.
|
||||
- Restart Obsidian and enable the plugin.
|
||||
|
||||
## Security notes
|
||||
|
||||
- This plugin does **not** send data over the network. All information is stored locally in your Obsidian vault.
|
||||
- If you sync your vault with Git, make sure your `.gitignore` excludes any encryption key files if you use them, or accept that encrypted JSON data may be committed.
|
||||
|
||||
> If you need a full-featured professional password manager, this plugin is better treated as a lightweight local record tool rather than a complete replacement.
|
||||
|
||||
## Development and contribution
|
||||
|
||||
- Author: [PandaNocturne](https://github.com/PandaNocturne)
|
||||
- Issues and pull requests are welcome.
|
||||
- Local development:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/PandaNocturne/obsidian-password-manager.git
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
62
README.zh-CN.md
Normal file
62
README.zh-CN.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Obsidian Password Manager
|
||||
|
||||
[English README](./README.md)
|
||||
|
||||
> 在 Obsidian 中轻量管理账号、密码与链接,支持加密、备份、回收站与导入导出。
|
||||
|
||||

|
||||
|
||||
## 📖 简介
|
||||
|
||||
**Obsidian Password Manager** 是一款用于 Obsidian 的轻量密码管理插件,适合在笔记库中集中保存和整理日常使用的账号密码信息。
|
||||
|
||||
插件使用 JSON 结构存储数据,并提供三栏管理界面,便于在分组、条目和详情之间快速切换。对于常见的网站账号、软件登录信息、简单密钥记录等场景,这种方式更轻量,也更方便和笔记一起管理。
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- 🔐 **加密存储** – 对密码等敏感字段进行本地对称加密,密钥由用户保管。
|
||||
- 📂 **分组管理** – 自定义分组(默认分组、网站密钥、个人档案等),便于分类。
|
||||
- 🔍 **快速检索** – 支持按标题、用户名、链接、备注或分组名实时搜索。
|
||||
- 💾 **备份与恢复** – 一键导出所有数据为 JSON 快照,随时恢复。
|
||||
- 📤 **导入 / 导出** – 支持 CSV / JSON 格式,方便迁移或与其他工具协作。
|
||||
- 🗑️ **回收站** – 删除的条目暂存回收站,可还原或彻底清除。
|
||||
|
||||
## 🚀 安装
|
||||
|
||||
### 手动安装(BRAT)
|
||||
|
||||
1. 安装 [BRAT](https://github.com/TfTHacker/obsidian-brat) 插件。
|
||||
2. 在 BRAT 设置中添加:`https://github.com/yourusername/obsidian-password-manager`。
|
||||
3. 启用插件。
|
||||
|
||||
### 本地手动安装
|
||||
|
||||
- 下载最新 `main.js`, `manifest.json`, `styles.css` 到库目录 `.obsidian/plugins/obsidian-password-manager/`。
|
||||
- 重启 Obsidian 并启用插件。
|
||||
|
||||
## 🔒 安全说明
|
||||
|
||||
- 本插件**不会**通过网络传输任何数据,所有信息均保存在本地 Obsidian Vault 中。
|
||||
- 如果使用 Git 同步 Vault,请确保 `.gitignore` 排除加密密钥文件(若有),或接受 JSON 文件中密文被提交。
|
||||
|
||||
> 如果你需要更完整的专业密码管理能力,这个插件更适合作为轻量记录工具,而不是完全替代专业密码管理器。
|
||||
|
||||
## 📝 开发与贡献
|
||||
|
||||
- 项目地址:[https://github.com/yourusername/obsidian-password-manager](https://github.com/yourusername/obsidian-password-manager)
|
||||
- 欢迎提交 Issue、PR 或建议。
|
||||
- 本地开发:
|
||||
|
||||
```bash
|
||||
git clone ...
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 👏 鸣谢
|
||||
|
||||
Developed by [PandaNocturne](https://github.com/PandaNocturne).
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
[MIT](LICENSE)
|
||||
BIN
assets/demo.png
Normal file
BIN
assets/demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
49
esbuild.config.mjs
Normal file
49
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import { builtinModules } from 'node:module';
|
||||
|
||||
const banner =
|
||||
`/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
`;
|
||||
|
||||
const prod = (process.argv[2] === "production");
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtinModules],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
minify: prod,
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
34
eslint.config.mts
Normal file
34
eslint.config.mts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import tseslint from 'typescript-eslint';
|
||||
import obsidianmd from "eslint-plugin-obsidianmd";
|
||||
import globals from "globals";
|
||||
import { globalIgnores } from "eslint/config";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
parserOptions: {
|
||||
projectService: {
|
||||
allowDefaultProject: [
|
||||
'eslint.config.js',
|
||||
'manifest.json'
|
||||
]
|
||||
},
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
extraFileExtensions: ['.json']
|
||||
},
|
||||
},
|
||||
},
|
||||
...obsidianmd.configs.recommended,
|
||||
globalIgnores([
|
||||
"node_modules",
|
||||
"dist",
|
||||
"esbuild.config.mjs",
|
||||
"eslint.config.js",
|
||||
"version-bump.mjs",
|
||||
"versions.json",
|
||||
"main.js",
|
||||
]),
|
||||
);
|
||||
11
manifest.json
Normal file
11
manifest.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample Plugin",
|
||||
"version": "1.0.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Demonstrates some of the capabilities of the Obsidian API.",
|
||||
"author": "Obsidian",
|
||||
"authorUrl": "https://obsidian.md",
|
||||
"fundingUrl": "https://obsidian.md/pricing",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
5160
package-lock.json
generated
Normal file
5160
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
29
package.json
Normal file
29
package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "obsidian-sample-plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"keywords": [],
|
||||
"license": "0-BSD",
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.6",
|
||||
"esbuild": "0.25.5",
|
||||
"eslint-plugin-obsidianmd": "0.1.9",
|
||||
"globals": "14.0.0",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "8.35.1",
|
||||
"@eslint/js": "9.30.1",
|
||||
"jiti": "2.6.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"obsidian": "latest"
|
||||
}
|
||||
}
|
||||
42
src/data/defaults.ts
Normal file
42
src/data/defaults.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { PWM_TEXT } from '../lang';
|
||||
import { DEFAULT_PASSWORD_MANAGER_SETTINGS } from '../settings';
|
||||
import type { PasswordManagerData } from '../util/types';
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
export const DEFAULT_DATA: PasswordManagerData = {
|
||||
groups: [
|
||||
{
|
||||
id: 'default-group',
|
||||
name: PWM_TEXT.defaultGroupName,
|
||||
createdAt: now,
|
||||
order: 0,
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
id: 'default-item',
|
||||
groupIds: ['default-group'],
|
||||
title: PWM_TEXT.defaultItemTitle,
|
||||
username: PWM_TEXT.defaultItemUsername,
|
||||
password: 'password',
|
||||
urls: [],
|
||||
notes: '',
|
||||
pinned: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
order: 0,
|
||||
},
|
||||
],
|
||||
trash: [],
|
||||
view: {
|
||||
groupSort: 'custom',
|
||||
itemSort: 'custom',
|
||||
lastMode: 'default',
|
||||
lastSelectedGroupId: 'default-group',
|
||||
lastSelectedItemId: 'default-item',
|
||||
groupColumnWidth: 220,
|
||||
itemColumnWidth: 320,
|
||||
},
|
||||
settings: DEFAULT_PASSWORD_MANAGER_SETTINGS,
|
||||
};
|
||||
160
src/data/import-service.ts
Normal file
160
src/data/import-service.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { createId } from '../util/id';
|
||||
import { decryptPasswordManagerData, isEncryptedLibraryPayload } from '../util/encryption';
|
||||
import type {
|
||||
PasswordGroup,
|
||||
PasswordItem,
|
||||
PasswordManagerData,
|
||||
PasswordManagerExportPayload,
|
||||
} from '../util/types';
|
||||
import { normalizeImportedLibraryData, normalizeUrls } from './normalize';
|
||||
import { createGroup, createItem, reindexOrders } from './password-library-service';
|
||||
import { parseCsvGroup, parseImportPayload, parseMarkdownGroup, parseMarkdownItems } from './transfer';
|
||||
|
||||
function assertImportPayload(
|
||||
payload: PasswordManagerExportPayload,
|
||||
kind: PasswordManagerExportPayload['kind'],
|
||||
): void {
|
||||
if (payload.kind !== kind) {
|
||||
throw new Error('Invalid import payload');
|
||||
}
|
||||
}
|
||||
|
||||
function applyImportedItem(data: PasswordManagerData, groupId: string, source: Partial<PasswordItem> & { url?: unknown }, index = 0) {
|
||||
const item = createItem(data, groupId);
|
||||
item.id = createId();
|
||||
item.title = source.title || item.title;
|
||||
item.username = source.username || '';
|
||||
item.password = source.password || '';
|
||||
item.urls = normalizeUrls(source.urls ?? source.url);
|
||||
item.notes = source.notes || '';
|
||||
item.createdAt = typeof source.createdAt === 'number' ? source.createdAt : Date.now() + index;
|
||||
item.groupIds = [groupId];
|
||||
item.pinned = !!source.pinned;
|
||||
return item;
|
||||
}
|
||||
|
||||
function parseLibraryImportData(payload: unknown): PasswordManagerData {
|
||||
if (isEncryptedLibraryPayload(payload)) {
|
||||
throw new Error('Encrypted import payload requires password');
|
||||
}
|
||||
|
||||
const rawPayload = payload as Partial<PasswordManagerExportPayload> | PasswordManagerData;
|
||||
if (
|
||||
rawPayload
|
||||
&& typeof rawPayload === 'object'
|
||||
&& 'kind' in rawPayload
|
||||
&& rawPayload.kind === 'library'
|
||||
&& 'data' in rawPayload
|
||||
) {
|
||||
const data = (rawPayload as PasswordManagerExportPayload).data as PasswordManagerData;
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('Invalid import payload');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
if (rawPayload && typeof rawPayload === 'object' && 'groups' in rawPayload && 'items' in rawPayload) {
|
||||
return rawPayload as PasswordManagerData;
|
||||
}
|
||||
|
||||
throw new Error('Invalid import payload');
|
||||
}
|
||||
|
||||
export function isEncryptedLibraryImportText(text: string): boolean {
|
||||
try {
|
||||
return isEncryptedLibraryPayload(JSON.parse(text));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function importLibraryFromText(text: string, password?: string): Promise<PasswordManagerData> {
|
||||
const payload = JSON.parse(text) as unknown;
|
||||
if (isEncryptedLibraryPayload(payload)) {
|
||||
if (!password) {
|
||||
throw new Error('Missing encryption password');
|
||||
}
|
||||
const imported = normalizeImportedLibraryData(await decryptPasswordManagerData(payload, password));
|
||||
reindexOrders(imported);
|
||||
return imported;
|
||||
}
|
||||
|
||||
const data = parseLibraryImportData(payload);
|
||||
const imported = normalizeImportedLibraryData(data);
|
||||
reindexOrders(imported);
|
||||
return imported;
|
||||
}
|
||||
|
||||
export function importGroupFromText(text: string, data: PasswordManagerData): PasswordGroup {
|
||||
let groupName = '';
|
||||
let items: Partial<PasswordItem>[] = [];
|
||||
let createdAt = Date.now();
|
||||
|
||||
try {
|
||||
const payload = parseImportPayload(text);
|
||||
assertImportPayload(payload, 'group');
|
||||
|
||||
if (
|
||||
typeof payload.data !== 'object' ||
|
||||
!payload.data ||
|
||||
!('group' in payload.data) ||
|
||||
!('items' in payload.data)
|
||||
) {
|
||||
throw new Error('Invalid import payload');
|
||||
}
|
||||
|
||||
const source = payload.data as { group: Partial<PasswordGroup>; items: Partial<PasswordItem>[] };
|
||||
groupName = source.group.name || '';
|
||||
createdAt = typeof source.group.createdAt === 'number' ? source.group.createdAt : Date.now();
|
||||
items = source.items;
|
||||
} catch {
|
||||
try {
|
||||
const markdownGroup = parseMarkdownGroup(text);
|
||||
groupName = markdownGroup.groupName;
|
||||
items = markdownGroup.items;
|
||||
} catch {
|
||||
const csvGroup = parseCsvGroup(text);
|
||||
groupName = csvGroup.groupName;
|
||||
items = csvGroup.items;
|
||||
}
|
||||
}
|
||||
|
||||
const group = createGroup(data, groupName);
|
||||
group.createdAt = createdAt;
|
||||
|
||||
items.forEach((sourceItem, index) => {
|
||||
applyImportedItem(data, group.id, sourceItem, index);
|
||||
});
|
||||
|
||||
reindexOrders(data);
|
||||
return group;
|
||||
}
|
||||
|
||||
export function importItemFromText(text: string, data: PasswordManagerData, groupId: string): PasswordItem {
|
||||
return importItemsFromText(text, data, groupId)[0];
|
||||
}
|
||||
|
||||
export function importItemsFromText(text: string, data: PasswordManagerData, groupId: string): PasswordItem[] {
|
||||
let sources: Partial<PasswordItem>[] = [];
|
||||
|
||||
try {
|
||||
const payload = parseImportPayload(text);
|
||||
if (payload.kind === 'item') {
|
||||
sources = [payload.data as Partial<PasswordItem>];
|
||||
} else if (payload.kind === 'items') {
|
||||
const items = payload.data as Partial<PasswordItem>[];
|
||||
if (!Array.isArray(items)) {
|
||||
throw new Error('Invalid import payload');
|
||||
}
|
||||
sources = items;
|
||||
} else {
|
||||
throw new Error('Invalid import payload');
|
||||
}
|
||||
} catch {
|
||||
sources = parseMarkdownItems(text, data, groupId);
|
||||
}
|
||||
|
||||
const imported = sources.map((source, index) => applyImportedItem(data, groupId, source, index));
|
||||
reindexOrders(data);
|
||||
return imported;
|
||||
}
|
||||
321
src/data/normalize.ts
Normal file
321
src/data/normalize.ts
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
import { DEFAULT_DATA } from './defaults';
|
||||
import type { PasswordManagerSettings, PasswordPluginConfig } from '../settings';
|
||||
import { PWM_TEXT } from '../lang';
|
||||
import { createId } from '../util/id';
|
||||
import type {
|
||||
DeletedPasswordItem,
|
||||
EncryptedPasswordVerifier,
|
||||
PasswordCopyFormat,
|
||||
PasswordGroup,
|
||||
PasswordItem,
|
||||
PasswordManagerData,
|
||||
PasswordUnlockMode,
|
||||
PwmSortMode,
|
||||
} from '../util/types';
|
||||
import { isEncryptedPasswordVerifier } from '../util/encryption';
|
||||
|
||||
const DEFAULT_STORAGE_FOLDER_NAME = '.password';
|
||||
const DEFAULT_AUTO_BACKUP_COUNT = 20;
|
||||
const DEFAULT_AUTO_BACKUP_INTERVAL_MINUTES = 5;
|
||||
const DEFAULT_TRASH_RETENTION_DAYS = 150;
|
||||
const DEFAULT_ENCRYPTION_RECHECK_INTERVAL_MINUTES = 30;
|
||||
const DEFAULT_ENCRYPTION_UNLOCK_MODE: PasswordUnlockMode = 'session';
|
||||
const DEFAULT_MODAL_WIDTH_EXPR = '92vw, 1200px';
|
||||
const DEFAULT_MODAL_HEIGHT_EXPR = '80vh, 800px';
|
||||
const DEFAULT_COLUMN_RATIO_EXPR = '1,1,2';
|
||||
const DEFAULT_GROUP_COLUMN_WIDTH = 220;
|
||||
const DEFAULT_ITEM_COLUMN_WIDTH = 320;
|
||||
|
||||
export function normalizePasswordManagerData(saved: unknown): PasswordManagerData {
|
||||
const source = saved as Partial<PasswordManagerData> | undefined;
|
||||
const now = Date.now();
|
||||
const sourceGroups = Array.isArray(source?.groups)
|
||||
? source?.groups ?? []
|
||||
: structuredClone(DEFAULT_DATA.groups);
|
||||
const groups = sourceGroups.map(
|
||||
(group: Partial<PasswordGroup>, index: number): PasswordGroup => ({
|
||||
id: group.id || createId(),
|
||||
name: group.name?.trim() || `${PWM_TEXT.generatedGroupName} ${index + 1}`,
|
||||
createdAt: typeof group.createdAt === 'number' ? group.createdAt : now + index,
|
||||
order: typeof group.order === 'number' ? group.order : index,
|
||||
}),
|
||||
);
|
||||
|
||||
const fallbackGroupId = groups[0]?.id ?? createId();
|
||||
const availableGroupIds = groups.map((group: PasswordGroup) => group.id);
|
||||
const rawItems = Array.isArray(source?.items)
|
||||
? source?.items ?? []
|
||||
: structuredClone(DEFAULT_DATA.items);
|
||||
const items = rawItems.map(
|
||||
(item: Partial<PasswordItem> & { groupId?: string; url?: unknown }, index: number): PasswordItem => ({
|
||||
id: item.id || createId(),
|
||||
groupIds: normalizeGroupIds(item.groupIds ?? item.groupId, fallbackGroupId, availableGroupIds),
|
||||
title: item.title || PWM_TEXT.generatedNewItemTitle,
|
||||
username: item.username || '',
|
||||
password: item.password || '',
|
||||
urls: normalizeUrls(item.urls ?? item.url),
|
||||
notes: item.notes || '',
|
||||
pinned: typeof item.pinned === 'boolean' ? item.pinned : false,
|
||||
createdAt: typeof item.createdAt === 'number' ? item.createdAt : now + index,
|
||||
updatedAt: typeof item.updatedAt === 'number'
|
||||
? item.updatedAt
|
||||
: (typeof item.createdAt === 'number' ? item.createdAt : now + index),
|
||||
order: typeof item.order === 'number' ? item.order : index,
|
||||
}),
|
||||
);
|
||||
const rawTrash = Array.isArray(source?.trash)
|
||||
? source?.trash ?? []
|
||||
: [];
|
||||
const trash = rawTrash
|
||||
.filter((item) => !!item && typeof item === 'object')
|
||||
.map((entry, index): DeletedPasswordItem => {
|
||||
const item = entry as Partial<DeletedPasswordItem> & { url?: unknown };
|
||||
return {
|
||||
id: item.id || createId(),
|
||||
groupIds: normalizeGroupIds(item.groupIds, fallbackGroupId),
|
||||
deletedGroupNames: Array.isArray(item.deletedGroupNames)
|
||||
? item.deletedGroupNames
|
||||
.filter((name): name is string => typeof name === 'string')
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
title: item.title || PWM_TEXT.generatedNewItemTitle,
|
||||
username: item.username || '',
|
||||
password: item.password || '',
|
||||
urls: normalizeUrls(item.urls ?? item.url),
|
||||
notes: item.notes || '',
|
||||
pinned: typeof item.pinned === 'boolean' ? item.pinned : false,
|
||||
createdAt: typeof item.createdAt === 'number' ? item.createdAt : now + index,
|
||||
updatedAt: typeof item.updatedAt === 'number'
|
||||
? item.updatedAt
|
||||
: (typeof item.createdAt === 'number' ? item.createdAt : now + index),
|
||||
order: typeof item.order === 'number' ? item.order : index,
|
||||
deletedAt: typeof item.deletedAt === 'number' ? item.deletedAt : now,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
groups,
|
||||
items,
|
||||
trash,
|
||||
view: {
|
||||
groupSort: normalizeSortMode(source?.view?.groupSort),
|
||||
itemSort: normalizeSortMode(source?.view?.itemSort),
|
||||
lastMode: normalizeModalMode(source?.view?.lastMode),
|
||||
lastSelectedGroupId: typeof source?.view?.lastSelectedGroupId === 'string' ? source.view.lastSelectedGroupId : '',
|
||||
lastSelectedItemId: typeof source?.view?.lastSelectedItemId === 'string' ? source.view.lastSelectedItemId : '',
|
||||
groupColumnWidth: normalizeColumnWidth(source?.view?.groupColumnWidth, DEFAULT_GROUP_COLUMN_WIDTH),
|
||||
itemColumnWidth: normalizeColumnWidth(source?.view?.itemColumnWidth, DEFAULT_ITEM_COLUMN_WIDTH),
|
||||
},
|
||||
settings: normalizeSettings(source?.settings),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeImportedLibraryData(data: PasswordManagerData): PasswordManagerData {
|
||||
return normalizePasswordManagerData(data);
|
||||
}
|
||||
|
||||
export function normalizeGroupIds(
|
||||
groupIds: unknown,
|
||||
fallbackGroupId: string,
|
||||
availableGroupIds?: Iterable<string>,
|
||||
): string[] {
|
||||
const source = Array.isArray(groupIds)
|
||||
? groupIds
|
||||
: typeof groupIds === 'string'
|
||||
? [groupIds]
|
||||
: [];
|
||||
const available = new Set(availableGroupIds ?? []);
|
||||
const normalized = [
|
||||
...new Set(
|
||||
source
|
||||
.filter((id): id is string => typeof id === 'string')
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => id && (available.size === 0 || available.has(id))),
|
||||
),
|
||||
];
|
||||
return normalized.length ? normalized : [fallbackGroupId];
|
||||
}
|
||||
|
||||
export function normalizeUrls(urls: unknown): string[] {
|
||||
const source = Array.isArray(urls)
|
||||
? urls
|
||||
: typeof urls === 'string'
|
||||
? [urls]
|
||||
: [];
|
||||
|
||||
return source
|
||||
.filter((url): url is string => typeof url === 'string')
|
||||
.map((url) => url.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function normalizeSettings(settings: unknown): PasswordManagerSettings {
|
||||
const source = settings as PasswordManagerSettings | undefined;
|
||||
return {
|
||||
confirmBeforeDelete:
|
||||
typeof source?.confirmBeforeDelete === 'boolean'
|
||||
? source.confirmBeforeDelete
|
||||
: DEFAULT_DATA.settings.confirmBeforeDelete,
|
||||
copyFormat: normalizeCopyFormat(source?.copyFormat),
|
||||
showItemUsername:
|
||||
typeof source?.showItemUsername === 'boolean'
|
||||
? source.showItemUsername
|
||||
: DEFAULT_DATA.settings.showItemUsername,
|
||||
showItemUrl:
|
||||
typeof source?.showItemUrl === 'boolean'
|
||||
? source.showItemUrl
|
||||
: DEFAULT_DATA.settings.showItemUrl,
|
||||
showItemGroupTags:
|
||||
typeof source?.showItemGroupTags === 'boolean'
|
||||
? source.showItemGroupTags
|
||||
: DEFAULT_DATA.settings.showItemGroupTags,
|
||||
showItemNotes:
|
||||
typeof source?.showItemNotes === 'boolean'
|
||||
? source.showItemNotes
|
||||
: DEFAULT_DATA.settings.showItemNotes,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePluginConfig(config: unknown): PasswordPluginConfig {
|
||||
const source = config as Partial<PasswordPluginConfig> & {
|
||||
modalWidthVw?: unknown;
|
||||
modalHeightVh?: unknown;
|
||||
} | undefined;
|
||||
return {
|
||||
storageFolderName: source?.storageFolderName?.trim() || DEFAULT_STORAGE_FOLDER_NAME,
|
||||
autoBackupEnabled:
|
||||
typeof source?.autoBackupEnabled === 'boolean'
|
||||
? source.autoBackupEnabled
|
||||
: true,
|
||||
autoBackupCount: normalizeAutoBackupCount(source?.autoBackupCount),
|
||||
autoBackupIntervalMinutes: normalizeAutoBackupIntervalMinutes(source?.autoBackupIntervalMinutes),
|
||||
trashRetentionDays: normalizeTrashRetentionDays(source?.trashRetentionDays),
|
||||
lastAutoBackupAt:
|
||||
typeof source?.lastAutoBackupAt === 'number' && Number.isFinite(source.lastAutoBackupAt)
|
||||
? source.lastAutoBackupAt
|
||||
: 0,
|
||||
encryptionEnabled:
|
||||
typeof source?.encryptionEnabled === 'boolean'
|
||||
? source.encryptionEnabled
|
||||
: false,
|
||||
encryptionUnlockMode: normalizeEncryptionUnlockMode(source?.encryptionUnlockMode),
|
||||
encryptionRecheckIntervalMinutes: normalizeEncryptionRecheckIntervalMinutes(source?.encryptionRecheckIntervalMinutes),
|
||||
encryptionVerifier: normalizeEncryptionVerifier(source?.encryptionVerifier),
|
||||
persistEncryptionPassword:
|
||||
typeof source?.persistEncryptionPassword === 'boolean'
|
||||
? source.persistEncryptionPassword
|
||||
: false,
|
||||
savedEncryptionPassword:
|
||||
typeof source?.savedEncryptionPassword === 'string'
|
||||
? source.savedEncryptionPassword
|
||||
: '',
|
||||
modalWidthExpr: normalizeModalExpr(source?.modalWidthExpr, source?.modalWidthVw, DEFAULT_MODAL_WIDTH_EXPR, 'vw'),
|
||||
modalHeightExpr: normalizeModalExpr(source?.modalHeightExpr, source?.modalHeightVh, DEFAULT_MODAL_HEIGHT_EXPR, 'vh'),
|
||||
columnRatioExpr: normalizeColumnRatioExpr(source?.columnRatioExpr),
|
||||
columnRatioLocked:
|
||||
typeof source?.columnRatioLocked === 'boolean'
|
||||
? source.columnRatioLocked
|
||||
: true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAutoBackupCount(value: unknown) {
|
||||
const count = typeof value === 'number' ? Math.round(value) : DEFAULT_AUTO_BACKUP_COUNT;
|
||||
return Math.min(50, Math.max(0, count));
|
||||
}
|
||||
|
||||
function normalizeAutoBackupIntervalMinutes(value: unknown) {
|
||||
const minutes = typeof value === 'number' ? Math.round(value) : DEFAULT_AUTO_BACKUP_INTERVAL_MINUTES;
|
||||
return Math.min(60, Math.max(1, minutes));
|
||||
}
|
||||
|
||||
function normalizeTrashRetentionDays(value: unknown) {
|
||||
const days = typeof value === 'number' ? Math.round(value) : DEFAULT_TRASH_RETENTION_DAYS;
|
||||
return Math.min(365, Math.max(0, days));
|
||||
}
|
||||
|
||||
function normalizeModalExpr(value: unknown, legacyValue: unknown, fallback: string, legacyUnit: 'vw' | 'vh') {
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof legacyValue === 'number' && Number.isFinite(legacyValue)) {
|
||||
return `${Math.round(legacyValue)}${legacyUnit}`;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeColumnRatioExpr(value: unknown) {
|
||||
if (typeof value !== 'string') {
|
||||
return DEFAULT_COLUMN_RATIO_EXPR;
|
||||
}
|
||||
|
||||
const ratios = value
|
||||
.split(',')
|
||||
.map((part) => Number(part.trim()))
|
||||
.filter((part) => Number.isFinite(part) && part > 0);
|
||||
|
||||
if (ratios.length !== 3) {
|
||||
return DEFAULT_COLUMN_RATIO_EXPR;
|
||||
}
|
||||
|
||||
return ratios.map((part) => `${part}`).join(',');
|
||||
}
|
||||
|
||||
function normalizeColumnWidth(value: unknown, fallback: number) {
|
||||
const width = typeof value === 'number' ? Math.round(value) : fallback;
|
||||
return Math.min(520, Math.max(160, width));
|
||||
}
|
||||
|
||||
function normalizeEncryptionUnlockMode(mode: unknown): PasswordUnlockMode {
|
||||
const modes: PasswordUnlockMode[] = ['session', 'interval', 'always'];
|
||||
return modes.includes(mode as PasswordUnlockMode)
|
||||
? (mode as PasswordUnlockMode)
|
||||
: DEFAULT_ENCRYPTION_UNLOCK_MODE;
|
||||
}
|
||||
|
||||
function normalizeEncryptionRecheckIntervalMinutes(value: unknown) {
|
||||
const minutes = typeof value === 'number' ? Math.round(value) : DEFAULT_ENCRYPTION_RECHECK_INTERVAL_MINUTES;
|
||||
return Math.max(1, minutes);
|
||||
}
|
||||
|
||||
function normalizeEncryptionVerifier(value: unknown): EncryptedPasswordVerifier | null {
|
||||
return isEncryptedPasswordVerifier(value) ? value : null;
|
||||
}
|
||||
|
||||
export function normalizeCopyFormat(format: unknown): PasswordCopyFormat {
|
||||
const formats: PasswordCopyFormat[] = ['markdown', 'plain-text', 'callout'];
|
||||
return formats.includes(format as PasswordCopyFormat)
|
||||
? (format as PasswordCopyFormat)
|
||||
: DEFAULT_DATA.settings.copyFormat;
|
||||
}
|
||||
|
||||
export function normalizeSortMode(mode: unknown): PwmSortMode {
|
||||
const modes: PwmSortMode[] = [
|
||||
'custom',
|
||||
'name-asc',
|
||||
'name-desc',
|
||||
'created-asc',
|
||||
'created-desc',
|
||||
'updated-asc',
|
||||
'updated-desc',
|
||||
'deleted-asc',
|
||||
'deleted-desc',
|
||||
'item-count-asc',
|
||||
'item-count-desc',
|
||||
];
|
||||
return modes.includes(mode as PwmSortMode) ? (mode as PwmSortMode) : 'custom';
|
||||
}
|
||||
|
||||
function normalizeModalMode(mode: unknown): 'default' | 'trash' {
|
||||
return mode === 'trash' ? 'trash' : 'default';
|
||||
}
|
||||
268
src/data/password-library-service.ts
Normal file
268
src/data/password-library-service.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import { createId } from '../util/id';
|
||||
import { PWM_TEXT, formatPWMText } from '../lang';
|
||||
import { validateFileSafeName } from '../util/file-name';
|
||||
import { getNextDuplicatedTitle } from '../util/duplicate-title';
|
||||
import { normalizeGroupIds } from './normalize';
|
||||
import type { PasswordGroup, PasswordItem, PasswordManagerData } from '../util/types';
|
||||
|
||||
function touchItem(item: PasswordItem) {
|
||||
item.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
export function createGroup(data: PasswordManagerData, name?: string) {
|
||||
const group: PasswordGroup = {
|
||||
id: createId(),
|
||||
name: name?.trim() || `${PWM_TEXT.generatedNewGroupName} ${data.groups.length + 1}`,
|
||||
createdAt: Date.now(),
|
||||
order: data.groups.length,
|
||||
};
|
||||
|
||||
data.groups.push(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
export function updateGroupName(data: PasswordManagerData, groupId: string, name: string) {
|
||||
const group = data.groups.find((item) => item.id === groupId);
|
||||
if (!group) {
|
||||
return PWM_TEXT.groupNotFound;
|
||||
}
|
||||
|
||||
const validationError = validateFileSafeName(name);
|
||||
if (validationError) {
|
||||
return formatPWMText(PWM_TEXT.invalidGroupNameWithReason, { reason: validationError });
|
||||
}
|
||||
|
||||
const nextName = name.trim();
|
||||
if (nextName === group.name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
group.name = nextName;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createItem(data: PasswordManagerData, groupId: string) {
|
||||
const now = Date.now();
|
||||
const item: PasswordItem = {
|
||||
id: createId(),
|
||||
groupIds: [groupId],
|
||||
title: PWM_TEXT.generatedNewItemTitle,
|
||||
username: '',
|
||||
password: '',
|
||||
urls: [],
|
||||
notes: '',
|
||||
pinned: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
order: data.items.length,
|
||||
};
|
||||
|
||||
data.items.push(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
export function duplicateItem(data: PasswordManagerData, itemId: string) {
|
||||
const source = data.items.find((item) => item.id === itemId);
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const item: PasswordItem = {
|
||||
...structuredClone(source),
|
||||
id: createId(),
|
||||
title: getNextDuplicatedTitle(data.items, source.title),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
order: data.items.length,
|
||||
};
|
||||
|
||||
const sourceIndex = data.items.findIndex((entry) => entry.id === itemId);
|
||||
const nextItems = [...data.items];
|
||||
nextItems.splice(sourceIndex + 1, 0, item);
|
||||
data.items = nextItems;
|
||||
reindexOrders(data);
|
||||
return item;
|
||||
}
|
||||
|
||||
export function updateItemTitle(data: PasswordManagerData, itemId: string, title: string) {
|
||||
const item = data.items.find((entry) => entry.id === itemId);
|
||||
if (!item) {
|
||||
return PWM_TEXT.itemNotFound;
|
||||
}
|
||||
|
||||
const validationError = validateFileSafeName(title);
|
||||
if (validationError) {
|
||||
return formatPWMText(PWM_TEXT.invalidItemTitleWithReason, { reason: validationError });
|
||||
}
|
||||
|
||||
const nextTitle = title.trim();
|
||||
if (nextTitle === item.title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
item.title = nextTitle;
|
||||
touchItem(item);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function updateItem(data: PasswordManagerData, itemId: string, patch: Partial<Omit<PasswordItem, 'id'>>) {
|
||||
const item = data.items.find((entry) => entry.id === itemId);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPatch = { ...patch };
|
||||
if ('groupIds' in nextPatch) {
|
||||
nextPatch.groupIds = normalizeGroupIds(
|
||||
nextPatch.groupIds,
|
||||
item.groupIds[0] ?? getFallbackGroupId(data),
|
||||
data.groups.map((group) => group.id),
|
||||
);
|
||||
}
|
||||
|
||||
const before = JSON.stringify(item);
|
||||
Object.assign(item, nextPatch);
|
||||
const after = JSON.stringify(item);
|
||||
if (before !== after) {
|
||||
touchItem(item);
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteGroup(data: PasswordManagerData, groupId: string) {
|
||||
data.items = data.items.filter((item) => {
|
||||
if (!item.groupIds.includes(groupId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const nextGroupIds = item.groupIds.filter((id) => id !== groupId);
|
||||
if (!nextGroupIds.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
item.groupIds = nextGroupIds;
|
||||
touchItem(item);
|
||||
return true;
|
||||
});
|
||||
|
||||
data.groups = data.groups.filter((group) => group.id !== groupId);
|
||||
reindexOrders(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function deleteItem(data: PasswordManagerData, itemId: string) {
|
||||
const item = data.items.find((entry) => entry.id === itemId);
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
data.items = data.items.filter((entry) => entry.id !== itemId);
|
||||
reindexOrders(data);
|
||||
return item;
|
||||
}
|
||||
|
||||
export function moveGroup(data: PasswordManagerData, groupId: string, toIndex: number) {
|
||||
moveGroups(data, [groupId], toIndex);
|
||||
}
|
||||
|
||||
export function moveGroups(data: PasswordManagerData, groupIds: string[], toIndex: number) {
|
||||
const groups = [...data.groups];
|
||||
const selectedIds = new Set(groupIds);
|
||||
const movingGroups = groups.filter((group) => selectedIds.has(group.id));
|
||||
if (!movingGroups.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingGroups = groups.filter((group) => !selectedIds.has(group.id));
|
||||
const boundedIndex = Math.max(0, Math.min(toIndex, remainingGroups.length));
|
||||
remainingGroups.splice(boundedIndex, 0, ...movingGroups);
|
||||
data.groups = remainingGroups;
|
||||
reindexOrders(data);
|
||||
}
|
||||
|
||||
export function moveItemWithinGroup(data: PasswordManagerData, itemId: string, toIndex: number, groupId: string) {
|
||||
moveItemsWithinGroup(data, [itemId], toIndex, groupId);
|
||||
}
|
||||
|
||||
export function moveItemsWithinGroup(data: PasswordManagerData, itemIds: string[], toIndex: number, groupId: string) {
|
||||
const visibleItems = data.items.filter((item) => item.groupIds.includes(groupId));
|
||||
const selectedIds = new Set(itemIds.filter((itemId) => visibleItems.some((item) => item.id === itemId)));
|
||||
const movingItems = visibleItems.filter((item) => selectedIds.has(item.id));
|
||||
if (!movingItems.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingVisibleItems = visibleItems.filter((item) => !selectedIds.has(item.id));
|
||||
const boundedIndex = Math.max(0, Math.min(toIndex, remainingVisibleItems.length));
|
||||
const reordered = [...remainingVisibleItems];
|
||||
reordered.splice(boundedIndex, 0, ...movingItems);
|
||||
|
||||
const reorderedIds = reordered.map((item) => item.id);
|
||||
const nextItems = [...data.items];
|
||||
const visibleIndexSet = new Set(visibleItems.map((item) => item.id));
|
||||
let orderCursor = 0;
|
||||
|
||||
for (let index = 0; index < nextItems.length; index += 1) {
|
||||
if (!visibleIndexSet.has(nextItems[index].id)) {
|
||||
continue;
|
||||
}
|
||||
nextItems[index] = reordered.find((item) => item.id === reorderedIds[orderCursor]) ?? nextItems[index];
|
||||
orderCursor += 1;
|
||||
}
|
||||
|
||||
data.items = nextItems;
|
||||
reindexOrders(data);
|
||||
}
|
||||
|
||||
export function assignItemToGroup(data: PasswordManagerData, itemId: string, groupId: string, mode: 'move' | 'add') {
|
||||
const item = data.items.find((entry) => entry.id === itemId);
|
||||
const group = data.groups.find((entry) => entry.id === groupId);
|
||||
if (!item || !group) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mode === 'add') {
|
||||
if (item.groupIds.includes(groupId)) {
|
||||
return false;
|
||||
}
|
||||
item.groupIds = [...item.groupIds, groupId];
|
||||
touchItem(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (item.groupIds.length === 1 && item.groupIds[0] === groupId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
item.groupIds = [groupId];
|
||||
touchItem(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function removeItemFromGroup(data: PasswordManagerData, itemId: string, groupId: string) {
|
||||
const item = data.items.find((entry) => entry.id === itemId);
|
||||
if (!item || !item.groupIds.includes(groupId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (item.groupIds.length <= 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
item.groupIds = item.groupIds.filter((id) => id !== groupId);
|
||||
touchItem(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function reindexOrders(data: PasswordManagerData) {
|
||||
data.groups.forEach((group, index) => {
|
||||
group.order = index;
|
||||
});
|
||||
data.items.forEach((item, index) => {
|
||||
item.order = index;
|
||||
});
|
||||
}
|
||||
|
||||
export function getFallbackGroupId(data: PasswordManagerData) {
|
||||
return data.groups[0]?.id;
|
||||
}
|
||||
265
src/data/storage-store.ts
Normal file
265
src/data/storage-store.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import { normalizePath, type App } from 'obsidian';
|
||||
import { formatDateTimeSuffix } from '../util/file-name';
|
||||
import { decryptPasswordManagerData, encryptPasswordManagerData, isEncryptedLibraryPayload } from '../util/encryption';
|
||||
import { downloadText } from './transfer';
|
||||
import { normalizePasswordManagerData } from './normalize';
|
||||
import type {
|
||||
DeletedPasswordItem,
|
||||
EncryptedPasswordLibraryPayload,
|
||||
PasswordItem,
|
||||
PasswordManagerData,
|
||||
} from '../util/types';
|
||||
import type { PasswordPluginConfig } from '../settings';
|
||||
|
||||
const DEFAULT_STORAGE_FOLDER_NAME = '.password';
|
||||
const DATA_FILE_NAME = 'data.json';
|
||||
const BACKUP_DIR_NAME = 'backup';
|
||||
const AUTO_BACKUP_PREFIX = 'auto-backup';
|
||||
const MANUAL_BACKUP_PREFIX = 'manual-backup';
|
||||
|
||||
export class PasswordStorageStore {
|
||||
constructor(private readonly app: App) {}
|
||||
|
||||
getStorageFolder(config: PasswordPluginConfig) {
|
||||
return normalizePath(config.storageFolderName.trim() || DEFAULT_STORAGE_FOLDER_NAME);
|
||||
}
|
||||
|
||||
getDataFilePath(config: PasswordPluginConfig) {
|
||||
return normalizePath(`${this.getStorageFolder(config)}/${DATA_FILE_NAME}`);
|
||||
}
|
||||
|
||||
getAutoBackupDirPath(config: PasswordPluginConfig) {
|
||||
return normalizePath(`${this.getStorageFolder(config)}/${BACKUP_DIR_NAME}`);
|
||||
}
|
||||
|
||||
getManualBackupDirPath(config: PasswordPluginConfig) {
|
||||
return normalizePath(`${this.getStorageFolder(config)}/${BACKUP_DIR_NAME}`);
|
||||
}
|
||||
|
||||
async readStoredData(config: PasswordPluginConfig): Promise<unknown | null> {
|
||||
const adapter = this.app.vault.adapter;
|
||||
const dataFilePath = this.getDataFilePath(config);
|
||||
if (!(await adapter.exists(dataFilePath))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await adapter.read(dataFilePath);
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async loadData(config: PasswordPluginConfig, encryptionPassword?: string): Promise<PasswordManagerData | null> {
|
||||
const stored = await this.readStoredData(config);
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isEncryptedLibraryPayload(stored)) {
|
||||
if (!encryptionPassword) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizePasswordManagerData(await decryptPasswordManagerData(stored, encryptionPassword));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return normalizePasswordManagerData(stored);
|
||||
}
|
||||
|
||||
async saveData(config: PasswordPluginConfig, data: PasswordManagerData, encryptionPassword?: string) {
|
||||
const adapter = this.app.vault.adapter;
|
||||
const storageFolder = this.getStorageFolder(config);
|
||||
const dataFilePath = this.getDataFilePath(config);
|
||||
|
||||
await this.ensureDir(storageFolder);
|
||||
|
||||
if (config.encryptionEnabled) {
|
||||
if (!encryptionPassword) {
|
||||
throw new Error('Missing encryption password');
|
||||
}
|
||||
const encryptedPayload = await encryptPasswordManagerData(data, encryptionPassword);
|
||||
await adapter.write(dataFilePath, JSON.stringify(encryptedPayload, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
await adapter.write(dataFilePath, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
async createAutoBackup(config: PasswordPluginConfig, data: PasswordManagerData, encryptionPassword?: string) {
|
||||
const backupFilePath = await this.writeBackupFile(
|
||||
config,
|
||||
data,
|
||||
this.getAutoBackupDirPath(config),
|
||||
AUTO_BACKUP_PREFIX,
|
||||
encryptionPassword,
|
||||
);
|
||||
await this.pruneBackups(config);
|
||||
return backupFilePath;
|
||||
}
|
||||
|
||||
async createManualBackup(config: PasswordPluginConfig, data: PasswordManagerData, encryptionPassword?: string) {
|
||||
return this.writeBackupFile(
|
||||
config,
|
||||
data,
|
||||
this.getManualBackupDirPath(config),
|
||||
MANUAL_BACKUP_PREFIX,
|
||||
encryptionPassword,
|
||||
);
|
||||
}
|
||||
|
||||
async createEncryptedManualBackup(config: PasswordPluginConfig) {
|
||||
const stored = await this.readStoredData(config);
|
||||
if (!stored || !isEncryptedLibraryPayload(stored)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.writeRawBackupFile(this.getManualBackupDirPath(config), MANUAL_BACKUP_PREFIX, stored);
|
||||
}
|
||||
|
||||
async downloadEncryptedLibrary(config: PasswordPluginConfig, filename: string) {
|
||||
const stored = await this.readStoredData(config);
|
||||
if (!stored || !isEncryptedLibraryPayload(stored)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
downloadText(filename, JSON.stringify(stored, null, 2), 'application/json');
|
||||
return true;
|
||||
}
|
||||
|
||||
async pruneBackups(config: PasswordPluginConfig) {
|
||||
if (config.autoBackupCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const adapter = this.app.vault.adapter;
|
||||
const backupDirPath = this.getAutoBackupDirPath(config);
|
||||
if (!(await adapter.exists(backupDirPath))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = await adapter.list(backupDirPath);
|
||||
const backupFiles = entries.files
|
||||
.filter((filePath) => filePath.endsWith('.json') && filePath.includes(`/${AUTO_BACKUP_PREFIX}-`))
|
||||
.sort((left, right) => this.getBackupTimestamp(left).localeCompare(this.getBackupTimestamp(right)));
|
||||
|
||||
const removeCount = Math.max(0, backupFiles.length - config.autoBackupCount);
|
||||
for (const filePath of backupFiles.slice(0, removeCount)) {
|
||||
await adapter.remove(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
moveItemToTrash(data: PasswordManagerData, item: PasswordItem) {
|
||||
const groupNameById = new Map(data.groups.map((group) => [group.id, group.name]));
|
||||
const deletedItem: DeletedPasswordItem = {
|
||||
...structuredClone(item),
|
||||
deletedAt: Date.now(),
|
||||
deletedGroupNames: item.groupIds.map((groupId) => groupNameById.get(groupId) || groupId),
|
||||
};
|
||||
data.trash = [deletedItem, ...data.trash.filter((entry) => entry.id !== item.id)];
|
||||
return deletedItem;
|
||||
}
|
||||
|
||||
async migrateLegacyTrash(config: PasswordPluginConfig, pluginId: string) {
|
||||
const adapter = this.app.vault.adapter;
|
||||
const legacyTrashPath = normalizePath(`${this.app.vault.configDir}/plugins/${pluginId}/trash.json`);
|
||||
if (!(await adapter.exists(legacyTrashPath))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storedData = await this.readStoredData(config);
|
||||
if (!storedData || isEncryptedLibraryPayload(storedData)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await adapter.read(legacyTrashPath);
|
||||
const parsed = JSON.parse(content) as { items?: DeletedPasswordItem[] };
|
||||
const legacyItems = Array.isArray(parsed?.items) ? parsed.items : [];
|
||||
if (!legacyItems.length) {
|
||||
await adapter.remove(legacyTrashPath).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedStored = normalizePasswordManagerData(storedData);
|
||||
if (normalizedStored.trash.length) {
|
||||
await adapter.remove(legacyTrashPath).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizePasswordManagerData({
|
||||
...(storedData as Record<string, unknown>),
|
||||
trash: legacyItems,
|
||||
});
|
||||
await this.saveData(config, normalized);
|
||||
await adapter.remove(legacyTrashPath).catch(() => undefined);
|
||||
} catch {
|
||||
// ignore migration failure and keep running with current data
|
||||
}
|
||||
}
|
||||
|
||||
private async writeBackupFile(
|
||||
config: PasswordPluginConfig,
|
||||
data: PasswordManagerData,
|
||||
backupDirPath: string,
|
||||
filePrefix: string,
|
||||
encryptionPassword?: string,
|
||||
) {
|
||||
const exportedAt = Date.now();
|
||||
|
||||
if (config.encryptionEnabled) {
|
||||
if (!encryptionPassword) {
|
||||
throw new Error('Missing encryption password');
|
||||
}
|
||||
const encryptedPayload = await encryptPasswordManagerData(data, encryptionPassword);
|
||||
return this.writeRawBackupFile(backupDirPath, filePrefix, encryptedPayload, exportedAt);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
version: 1 as const,
|
||||
kind: 'library' as const,
|
||||
exportedAt,
|
||||
data,
|
||||
};
|
||||
return this.writeRawBackupFile(backupDirPath, filePrefix, payload, exportedAt);
|
||||
}
|
||||
|
||||
private async writeRawBackupFile(
|
||||
backupDirPath: string,
|
||||
filePrefix: string,
|
||||
payload: EncryptedPasswordLibraryPayload | { version: 1; kind: 'library'; exportedAt: number; data: PasswordManagerData },
|
||||
exportedAt = Date.now(),
|
||||
) {
|
||||
const adapter = this.app.vault.adapter;
|
||||
const fileSuffix = formatDateTimeSuffix(exportedAt);
|
||||
|
||||
await this.ensureDir(backupDirPath);
|
||||
|
||||
const backupFilePath = normalizePath(`${backupDirPath}/${filePrefix}-${fileSuffix}.json`);
|
||||
await adapter.write(backupFilePath, JSON.stringify(payload, null, 2));
|
||||
return backupFilePath;
|
||||
}
|
||||
|
||||
private getBackupTimestamp(filePath: string) {
|
||||
const matched = filePath.match(/(?:auto|manual)-backup-(\d{8}-\d{6}-\d{3})\.json$/);
|
||||
return matched?.[1] ?? '';
|
||||
}
|
||||
|
||||
private async ensureDir(path: string) {
|
||||
const adapter = this.app.vault.adapter;
|
||||
const parts = normalizePath(path).split('/').filter(Boolean);
|
||||
let current = '';
|
||||
|
||||
for (const part of parts) {
|
||||
current = current ? `${current}/${part}` : part;
|
||||
if (!(await adapter.exists(current))) {
|
||||
await adapter.mkdir(current);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
440
src/data/transfer.ts
Normal file
440
src/data/transfer.ts
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
import { PWM_TEXT } from '../lang';
|
||||
import type { PasswordGroup, PasswordItem, PasswordManagerData, PasswordManagerExportPayload } from '../util/types';
|
||||
|
||||
interface ParsedMarkdownGroup {
|
||||
groupName: string;
|
||||
items: Partial<PasswordItem>[];
|
||||
}
|
||||
|
||||
const MARKDOWN_FIELD_LABELS = {
|
||||
username: [PWM_TEXT.copyFieldUsername, 'Username'],
|
||||
password: [PWM_TEXT.copyFieldPassword, 'Password'],
|
||||
url: [PWM_TEXT.copyFieldUrl, 'URL', 'Link'],
|
||||
notes: [PWM_TEXT.copyFieldNotes, 'Notes'],
|
||||
groupTags: [PWM_TEXT.copyFieldGroupTags, 'Group Tags'],
|
||||
} as const;
|
||||
|
||||
const CSV_HEADER_ALIASES = {
|
||||
group: ['group', '组', 'groupName'],
|
||||
title: ['title', '标题'],
|
||||
username: ['username', '用户名'],
|
||||
password: ['password', '密码'],
|
||||
url: ['url', 'link', '链接'],
|
||||
notes: ['notes', 'remark', '备注'],
|
||||
pinned: ['pinned', '置顶'],
|
||||
createdAt: ['createdAt', '创建时间'],
|
||||
} as const;
|
||||
|
||||
function normalizeLookupKey(value: string) {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function matchLookupKey(value: string, aliases: readonly string[]) {
|
||||
const normalized = normalizeLookupKey(value);
|
||||
return aliases.some((alias) => normalizeLookupKey(alias) === normalized);
|
||||
}
|
||||
|
||||
function getMarkdownFieldLabel(key: keyof typeof MARKDOWN_FIELD_LABELS) {
|
||||
return MARKDOWN_FIELD_LABELS[key][0];
|
||||
}
|
||||
|
||||
function parseMarkdownFieldLabel(label: string) {
|
||||
if (matchLookupKey(label, MARKDOWN_FIELD_LABELS.username)) {
|
||||
return 'username';
|
||||
}
|
||||
if (matchLookupKey(label, MARKDOWN_FIELD_LABELS.password)) {
|
||||
return 'password';
|
||||
}
|
||||
if (matchLookupKey(label, MARKDOWN_FIELD_LABELS.url)) {
|
||||
return 'url';
|
||||
}
|
||||
if (matchLookupKey(label, MARKDOWN_FIELD_LABELS.notes)) {
|
||||
return 'notes';
|
||||
}
|
||||
if (matchLookupKey(label, MARKDOWN_FIELD_LABELS.groupTags)) {
|
||||
return 'groupTags';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCsvHeaderIndex(headerMap: Map<string, number>, aliases: readonly string[]) {
|
||||
for (const alias of aliases) {
|
||||
const index = headerMap.get(normalizeLookupKey(alias));
|
||||
if (index !== undefined) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function escapeMarkdownValue(value: string) {
|
||||
return value.replace(/\r\n/g, '\n').trim();
|
||||
}
|
||||
|
||||
function wrapInlineCode(value: string) {
|
||||
const normalized = escapeMarkdownValue(value);
|
||||
return normalized ? `\`${normalized}\`` : '';
|
||||
}
|
||||
|
||||
function wrapMarkdownLink(value: string) {
|
||||
const normalized = escapeMarkdownValue(value);
|
||||
return normalized ? `<${normalized}>` : '';
|
||||
}
|
||||
|
||||
function unwrapMarkdownValue(value: string) {
|
||||
return value.trim().replace(/^`([^`]*)`$/, '$1').replace(/^<([^>]+)>$/, '$1').trim();
|
||||
}
|
||||
|
||||
function escapeCsvValue(value: string) {
|
||||
const normalized = value.replace(/\r\n/g, '\n');
|
||||
if (!/[",\n]/.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return `"${normalized.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function parseCsvRows(text: string) {
|
||||
const normalized = text.replace(/\r\n/g, '\n');
|
||||
const rows: string[][] = [];
|
||||
let currentRow: string[] = [];
|
||||
let currentValue = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let index = 0; index < normalized.length; index += 1) {
|
||||
const char = normalized[index];
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && normalized[index + 1] === '"') {
|
||||
currentValue += '"';
|
||||
index += 1;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ',' && !inQuotes) {
|
||||
currentRow.push(currentValue);
|
||||
currentValue = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\n' && !inQuotes) {
|
||||
currentRow.push(currentValue);
|
||||
rows.push(currentRow);
|
||||
currentRow = [];
|
||||
currentValue = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
currentValue += char;
|
||||
}
|
||||
|
||||
currentRow.push(currentValue);
|
||||
rows.push(currentRow);
|
||||
|
||||
return rows.filter((row) => row.some((value) => value.length > 0));
|
||||
}
|
||||
|
||||
function createEmptyImportedItem(title = ''): Partial<PasswordItem> {
|
||||
return {
|
||||
title,
|
||||
username: '',
|
||||
password: '',
|
||||
urls: [],
|
||||
notes: '',
|
||||
pinned: false,
|
||||
};
|
||||
}
|
||||
|
||||
function getPrimaryUrl(item: Pick<PasswordItem, 'urls'> & { url?: string }) {
|
||||
return item.urls[0] ?? item.url ?? '';
|
||||
}
|
||||
|
||||
function getJoinedUrls(item: Pick<PasswordItem, 'urls'> & { url?: string }) {
|
||||
return (item.urls.length ? item.urls : (item.url ? [item.url] : [])).join('\n');
|
||||
}
|
||||
|
||||
function buildMarkdownItemLines(item: PasswordItem, headingLevel: 2 | 3) {
|
||||
const headingPrefix = '#'.repeat(headingLevel);
|
||||
return [
|
||||
`${headingPrefix} ${escapeMarkdownValue(item.title) || PWM_TEXT.untitledItem}`,
|
||||
'',
|
||||
`- ${getMarkdownFieldLabel('username')}:${wrapInlineCode(item.username)}`,
|
||||
`- ${getMarkdownFieldLabel('password')}:${wrapInlineCode(item.password)}`,
|
||||
`- ${getMarkdownFieldLabel('url')}:${wrapMarkdownLink(getPrimaryUrl(item))}`,
|
||||
`- ${getMarkdownFieldLabel('notes')}:${escapeMarkdownValue(item.notes)}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function formatGroupedMarkdown(groupName: string, items: PasswordItem[]) {
|
||||
const itemBlocks = items.map((item) => buildMarkdownItemLines(item, 3));
|
||||
return [
|
||||
`## ${escapeMarkdownValue(groupName) || PWM_TEXT.untitledGroup}`,
|
||||
'',
|
||||
...itemBlocks,
|
||||
].join('\n\n');
|
||||
}
|
||||
|
||||
function parseGroupedMarkdownGroups(text: string): ParsedMarkdownGroup[] {
|
||||
const lines = text.replace(/\r\n/g, '\n').split('\n');
|
||||
const groups: ParsedMarkdownGroup[] = [];
|
||||
let currentGroup: ParsedMarkdownGroup | null = null;
|
||||
let currentItem: Partial<PasswordItem> | null = null;
|
||||
|
||||
const ensureGroup = () => {
|
||||
if (currentGroup) {
|
||||
return currentGroup;
|
||||
}
|
||||
currentGroup = { groupName: '', items: [] };
|
||||
groups.push(currentGroup);
|
||||
return currentGroup;
|
||||
};
|
||||
|
||||
const startItem = (title: string) => {
|
||||
currentItem = createEmptyImportedItem(title);
|
||||
ensureGroup().items.push(currentItem);
|
||||
};
|
||||
|
||||
lines.forEach((line) => {
|
||||
if (line.startsWith('## ')) {
|
||||
currentGroup = { groupName: line.slice(3).trim(), items: [] };
|
||||
groups.push(currentGroup);
|
||||
currentItem = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith('### ')) {
|
||||
startItem(line.slice(4).trim());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
const match = line.match(/^-\s*([^::]+)[::](.*)$/);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [, rawLabel, rawValue] = match;
|
||||
const field = parseMarkdownFieldLabel(rawLabel);
|
||||
if (!field) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = unwrapMarkdownValue(rawValue);
|
||||
switch (field) {
|
||||
case 'username':
|
||||
currentItem.username = value;
|
||||
break;
|
||||
case 'password':
|
||||
currentItem.password = value;
|
||||
break;
|
||||
case 'url':
|
||||
currentItem.urls = value ? [value] : [];
|
||||
break;
|
||||
case 'notes':
|
||||
currentItem.notes = value;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return groups.filter((group) => group.items.length > 0 || group.groupName);
|
||||
}
|
||||
|
||||
function parseFlatMarkdownItems(text: string) {
|
||||
const normalized = text.replace(/\r\n/g, '\n').trim();
|
||||
if (!normalized) {
|
||||
throw new Error('Invalid markdown payload');
|
||||
}
|
||||
|
||||
const sections = normalized
|
||||
.split(/\n(?:---|___|\*\*\*)\n/g)
|
||||
.map((section) => section.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return sections.map((section) => {
|
||||
const lines = section.split('\n');
|
||||
const heading = lines.find((line) => line.startsWith('## ') || line.startsWith('### '));
|
||||
const item = createEmptyImportedItem(
|
||||
heading?.startsWith('### ')
|
||||
? heading.slice(4).trim()
|
||||
: heading?.slice(3).trim() || '',
|
||||
);
|
||||
|
||||
lines.forEach((line) => {
|
||||
const match = line.match(/^-\s*([^::]+)[::](.*)$/);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [, rawLabel, rawValue] = match;
|
||||
const field = parseMarkdownFieldLabel(rawLabel);
|
||||
if (!field) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = unwrapMarkdownValue(rawValue);
|
||||
switch (field) {
|
||||
case 'username':
|
||||
item.username = value;
|
||||
break;
|
||||
case 'password':
|
||||
item.password = value;
|
||||
break;
|
||||
case 'url':
|
||||
item.urls = value ? [value] : [];
|
||||
break;
|
||||
case 'notes':
|
||||
item.notes = value;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
export function parseImportPayload(text: string): PasswordManagerExportPayload {
|
||||
return JSON.parse(text) as PasswordManagerExportPayload;
|
||||
}
|
||||
|
||||
export function downloadText(filename: string, content: string, mimeType: string) {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export async function downloadJson(filename: string, payload: PasswordManagerExportPayload) {
|
||||
downloadText(filename, JSON.stringify(payload, null, 2), 'application/json');
|
||||
}
|
||||
|
||||
export async function downloadMarkdownItems(filename: string, items: PasswordItem[], groups: PasswordGroup[]) {
|
||||
const groupedContent = groups
|
||||
.filter((group) => items.some((item) => item.groupIds.includes(group.id)))
|
||||
.map((group) => formatGroupedMarkdown(group.name, items.filter((item) => item.groupIds.includes(group.id))))
|
||||
.join('\n\n');
|
||||
|
||||
downloadText(filename, groupedContent, 'text/markdown;charset=utf-8');
|
||||
}
|
||||
|
||||
export async function downloadMarkdownGroups(filename: string, groupsWithItems: Array<{ group: PasswordGroup; items: PasswordItem[] }>) {
|
||||
const content = groupsWithItems
|
||||
.filter(({ items }) => items.length > 0)
|
||||
.map(({ group, items }) => formatGroupedMarkdown(group.name, items))
|
||||
.join('\n\n');
|
||||
|
||||
downloadText(filename, content, 'text/markdown;charset=utf-8');
|
||||
}
|
||||
|
||||
export async function downloadMarkdownGroup(filename: string, group: PasswordGroup, items: PasswordItem[]) {
|
||||
downloadText(filename, formatGroupedMarkdown(group.name, items), 'text/markdown;charset=utf-8');
|
||||
}
|
||||
|
||||
export async function downloadCsvGroups(filename: string, groupsWithItems: Array<{ group: PasswordGroup; items: PasswordItem[] }>) {
|
||||
const header = ['group', 'title', 'username', 'password', 'url', 'notes', 'pinned', 'createdAt'];
|
||||
const rows = groupsWithItems.flatMap(({ group, items }) => items.map((item) => [
|
||||
group.name,
|
||||
item.title,
|
||||
item.username,
|
||||
item.password,
|
||||
getJoinedUrls(item),
|
||||
item.notes,
|
||||
String(item.pinned),
|
||||
String(item.createdAt),
|
||||
].map(escapeCsvValue).join(',')));
|
||||
|
||||
downloadText(filename, [header.join(','), ...rows].join('\n'), 'text/csv;charset=utf-8');
|
||||
}
|
||||
|
||||
export async function downloadCsvGroup(filename: string, group: PasswordGroup, items: PasswordItem[]) {
|
||||
const header = ['group', 'title', 'username', 'password', 'url', 'notes', 'pinned', 'createdAt'];
|
||||
const rows = items.map((item) => [
|
||||
group.name,
|
||||
item.title,
|
||||
item.username,
|
||||
item.password,
|
||||
getJoinedUrls(item),
|
||||
item.notes,
|
||||
String(item.pinned),
|
||||
String(item.createdAt),
|
||||
].map(escapeCsvValue).join(','));
|
||||
|
||||
downloadText(filename, [header.join(','), ...rows].join('\n'), 'text/csv;charset=utf-8');
|
||||
}
|
||||
|
||||
export function parseMarkdownGroup(text: string) {
|
||||
const groups = parseGroupedMarkdownGroups(text).filter((group) => group.items.length > 0);
|
||||
if (!groups.length) {
|
||||
throw new Error('Invalid markdown payload');
|
||||
}
|
||||
|
||||
return {
|
||||
groupName: groups[0].groupName || PWM_TEXT.importGroupFallbackName,
|
||||
items: groups[0].items,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCsvGroup(text: string) {
|
||||
const rows = parseCsvRows(text);
|
||||
if (rows.length < 2) {
|
||||
throw new Error('Invalid csv payload');
|
||||
}
|
||||
|
||||
const headers = rows[0];
|
||||
const headerMap = new Map(headers.map((header, index) => [normalizeLookupKey(header), index]));
|
||||
const groupIndex = getCsvHeaderIndex(headerMap, CSV_HEADER_ALIASES.group);
|
||||
const titleIndex = getCsvHeaderIndex(headerMap, CSV_HEADER_ALIASES.title);
|
||||
const usernameIndex = getCsvHeaderIndex(headerMap, CSV_HEADER_ALIASES.username);
|
||||
const passwordIndex = getCsvHeaderIndex(headerMap, CSV_HEADER_ALIASES.password);
|
||||
const urlIndex = getCsvHeaderIndex(headerMap, CSV_HEADER_ALIASES.url);
|
||||
const notesIndex = getCsvHeaderIndex(headerMap, CSV_HEADER_ALIASES.notes);
|
||||
const pinnedIndex = getCsvHeaderIndex(headerMap, CSV_HEADER_ALIASES.pinned);
|
||||
const createdAtIndex = getCsvHeaderIndex(headerMap, CSV_HEADER_ALIASES.createdAt);
|
||||
|
||||
if (groupIndex === undefined || titleIndex === undefined) {
|
||||
throw new Error('Invalid csv payload');
|
||||
}
|
||||
|
||||
const dataRows = rows.slice(1);
|
||||
return {
|
||||
groupName: dataRows[0]?.[groupIndex]?.trim() || PWM_TEXT.importGroupFallbackName,
|
||||
items: dataRows.map((row) => ({
|
||||
title: row[titleIndex]?.trim() || '',
|
||||
username: row[usernameIndex ?? -1]?.trim() || '',
|
||||
password: row[passwordIndex ?? -1]?.trim() || '',
|
||||
urls: (row[urlIndex ?? -1]?.split(/\r?\n/) ?? []).map((value) => value.trim()).filter(Boolean),
|
||||
notes: row[notesIndex ?? -1]?.trim() || '',
|
||||
pinned: row[pinnedIndex ?? -1]?.trim() === 'true',
|
||||
createdAt: Number(row[createdAtIndex ?? -1]) || undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMarkdownItems(text: string, data: PasswordManagerData, defaultGroupId: string) {
|
||||
const groupedItems = parseGroupedMarkdownGroups(text)
|
||||
.flatMap((group) => group.items)
|
||||
.filter((item) => item.title || item.username || item.password || item.urls?.length || item.notes);
|
||||
|
||||
if (groupedItems.length) {
|
||||
return groupedItems.map((item) => ({
|
||||
...createEmptyImportedItem(item.title || ''),
|
||||
...item,
|
||||
}));
|
||||
}
|
||||
|
||||
return parseFlatMarkdownItems(text).map((item) => ({
|
||||
...createEmptyImportedItem(item.title || ''),
|
||||
...item,
|
||||
}));
|
||||
}
|
||||
56
src/data/trash-store.ts
Normal file
56
src/data/trash-store.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { normalizePath, type App } from 'obsidian';
|
||||
import type { DeletedPasswordItem, PasswordItem, PasswordTrashData } from '../util/types';
|
||||
|
||||
export class PasswordTrashStore {
|
||||
private readonly trashFilePath: string;
|
||||
|
||||
constructor(
|
||||
private readonly app: App,
|
||||
private readonly pluginId: string,
|
||||
) {
|
||||
this.trashFilePath = normalizePath(`${this.app.vault.configDir}/plugins/${pluginId}/trash.json`);
|
||||
}
|
||||
|
||||
async moveItemToTrash(item: PasswordItem) {
|
||||
const trash = await this.load();
|
||||
const deletedItem: DeletedPasswordItem = {
|
||||
...structuredClone(item),
|
||||
deletedAt: Date.now(),
|
||||
};
|
||||
trash.items.unshift(deletedItem);
|
||||
await this.save(trash);
|
||||
}
|
||||
|
||||
async load(): Promise<PasswordTrashData> {
|
||||
const adapter = this.app.vault.adapter;
|
||||
if (!(await adapter.exists(this.trashFilePath))) {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await adapter.read(this.trashFilePath);
|
||||
const parsed = JSON.parse(content) as Partial<PasswordTrashData>;
|
||||
return {
|
||||
items: Array.isArray(parsed?.items)
|
||||
? parsed.items
|
||||
.filter((item): item is DeletedPasswordItem => !!item && typeof item === 'object' && typeof item.id === 'string')
|
||||
.map((item) => ({
|
||||
...item,
|
||||
deletedAt: typeof item.deletedAt === 'number' ? item.deletedAt : Date.now(),
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
} catch {
|
||||
return { items: [] };
|
||||
}
|
||||
}
|
||||
|
||||
private async save(trash: PasswordTrashData) {
|
||||
const adapter = this.app.vault.adapter;
|
||||
const pluginDir = normalizePath(`${this.app.vault.configDir}/plugins/${this.pluginId}`);
|
||||
if (!(await adapter.exists(pluginDir))) {
|
||||
await adapter.mkdir(pluginDir);
|
||||
}
|
||||
await adapter.write(this.trashFilePath, JSON.stringify(trash, null, 2));
|
||||
}
|
||||
}
|
||||
202
src/lang/en.ts
Normal file
202
src/lang/en.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
export const PWM_TEXT = {
|
||||
openManager: 'Open Password Manager',
|
||||
modalTitle: 'Password Manager',
|
||||
settingsTitle: 'General Settings',
|
||||
modalSettingsTitle: 'Interface Settings',
|
||||
modalWidthSetting: 'Manager Width',
|
||||
modalWidthSettingDesc: 'Enter width values separated by commas. The smallest value will be used via min(...), for example: 92vw, 1200px.',
|
||||
modalHeightSetting: 'Manager Height',
|
||||
modalHeightSettingDesc: 'Enter height values separated by commas. The smallest value will be used via min(...), for example: 80vh, 800px.',
|
||||
columnRatioSetting: 'Three-Column Ratio',
|
||||
columnRatioSettingDesc: 'Enter three-column ratios separated by commas, for example: 1,1,2. When locked, the layout follows this ratio. After unlocking, you can drag to resize and the ratio will be written back.',
|
||||
columnRatioLock: 'Lock Ratio',
|
||||
columnRatioUnlock: 'Unlock Ratio',
|
||||
columnRatioReset: 'Reset Ratio',
|
||||
encryptionSettingsTitle: 'Encryption Settings',
|
||||
encryptionSettingsTitleDesc: 'When enabled, the entire password library is stored encrypted with your encryption password. You must set and confirm the password the first time, and later verification follows the configured mode when opening or disabling it.',
|
||||
encryptionEnabledSetting: 'Enable Encryption Password',
|
||||
encryptionEnabledSettingDesc: 'When enabled, the entire data.json will be encrypted with the encryption password. Disabled by default.',
|
||||
encryptionUnlockModeSetting: 'Recheck Mode',
|
||||
encryptionUnlockModeSettingDesc: 'Controls when the encryption password must be re-verified when opening the password manager.',
|
||||
encryptionUnlockModeSession: 'Verify only once after launch',
|
||||
encryptionUnlockModeInterval: 'Verify after a fixed interval',
|
||||
encryptionUnlockModeAlways: 'Verify every time it opens',
|
||||
persistEncryptionPasswordSetting: 'Save encryption password to plugin settings',
|
||||
persistEncryptionPasswordSettingDesc: 'When enabled, the plaintext encryption password is written into plugin settings and later unlocks automatically without prompting again.',
|
||||
persistEncryptionPasswordEnabledNotice: 'Encryption password saved to plugin settings',
|
||||
persistEncryptionPasswordDisabledNotice: 'Saved encryption password cleared from plugin settings',
|
||||
encryptionRecheckIntervalSetting: 'Recheck Interval (minutes)',
|
||||
encryptionRecheckIntervalSettingDesc: 'Only applies in fixed-interval mode. Default: 30 minutes.',
|
||||
encryptionRecheckIntervalReset: 'Reset to 30 minutes',
|
||||
enableEncryptionPassword: 'Set Encryption Password',
|
||||
disableEncryptionPassword: 'Verify Password to Disable Encryption',
|
||||
changeEncryptionPassword: 'Change Encryption Password',
|
||||
currentEncryptionPassword: 'Encryption Password',
|
||||
newEncryptionPassword: 'New Encryption Password',
|
||||
confirmEncryptionPassword: 'Confirm Encryption Password',
|
||||
changeEncryptionPasswordAction: 'Change Password',
|
||||
encryptionPasswordMismatch: 'The two passwords do not match',
|
||||
encryptionPasswordInvalid: 'Incorrect encryption password',
|
||||
encryptionEnabledNotice: 'Data encryption enabled',
|
||||
encryptionDisabledNotice: 'Data encryption disabled',
|
||||
encryptionPasswordChanged: 'Encryption password updated',
|
||||
encryptionNotConfigured: 'No encryption password is configured',
|
||||
encryptionRequiredNotice: 'Encryption verification is required first',
|
||||
unlockManagerTitle: 'Enter Encryption Password',
|
||||
backupSettingsTitle: 'Backup Settings',
|
||||
autoBackupEnabledSetting: 'Enable Auto Backup',
|
||||
autoBackupEnabledSettingDesc: 'When password data is saved, JSON snapshots are generated automatically based on the backup interval. Both automatic and manual backups are stored under .password/backup/ in the vault root. Only the filename prefixes differ.',
|
||||
autoBackupCountSetting: 'Backup Count',
|
||||
autoBackupCountSettingDesc: 'Limits how many automatic backups to keep. Range: 0~50. 0 means unlimited. Default: 20.',
|
||||
autoBackupCountReset: 'Reset to 20',
|
||||
autoBackupIntervalSetting: 'Backup Interval (minutes)',
|
||||
autoBackupIntervalSettingDesc: 'The minimum interval between automatic backups. Range: 1~60 minutes. Default: 5 minutes.',
|
||||
trashRetentionDaysSetting: 'Trash Retention Days',
|
||||
trashRetentionDaysSettingDesc: 'Maximum days items are kept in the trash. Range: 0~365. 0 means unlimited. Default: 150 days. Expired items are cleaned up automatically when saving data.',
|
||||
trashRetentionDaysReset: 'Reset to 150 days',
|
||||
autoBackupIntervalReset: 'Reset to 5 minutes',
|
||||
createBackupNow: 'Create Backup Now',
|
||||
createBackupNowDesc: 'Create a JSON snapshot of the current password library immediately and store it in .password/backup/. This is not limited by the auto backup interval.',
|
||||
openStorageFolder: 'Open Data Folder',
|
||||
openStorageFolderFailed: 'Failed to open data folder',
|
||||
backupCreated: 'Backup created',
|
||||
backupCreateFailed: 'Failed to create backup',
|
||||
confirmDeleteSetting: 'Show confirmation before deletion',
|
||||
confirmDeleteSettingDesc: 'When disabled, groups or items will be deleted directly.',
|
||||
copyFormatSetting: 'Copy Format',
|
||||
copyFormatSettingDesc: 'Set the format used when copying password information to the clipboard.',
|
||||
copyFormatMarkdown: 'Markdown Format',
|
||||
copyFormatPlainText: 'Plain Text Format',
|
||||
copyFormatCallout: 'Callout Format',
|
||||
storageFolderSetting: 'Data Storage Folder',
|
||||
storageFolderSettingDesc: 'Password data will be saved in this folder under the vault root, including data.json and backup/. The default folder is .password.',
|
||||
itemDisplaySetting: 'Item Fields',
|
||||
itemDisplayUsername: 'Username',
|
||||
itemDisplayUrl: 'Links',
|
||||
itemDisplayGroupTags: 'Group Tags',
|
||||
itemDisplayNotes: 'Notes',
|
||||
groups: 'Groups',
|
||||
items: 'Items',
|
||||
details: 'Password Details',
|
||||
tags: 'Group Tags',
|
||||
noItem: 'Please select or create an item.',
|
||||
noItemsInGroup: 'There are no items in the current group.',
|
||||
noSearchResults: 'No matching items.',
|
||||
noOpenUrl: 'There is no link to open',
|
||||
copiedUsername: 'Username copied',
|
||||
copiedPassword: 'Password copied',
|
||||
copiedPasswordInfo: 'Password info copied',
|
||||
copiedUrl: 'Link copied',
|
||||
copiedNotes: 'Notes copied',
|
||||
copiedItem: 'Item copied',
|
||||
copyUsername: 'Copy Username',
|
||||
copyPassword: 'Copy Password',
|
||||
copyUrl: 'Copy Link',
|
||||
copyNotes: 'Copy Notes',
|
||||
copyItem: 'Copy Item',
|
||||
pinItem: 'Pin Item',
|
||||
unpinItem: 'Unpin Item',
|
||||
copyPasswordInfo: 'Copy Password Info',
|
||||
pasteIntoFocusedField: 'Parse from Clipboard and Fill',
|
||||
savePasswordInfo: 'Save Password Info',
|
||||
savedPasswordInfo: 'Password info saved',
|
||||
focusFieldToPaste: 'Focus the field to paste into first',
|
||||
keepOneGroup: 'At least one group must remain',
|
||||
keepOneItemGroup: 'An item must belong to at least one group',
|
||||
selectGroupFirst: 'Please select a group first',
|
||||
selectGroupTag: 'Select a group to add',
|
||||
untitledItem: 'Untitled Item',
|
||||
emptyUsername: 'No username',
|
||||
emptyUrl: 'No link',
|
||||
title: 'Title',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
url: 'Link',
|
||||
notes: 'Notes',
|
||||
searchPlaceholder: 'Search title, group, username, link, or notes',
|
||||
clearSearch: 'Clear search',
|
||||
addLink: 'Add Link',
|
||||
addGroup: 'Add Group',
|
||||
addItem: 'Add Item',
|
||||
removeLink: 'Remove Link',
|
||||
emptyLinks: 'No links',
|
||||
deleteGroup: 'Delete Current Group',
|
||||
deleteItem: 'Delete Current Item',
|
||||
deleteItemFromDetails: 'Delete Current Password Info',
|
||||
openTrash: 'Open Trash',
|
||||
switchManager: 'Switch Manager',
|
||||
openPluginSettings: 'Open Plugin Settings',
|
||||
addTag: 'Add Group Tag',
|
||||
removeTag: 'Remove Group Tag',
|
||||
noTags: 'No group tags yet',
|
||||
editGroupName: 'Double-click to rename group',
|
||||
invalidGroupName: 'Invalid group name',
|
||||
invalidItemTitle: 'Invalid title',
|
||||
togglePassword: 'Show or hide password',
|
||||
showPassword: 'Show Password',
|
||||
hidePassword: 'Hide Password',
|
||||
openUrl: 'Open Link',
|
||||
sortGroups: 'Sort Groups',
|
||||
sortItems: 'Sort Items',
|
||||
sortCustom: 'Custom Sort',
|
||||
sortByNameAsc: 'Name Ascending',
|
||||
sortByNameDesc: 'Name Descending',
|
||||
sortByCreatedAsc: 'Created Time Ascending',
|
||||
sortByCreatedDesc: 'Created Time Descending',
|
||||
sortByUpdatedAsc: 'Updated Time Ascending',
|
||||
sortByUpdatedDesc: 'Updated Time Descending',
|
||||
sortByDeletedAsc: 'Deleted Time Ascending',
|
||||
sortByDeletedDesc: 'Deleted Time Descending',
|
||||
sortByItemCountAsc: 'Item Count Ascending',
|
||||
sortByItemCountDesc: 'Item Count Descending',
|
||||
dragToSort: 'Drag to sort',
|
||||
exportLibrary: 'Export Entire Library',
|
||||
importLibrary: 'Import Entire Library',
|
||||
exportGroup: 'Export Current Group',
|
||||
importGroup: 'Import Group',
|
||||
exportItem: 'Export Current Item',
|
||||
importItem: 'Import Item',
|
||||
exportSelectedItems: 'Export Selected Items',
|
||||
importItems: 'Import Items to Current Group',
|
||||
restoreItem: 'Restore Selected Items',
|
||||
exportFormatJson: 'JSON Format',
|
||||
exportFormatMarkdown: 'Markdown Format',
|
||||
exportFormatCsv: 'CSV Format',
|
||||
importSuccess: 'Import completed',
|
||||
exportSuccess: 'Export completed',
|
||||
importFailed: 'Import failed: invalid file format',
|
||||
confirmDeleteGroupTitle: 'Confirm Group Deletion',
|
||||
confirmDeleteGroupMessage: 'This will delete 0 items.',
|
||||
confirmDeleteItemTitle: 'Confirm Item Deletion',
|
||||
confirmDeleteItemMessage: 'This will delete 0 items.',
|
||||
defaultGroupName: 'Default Group',
|
||||
defaultItemTitle: 'Default Title',
|
||||
defaultItemUsername: 'Default Username',
|
||||
generatedGroupName: 'Group',
|
||||
generatedNewGroupName: 'New Group',
|
||||
generatedNewItemTitle: 'New Item',
|
||||
untitledGroup: 'Untitled Group',
|
||||
groupNotFound: 'Group not found',
|
||||
invalidGroupNameWithReason: 'Invalid group name: {reason}',
|
||||
itemNotFound: 'Item not found',
|
||||
invalidItemTitleWithReason: 'Invalid title: {reason}',
|
||||
fileNameEmpty: 'Name cannot be empty',
|
||||
fileNameInvalidChars: 'Name cannot contain these characters: < > : " / \\ | ? *',
|
||||
fileNameTrailingChars: 'Name cannot end with a space or period',
|
||||
fileNameWindowsReserved: 'Name cannot use a Windows reserved word',
|
||||
copyFieldTitle: 'Title',
|
||||
copyFieldUsername: 'Username',
|
||||
copyFieldPassword: 'Password',
|
||||
copyFieldUrl: 'Link',
|
||||
copyFieldNotes: 'Notes',
|
||||
copyFieldGroupTags: 'Group Tags',
|
||||
deleteGroupPermanentMessage: 'This will permanently delete {count} items.',
|
||||
deleteGroupTrashMessage: 'This will move {count} items to the trash.',
|
||||
deleteItemPermanentMessage: 'This will permanently delete {count} items.',
|
||||
deleteItemMixedMessage: 'This will permanently delete {directCount} empty title-only items, and move another {trashCount} items to the trash.',
|
||||
deleteItemDirectMessage: 'This will permanently delete {count} empty title-only items without moving them to the trash.',
|
||||
deleteItemTrashMessage: 'This will move {count} items to the trash.',
|
||||
importGroupFallbackName: 'Imported Group',
|
||||
confirm: 'Confirm',
|
||||
cancel: 'Cancel',
|
||||
} as const;
|
||||
29
src/lang/index.ts
Normal file
29
src/lang/index.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { PWM_TEXT as EN_TEXT } from './en';
|
||||
import { PWM_TEXT as ZH_CN_TEXT } from './zh-cn';
|
||||
|
||||
type PwmText = typeof EN_TEXT;
|
||||
|
||||
const resolveLocale = () => {
|
||||
const language = (
|
||||
globalThis.localStorage?.getItem('language')
|
||||
|| globalThis.navigator?.language
|
||||
|| 'zh-CN'
|
||||
).toLowerCase();
|
||||
|
||||
if (language.startsWith('en')) {
|
||||
return 'en';
|
||||
}
|
||||
|
||||
return 'zh-cn';
|
||||
};
|
||||
|
||||
export const PWM_TEXT = resolveLocale() === 'en' ? EN_TEXT : ZH_CN_TEXT;
|
||||
|
||||
export function formatPWMText(template: string, params: Record<string, string | number>) {
|
||||
return Object.entries(params).reduce(
|
||||
(result, [key, value]) => result.split(`{${key}}`).join(String(value)),
|
||||
template,
|
||||
);
|
||||
}
|
||||
|
||||
export type { PwmText };
|
||||
202
src/lang/zh-cn.ts
Normal file
202
src/lang/zh-cn.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
export const PWM_TEXT = {
|
||||
openManager: '打开密码管理器',
|
||||
modalTitle: '密码管理器',
|
||||
settingsTitle: '常规设置',
|
||||
modalSettingsTitle: '界面设置',
|
||||
modalWidthSetting: '管理器宽度',
|
||||
modalWidthSettingDesc: '输入宽度参数,多个值用英文逗号分隔,并按 min(...) 取最小值,例如 92vw, 1200px。',
|
||||
modalHeightSetting: '管理器高度',
|
||||
modalHeightSettingDesc: '输入高度参数,多个值用英文逗号分隔,并按 min(...) 取最小值,例如 80vh, 800px。',
|
||||
columnRatioSetting: '三栏比例',
|
||||
columnRatioSettingDesc: '输入三栏比例,使用英文逗号分隔,例如 1,1,2。锁定时按该比例布局,解锁后可拖动修改并回写比例。',
|
||||
columnRatioLock: '锁定比例',
|
||||
columnRatioUnlock: '解锁比例',
|
||||
columnRatioReset: '重置比例',
|
||||
encryptionSettingsTitle: '加密设置',
|
||||
encryptionSettingsTitleDesc: '启用后会使用你设置的加密密码对整个密码库进行加密存储;首次启用需要输入并确认密码,后续打开或关闭时按设定方式校验。',
|
||||
encryptionEnabledSetting: '启用加密密码',
|
||||
encryptionEnabledSettingDesc: '启用后会使用加密密码对整个 data.json 进行加密。默认关闭。',
|
||||
encryptionUnlockModeSetting: '重新检验方式',
|
||||
encryptionUnlockModeSettingDesc: '控制打开密码管理器时需要重新验证加密密码的时机。',
|
||||
encryptionUnlockModeSession: '启动后仅校验一次',
|
||||
encryptionUnlockModeInterval: '固定时间后校验',
|
||||
encryptionUnlockModeAlways: '每次打开都校验',
|
||||
persistEncryptionPasswordSetting: '将加密密码保存到插件设置',
|
||||
persistEncryptionPasswordSettingDesc: '开启后会将明文加密密码写入插件设置,之后会自动解锁,不再弹出校验。',
|
||||
persistEncryptionPasswordEnabledNotice: '已保存加密密码到插件设置',
|
||||
persistEncryptionPasswordDisabledNotice: '已清除插件设置中的加密密码',
|
||||
encryptionRecheckIntervalSetting: '重新校验间隔(分钟)',
|
||||
encryptionRecheckIntervalSettingDesc: '仅在固定时间后校验模式下生效,默认 30 分钟。',
|
||||
encryptionRecheckIntervalReset: '重置为 30 分钟',
|
||||
enableEncryptionPassword: '设置加密密码',
|
||||
disableEncryptionPassword: '关闭加密需要验证密码',
|
||||
changeEncryptionPassword: '修改加密密码',
|
||||
currentEncryptionPassword: '原始加密密码',
|
||||
newEncryptionPassword: '新的加密密码',
|
||||
confirmEncryptionPassword: '确认加密密码',
|
||||
changeEncryptionPasswordAction: '修改密码',
|
||||
encryptionPasswordMismatch: '两次输入的密码不一致',
|
||||
encryptionPasswordInvalid: '输入的加密密码错误',
|
||||
encryptionEnabledNotice: '已启用数据加密',
|
||||
encryptionDisabledNotice: '已关闭数据加密',
|
||||
encryptionPasswordChanged: '加密密码已更新',
|
||||
encryptionNotConfigured: '当前未配置加密密码',
|
||||
encryptionRequiredNotice: '需要先完成加密验证',
|
||||
unlockManagerTitle: '输入加密密码',
|
||||
backupSettingsTitle: '备份设置',
|
||||
autoBackupEnabledSetting: '启用自动备份',
|
||||
autoBackupEnabledSettingDesc: '保存密码数据时,按备份间隔自动生成 JSON 快照。自动备份与手动备份都保存在根目录下 .password/backup/ 中,仅文件名前缀不同。',
|
||||
autoBackupCountSetting: '备份数量',
|
||||
autoBackupCountSettingDesc: '限制自动备份保留数量,范围 0~50。0 表示不设限制,默认 20。',
|
||||
autoBackupCountReset: '重置为 20',
|
||||
autoBackupIntervalSetting: '备份间隔(分钟)',
|
||||
autoBackupIntervalSettingDesc: '两次自动备份之间的最小间隔,范围 1~60 分钟,默认 5 分钟。',
|
||||
trashRetentionDaysSetting: '回收站保留天数',
|
||||
trashRetentionDaysSettingDesc: '回收站中条目最多保留的天数,范围 0~365 天。0 表示不限制,默认 150 天。保存数据时会自动清理过期条目。',
|
||||
trashRetentionDaysReset: '重置为 150 天',
|
||||
autoBackupIntervalReset: '重置为 5 分钟',
|
||||
createBackupNow: '立即创建备份',
|
||||
createBackupNowDesc: '立即生成一份当前密码库的 JSON 快照,保存在 .password/backup/ 中,不受自动备份间隔限制。',
|
||||
openStorageFolder: '打开数据目录',
|
||||
openStorageFolderFailed: '打开数据目录失败',
|
||||
backupCreated: '备份已创建',
|
||||
backupCreateFailed: '创建备份失败',
|
||||
confirmDeleteSetting: '删除前显示确认提示',
|
||||
confirmDeleteSettingDesc: '关闭后将直接删除分组或条目。',
|
||||
copyFormatSetting: '复制格式',
|
||||
copyFormatSettingDesc: '设置密码信息复制到剪贴板时使用的内容格式。',
|
||||
copyFormatMarkdown: 'Markdown 格式',
|
||||
copyFormatPlainText: '纯文本格式',
|
||||
copyFormatCallout: 'Callout 格式',
|
||||
storageFolderSetting: '数据存储文件夹',
|
||||
storageFolderSettingDesc: '密码数据将保存到笔记库根目录下的该文件夹中,包含 data.json 和 backup/。默认目录为 .password。',
|
||||
itemDisplaySetting: '条目属性',
|
||||
itemDisplayUsername: '用户名',
|
||||
itemDisplayUrl: '链接',
|
||||
itemDisplayGroupTags: '组标签',
|
||||
itemDisplayNotes: '备注',
|
||||
groups: '分组',
|
||||
items: '条目',
|
||||
details: '密码信息',
|
||||
tags: '组标签',
|
||||
noItem: '请选择或新建一个条目。',
|
||||
noItemsInGroup: '当前分组还没有条目。',
|
||||
noSearchResults: '没有匹配的条目。',
|
||||
noOpenUrl: '当前没有可打开的链接',
|
||||
copiedUsername: '用户名已复制',
|
||||
copiedPassword: '密码已复制',
|
||||
copiedPasswordInfo: '密码信息已复制',
|
||||
copiedUrl: '链接已复制',
|
||||
copiedNotes: '备注已复制',
|
||||
copiedItem: '条目已复制',
|
||||
copyUsername: '复制用户名',
|
||||
copyPassword: '复制密码',
|
||||
copyUrl: '复制链接',
|
||||
copyNotes: '复制备注',
|
||||
copyItem: '复制条目',
|
||||
pinItem: '置顶条目',
|
||||
unpinItem: '取消置顶',
|
||||
copyPasswordInfo: '复制密码信息',
|
||||
pasteIntoFocusedField: '从剪贴板解析并填充',
|
||||
savePasswordInfo: '保存密码信息',
|
||||
savedPasswordInfo: '密码信息已保存',
|
||||
focusFieldToPaste: '请先聚焦要粘贴的字段',
|
||||
keepOneGroup: '至少保留一个分组',
|
||||
keepOneItemGroup: '条目至少保留一个分组',
|
||||
selectGroupFirst: '请先选择分组',
|
||||
selectGroupTag: '选择要添加的分组',
|
||||
untitledItem: '未命名条目',
|
||||
emptyUsername: '未填写用户名',
|
||||
emptyUrl: '未填写链接',
|
||||
title: '标题',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
url: '链接',
|
||||
notes: '备注',
|
||||
searchPlaceholder: '搜索标题、分组、用户名、链接或备注',
|
||||
clearSearch: '清空搜索',
|
||||
addLink: '添加链接',
|
||||
addGroup: '添加分组',
|
||||
addItem: '添加条目',
|
||||
removeLink: '删除链接',
|
||||
emptyLinks: '未填写链接',
|
||||
deleteGroup: '删除当前分组',
|
||||
deleteItem: '删除当前条目',
|
||||
deleteItemFromDetails: '删除当前密码信息',
|
||||
openTrash: '打开回收站',
|
||||
switchManager: '切换管理器',
|
||||
openPluginSettings: '打开插件设置',
|
||||
addTag: '添加组标签',
|
||||
removeTag: '移除组标签',
|
||||
noTags: '暂无组标签',
|
||||
editGroupName: '双击重命名分组',
|
||||
invalidGroupName: '分组名称不符合要求',
|
||||
invalidItemTitle: '标题不符合要求',
|
||||
togglePassword: '显示或隐藏密码',
|
||||
showPassword: '显示密码',
|
||||
hidePassword: '隐藏密码',
|
||||
openUrl: '打开链接',
|
||||
sortGroups: '分组排序',
|
||||
sortItems: '条目排序',
|
||||
sortCustom: '自定义排序',
|
||||
sortByNameAsc: '按名称升序',
|
||||
sortByNameDesc: '按名称降序',
|
||||
sortByCreatedAsc: '按创建时间升序',
|
||||
sortByCreatedDesc: '按创建时间降序',
|
||||
sortByUpdatedAsc: '按修改时间升序',
|
||||
sortByUpdatedDesc: '按修改时间降序',
|
||||
sortByDeletedAsc: '按删除时间升序',
|
||||
sortByDeletedDesc: '按删除时间降序',
|
||||
sortByItemCountAsc: '按条目数量升序',
|
||||
sortByItemCountDesc: '按条目数量降序',
|
||||
dragToSort: '拖拽排序',
|
||||
exportLibrary: '导出全部库',
|
||||
importLibrary: '导入全部库',
|
||||
exportGroup: '导出当前分组',
|
||||
importGroup: '导入分组',
|
||||
exportItem: '导出当前条目',
|
||||
importItem: '导入条目',
|
||||
exportSelectedItems: '导出选中条目',
|
||||
importItems: '导入条目到当前分组',
|
||||
restoreItem: '恢复选中条目',
|
||||
exportFormatJson: 'JSON 格式',
|
||||
exportFormatMarkdown: 'Markdown 格式',
|
||||
exportFormatCsv: 'CSV 格式',
|
||||
importSuccess: '导入完成',
|
||||
exportSuccess: '导出完成',
|
||||
importFailed: '导入失败,文件格式无效',
|
||||
confirmDeleteGroupTitle: '确认删除分组',
|
||||
confirmDeleteGroupMessage: '本次删除将删除 0 个条目。',
|
||||
confirmDeleteItemTitle: '确认删除条目',
|
||||
confirmDeleteItemMessage: '本次删除将删除 0 个条目。',
|
||||
defaultGroupName: '默认分组',
|
||||
defaultItemTitle: '默认标题',
|
||||
defaultItemUsername: '默认用户名',
|
||||
generatedGroupName: '分组',
|
||||
generatedNewGroupName: '新分组',
|
||||
generatedNewItemTitle: '新条目',
|
||||
untitledGroup: '未命名分组',
|
||||
groupNotFound: '分组不存在',
|
||||
invalidGroupNameWithReason: '分组名称无效:{reason}',
|
||||
itemNotFound: '条目不存在',
|
||||
invalidItemTitleWithReason: '标题无效:{reason}',
|
||||
fileNameEmpty: '名称不能为空',
|
||||
fileNameInvalidChars: '名称不能包含以下字符:< > : " / \\ | ? *',
|
||||
fileNameTrailingChars: '名称不能以空格或句点结尾',
|
||||
fileNameWindowsReserved: '名称不能使用 Windows 保留字',
|
||||
copyFieldTitle: '标题',
|
||||
copyFieldUsername: '用户名',
|
||||
copyFieldPassword: '密码',
|
||||
copyFieldUrl: '链接',
|
||||
copyFieldNotes: '备注',
|
||||
copyFieldGroupTags: '组标签',
|
||||
deleteGroupPermanentMessage: '本次删除会永久删除 {count} 个条目。',
|
||||
deleteGroupTrashMessage: '本次删除会将 {count} 个条目移动到回收站。',
|
||||
deleteItemPermanentMessage: '本次删除会永久删除 {count} 个条目。',
|
||||
deleteItemMixedMessage: '本次删除会直接删除 {directCount} 个仅含标题的空条目,另外 {trashCount} 个条目会移动到回收站。',
|
||||
deleteItemDirectMessage: '本次删除会直接删除 {count} 个仅含标题的空条目,不会进入回收站。',
|
||||
deleteItemTrashMessage: '本次删除会将 {count} 个条目移动到回收站。',
|
||||
importGroupFallbackName: '导入分组',
|
||||
confirm: '确认',
|
||||
cancel: '取消',
|
||||
} as const;
|
||||
303
src/main.ts
Normal file
303
src/main.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import { Plugin, type App } from 'obsidian';
|
||||
import { PasswordStorageStore } from './data/storage-store';
|
||||
import { PWM_TEXT } from './lang';
|
||||
import { PasswordPluginContext } from './services/plugin-context';
|
||||
import { PasswordEncryptionService } from './services/encryption-service';
|
||||
import { PasswordTransferService } from './services/transfer-service';
|
||||
import { createIconButton } from './services/ui-helpers';
|
||||
import type { PasswordItem, PasswordManagerData, PwmSortMode } from './util/types';
|
||||
import type { PasswordManagerSettings, PasswordPluginConfig } from './settings';
|
||||
import { PasswordManagerModal } from './ui/password-manager-modal';
|
||||
import { PasswordManagerSettingTab } from './settings';
|
||||
|
||||
export default class PasswordManagerPlugin extends Plugin {
|
||||
private storageStore!: PasswordStorageStore;
|
||||
private context!: PasswordPluginContext;
|
||||
private encryptionService!: PasswordEncryptionService;
|
||||
private transferService!: PasswordTransferService;
|
||||
private readonly managerModals = new Set<PasswordManagerModal>();
|
||||
|
||||
get data(): PasswordManagerData {
|
||||
return this.context.data;
|
||||
}
|
||||
|
||||
get pluginConfig(): PasswordPluginConfig {
|
||||
return this.context.pluginConfig;
|
||||
}
|
||||
|
||||
async onload() {
|
||||
this.initializeDependencies();
|
||||
await this.context.loadPluginData();
|
||||
this.registerEntrypoints();
|
||||
}
|
||||
|
||||
private initializeDependencies() {
|
||||
this.storageStore = new PasswordStorageStore(this.app);
|
||||
this.context = new PasswordPluginContext(
|
||||
this.storageStore,
|
||||
{
|
||||
loadLegacyData: () => this.loadData(),
|
||||
savePluginConfig: (config) => this.saveData(config),
|
||||
},
|
||||
this.manifest.id,
|
||||
);
|
||||
this.encryptionService = new PasswordEncryptionService(this.app, this.context);
|
||||
this.transferService = new PasswordTransferService(this.app, this.context);
|
||||
this.context.setEncryptionWriteGuard(() => this.encryptionService.ensureEncryptionWriteAccess());
|
||||
}
|
||||
|
||||
private registerEntrypoints() {
|
||||
this.addRibbonIcon('lock', PWM_TEXT.openManager, () => {
|
||||
void this.openManager();
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'open-password-manager',
|
||||
name: PWM_TEXT.openManager,
|
||||
callback: () => {
|
||||
void this.openManager();
|
||||
},
|
||||
});
|
||||
|
||||
this.addSettingTab(new PasswordManagerSettingTab(this.app, this));
|
||||
}
|
||||
|
||||
async openManager() {
|
||||
const allowed = await this.encryptionService.ensureEncryptionAccess();
|
||||
if (!allowed) {
|
||||
return;
|
||||
}
|
||||
new PasswordManagerModal(this.app, this).open();
|
||||
}
|
||||
|
||||
async openTrash() {
|
||||
new PasswordManagerModal(this.app, this, { mode: 'trash' }).open();
|
||||
}
|
||||
|
||||
openSettings() {
|
||||
const setting = (this.app as App & {
|
||||
setting: {
|
||||
open: () => void;
|
||||
openTabById: (id: string) => void;
|
||||
};
|
||||
}).setting;
|
||||
setting.open();
|
||||
setting.openTabById(this.manifest.id);
|
||||
}
|
||||
|
||||
registerManagerModal(modal: PasswordManagerModal) {
|
||||
this.managerModals.add(modal);
|
||||
}
|
||||
|
||||
unregisterManagerModal(modal: PasswordManagerModal) {
|
||||
this.managerModals.delete(modal);
|
||||
}
|
||||
|
||||
refreshManagerLayouts() {
|
||||
this.managerModals.forEach((modal) => modal.refreshLayout());
|
||||
}
|
||||
|
||||
createIconButton(container: HTMLElement, icon: string, label: string, onClick: () => void | Promise<void>) {
|
||||
return createIconButton(container, icon, label, onClick);
|
||||
}
|
||||
|
||||
async savePluginData() {
|
||||
return this.context.savePluginData();
|
||||
}
|
||||
|
||||
async savePluginConfig() {
|
||||
return this.context.savePluginConfig();
|
||||
}
|
||||
|
||||
async applyTrashRetentionPolicy() {
|
||||
return this.context.applyTrashRetentionPolicy();
|
||||
}
|
||||
|
||||
async createBackupNow() {
|
||||
return this.context.createBackupNow();
|
||||
}
|
||||
|
||||
async enableEncryption() {
|
||||
return this.encryptionService.enableEncryption();
|
||||
}
|
||||
|
||||
async disableEncryption() {
|
||||
return this.encryptionService.disableEncryption();
|
||||
}
|
||||
|
||||
async changeEncryptionPassword() {
|
||||
return this.encryptionService.changeEncryptionPassword();
|
||||
}
|
||||
|
||||
async setPersistEncryptionPassword(enabled: boolean) {
|
||||
return this.encryptionService.setPersistEncryptionPassword(enabled);
|
||||
}
|
||||
|
||||
async openStorageFolder() {
|
||||
return this.encryptionService.openStorageFolder();
|
||||
}
|
||||
|
||||
async ensureWriteAccess() {
|
||||
return this.encryptionService.ensureEncryptionWriteAccess();
|
||||
}
|
||||
|
||||
async exportLibrary() {
|
||||
return this.transferService.exportLibrary();
|
||||
}
|
||||
|
||||
async exportGroup(groupId: string, format: 'json' | 'markdown' | 'csv' = 'json') {
|
||||
return this.transferService.exportGroup(groupId, format);
|
||||
}
|
||||
|
||||
async exportGroups(groupIds: string[], format: 'json' | 'markdown' | 'csv' = 'json') {
|
||||
return this.transferService.exportGroups(groupIds, format);
|
||||
}
|
||||
|
||||
async exportItem(itemId: string) {
|
||||
return this.transferService.exportItem(itemId);
|
||||
}
|
||||
|
||||
async exportItems(itemIds: string[], format: 'json' | 'markdown') {
|
||||
return this.transferService.exportItems(itemIds, format);
|
||||
}
|
||||
|
||||
async importLibraryFromText(text: string) {
|
||||
return this.transferService.importLibraryFromText(text);
|
||||
}
|
||||
|
||||
async importGroupFromText(text: string) {
|
||||
return this.transferService.importGroupFromText(text);
|
||||
}
|
||||
|
||||
async importItemFromText(text: string, groupId: string) {
|
||||
return this.transferService.importItemFromText(text, groupId);
|
||||
}
|
||||
|
||||
async importItemsFromText(text: string, groupId: string) {
|
||||
return this.transferService.importItemsFromText(text, groupId);
|
||||
}
|
||||
|
||||
updatePluginConfig(patch: Partial<PasswordPluginConfig>) {
|
||||
this.context.updatePluginConfig(patch);
|
||||
}
|
||||
|
||||
updateSettings(patch: Partial<PasswordManagerSettings>) {
|
||||
this.context.updateSettings(patch);
|
||||
}
|
||||
|
||||
createGroup(name?: string) {
|
||||
return this.context.createGroup(name);
|
||||
}
|
||||
|
||||
updateGroupName(groupId: string, name: string) {
|
||||
return this.context.updateGroupName(groupId, name);
|
||||
}
|
||||
|
||||
createItem(groupId: string) {
|
||||
return this.context.createItem(groupId);
|
||||
}
|
||||
|
||||
duplicateItem(itemId: string) {
|
||||
return this.context.duplicateItem(itemId);
|
||||
}
|
||||
|
||||
updateItemTitle(itemId: string, title: string) {
|
||||
return this.context.updateItemTitle(itemId, title);
|
||||
}
|
||||
|
||||
updateItem(itemId: string, patch: Partial<Omit<PasswordItem, 'id'>>) {
|
||||
this.context.updateItem(itemId, patch);
|
||||
}
|
||||
|
||||
setGroupSort(mode: PwmSortMode) {
|
||||
this.context.setGroupSort(mode);
|
||||
}
|
||||
|
||||
setItemSort(mode: PwmSortMode) {
|
||||
this.context.setItemSort(mode);
|
||||
}
|
||||
|
||||
deleteGroup(groupId: string) {
|
||||
return this.context.deleteGroup(groupId);
|
||||
}
|
||||
|
||||
deleteGroups(groupIds: string[]) {
|
||||
return this.context.deleteGroups(groupIds);
|
||||
}
|
||||
|
||||
async deleteItem(itemId: string) {
|
||||
return this.context.deleteItem(itemId);
|
||||
}
|
||||
|
||||
shouldDeleteItemDirectlyById(itemId: string) {
|
||||
return this.context.shouldDeleteItemDirectlyById(itemId);
|
||||
}
|
||||
|
||||
deleteTrashItem(itemId: string) {
|
||||
return this.context.deleteTrashItem(itemId);
|
||||
}
|
||||
|
||||
restoreTrashItem(itemId: string, groupId?: string) {
|
||||
return this.context.restoreTrashItem(itemId, groupId);
|
||||
}
|
||||
|
||||
getGroup(groupId: string) {
|
||||
return this.context.getGroup(groupId);
|
||||
}
|
||||
|
||||
getItem(itemId: string) {
|
||||
return this.context.getItem(itemId);
|
||||
}
|
||||
|
||||
getSortedGroups() {
|
||||
return this.context.getSortedGroups();
|
||||
}
|
||||
|
||||
getTrashGroups() {
|
||||
return this.context.getTrashGroups();
|
||||
}
|
||||
|
||||
getItemsByGroup(groupId: string) {
|
||||
return this.context.getItemsByGroup(groupId);
|
||||
}
|
||||
|
||||
getSortedItemsByGroup(groupId: string) {
|
||||
return this.context.getSortedItemsByGroup(groupId);
|
||||
}
|
||||
|
||||
getTrashItemsByGroup(groupId: string) {
|
||||
return this.context.getTrashItemsByGroup(groupId);
|
||||
}
|
||||
|
||||
getTrashItem(itemId: string) {
|
||||
return this.context.getTrashItem(itemId);
|
||||
}
|
||||
|
||||
async copyItemAsConfiguredFormat(itemId: string) {
|
||||
return this.context.copyItemAsConfiguredFormat(itemId);
|
||||
}
|
||||
|
||||
moveGroup(groupId: string, toIndex: number) {
|
||||
this.context.moveGroup(groupId, toIndex);
|
||||
}
|
||||
|
||||
moveGroups(groupIds: string[], toIndex: number) {
|
||||
this.context.moveGroups(groupIds, toIndex);
|
||||
}
|
||||
|
||||
moveItemWithinGroup(itemId: string, toIndex: number, groupId: string) {
|
||||
this.context.moveItemWithinGroup(itemId, toIndex, groupId);
|
||||
}
|
||||
|
||||
moveItemsWithinGroup(itemIds: string[], toIndex: number, groupId: string) {
|
||||
this.context.moveItemsWithinGroup(itemIds, toIndex, groupId);
|
||||
}
|
||||
|
||||
assignItemToGroup(itemId: string, groupId: string, mode: 'move' | 'add') {
|
||||
return this.context.assignItemToGroup(itemId, groupId, mode);
|
||||
}
|
||||
|
||||
removeItemFromGroup(itemId: string, groupId: string) {
|
||||
return this.context.removeItemFromGroup(itemId, groupId);
|
||||
}
|
||||
}
|
||||
372
src/services/encryption-service.ts
Normal file
372
src/services/encryption-service.ts
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
import { FileSystemAdapter, Notice, type App } from 'obsidian';
|
||||
import { PWM_TEXT } from '../lang';
|
||||
import { createPasswordVerifier, verifyPassword } from '../util/encryption';
|
||||
import type { PasswordPluginContext } from './plugin-context';
|
||||
import { PasswordPromptModal, type PasswordPromptField } from '../ui/password-prompt-modal';
|
||||
|
||||
export class PasswordEncryptionService {
|
||||
constructor(
|
||||
private readonly app: App,
|
||||
private readonly context: PasswordPluginContext,
|
||||
) { }
|
||||
|
||||
async ensureEncryptionAccess() {
|
||||
const config = this.context.pluginConfig;
|
||||
if (!config.encryptionEnabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!config.encryptionVerifier) {
|
||||
new Notice(PWM_TEXT.encryptionNotConfigured);
|
||||
return false;
|
||||
}
|
||||
|
||||
const savedPassword = await this.tryUnlockWithSavedPassword();
|
||||
if (savedPassword) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!this.shouldVerifyEncryption()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const password = await this.requestSinglePassword();
|
||||
if (!password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const unlocked = await this.unlockWithPassword(password);
|
||||
if (!unlocked) {
|
||||
new Notice(PWM_TEXT.encryptionPasswordInvalid);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async ensureEncryptionWriteAccess() {
|
||||
const config = this.context.pluginConfig;
|
||||
if (!config.encryptionEnabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!config.encryptionVerifier) {
|
||||
new Notice(PWM_TEXT.encryptionNotConfigured);
|
||||
return false;
|
||||
}
|
||||
|
||||
const savedPassword = await this.tryUnlockWithSavedPassword();
|
||||
if (savedPassword) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.context.getEncryptionState().encryptionPassword) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const password = await this.requestSinglePassword();
|
||||
if (!password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const unlocked = await this.unlockWithPassword(password);
|
||||
if (!unlocked) {
|
||||
new Notice(PWM_TEXT.encryptionPasswordInvalid);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async enableEncryption() {
|
||||
const passwords = await this.requestPasswords(PWM_TEXT.enableEncryptionPassword, [
|
||||
{ key: 'password', label: PWM_TEXT.newEncryptionPassword },
|
||||
{ key: 'confirmPassword', label: PWM_TEXT.confirmEncryptionPassword },
|
||||
]);
|
||||
if (!passwords) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const password = passwords.password?.trim() ?? '';
|
||||
const confirmPassword = passwords.confirmPassword?.trim() ?? '';
|
||||
if (!password || password !== confirmPassword) {
|
||||
new Notice(PWM_TEXT.encryptionPasswordMismatch);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.context.setEncryptionState({
|
||||
encryptionPassword: password,
|
||||
lastVerifiedAt: Date.now(),
|
||||
hasUnlockedData: true,
|
||||
});
|
||||
this.context.updatePluginConfig({
|
||||
encryptionEnabled: true,
|
||||
encryptionVerifier: await createPasswordVerifier(password),
|
||||
savedEncryptionPassword: '',
|
||||
});
|
||||
|
||||
await this.context.savePluginData();
|
||||
new Notice(PWM_TEXT.encryptionEnabledNotice);
|
||||
return true;
|
||||
}
|
||||
|
||||
async disableEncryption() {
|
||||
const config = this.context.pluginConfig;
|
||||
if (!config.encryptionVerifier) {
|
||||
this.context.updatePluginConfig({
|
||||
encryptionEnabled: false,
|
||||
encryptionVerifier: null,
|
||||
persistEncryptionPassword: false,
|
||||
savedEncryptionPassword: '',
|
||||
});
|
||||
await this.context.savePluginData();
|
||||
return true;
|
||||
}
|
||||
|
||||
const password = await this.requestSinglePassword(PWM_TEXT.disableEncryptionPassword);
|
||||
if (!password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(password, config.encryptionVerifier);
|
||||
if (!valid) {
|
||||
new Notice(PWM_TEXT.encryptionPasswordInvalid);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.context.getEncryptionState().hasUnlockedData) {
|
||||
const unlocked = await this.unlockWithPassword(password);
|
||||
if (!unlocked) {
|
||||
new Notice(PWM_TEXT.encryptionPasswordInvalid);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.context.updatePluginConfig({
|
||||
encryptionEnabled: false,
|
||||
encryptionVerifier: null,
|
||||
persistEncryptionPassword: false,
|
||||
savedEncryptionPassword: '',
|
||||
});
|
||||
await this.context.savePluginData();
|
||||
this.context.setEncryptionState({
|
||||
encryptionPassword: '',
|
||||
lastVerifiedAt: 0,
|
||||
hasEncryptedStorage: false,
|
||||
});
|
||||
new Notice(PWM_TEXT.encryptionDisabledNotice);
|
||||
return true;
|
||||
}
|
||||
|
||||
async changeEncryptionPassword() {
|
||||
const config = this.context.pluginConfig;
|
||||
if (!config.encryptionVerifier) {
|
||||
new Notice(PWM_TEXT.encryptionNotConfigured);
|
||||
return false;
|
||||
}
|
||||
|
||||
const passwords = await this.requestPasswords(PWM_TEXT.changeEncryptionPassword, [
|
||||
{
|
||||
key: 'currentPassword',
|
||||
label: PWM_TEXT.currentEncryptionPassword,
|
||||
value: config.persistEncryptionPassword ? config.savedEncryptionPassword : '',
|
||||
},
|
||||
{ key: 'newPassword', label: PWM_TEXT.newEncryptionPassword },
|
||||
{ key: 'confirmPassword', label: PWM_TEXT.confirmEncryptionPassword },
|
||||
]);
|
||||
if (!passwords) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentPassword = passwords.currentPassword?.trim() ?? '';
|
||||
const newPassword = passwords.newPassword?.trim() ?? '';
|
||||
const confirmPassword = passwords.confirmPassword?.trim() ?? '';
|
||||
if (!currentPassword || !newPassword || newPassword !== confirmPassword) {
|
||||
new Notice(PWM_TEXT.encryptionPasswordMismatch);
|
||||
return false;
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(currentPassword, config.encryptionVerifier);
|
||||
if (!valid) {
|
||||
new Notice(PWM_TEXT.encryptionPasswordInvalid);
|
||||
return false;
|
||||
}
|
||||
|
||||
const unlocked = await this.unlockWithPassword(currentPassword);
|
||||
if (!unlocked) {
|
||||
new Notice(PWM_TEXT.encryptionPasswordInvalid);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.context.setEncryptionState({
|
||||
encryptionPassword: newPassword,
|
||||
lastVerifiedAt: Date.now(),
|
||||
});
|
||||
this.context.updatePluginConfig({
|
||||
encryptionVerifier: await createPasswordVerifier(newPassword),
|
||||
savedEncryptionPassword: this.context.pluginConfig.persistEncryptionPassword ? newPassword : '',
|
||||
});
|
||||
await this.context.savePluginData();
|
||||
new Notice(PWM_TEXT.encryptionPasswordChanged);
|
||||
return true;
|
||||
}
|
||||
|
||||
async setPersistEncryptionPassword(enabled: boolean) {
|
||||
if (!this.context.pluginConfig.encryptionEnabled) {
|
||||
this.context.updatePluginConfig({
|
||||
persistEncryptionPassword: false,
|
||||
savedEncryptionPassword: '',
|
||||
});
|
||||
await this.context.savePluginConfig();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
this.context.updatePluginConfig({
|
||||
persistEncryptionPassword: false,
|
||||
savedEncryptionPassword: '',
|
||||
});
|
||||
await this.context.savePluginConfig();
|
||||
new Notice(PWM_TEXT.persistEncryptionPasswordDisabledNotice);
|
||||
return true;
|
||||
}
|
||||
|
||||
let password = this.context.getEncryptionState().encryptionPassword;
|
||||
if (!password) {
|
||||
password = await this.requestSinglePassword();
|
||||
if (!password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const unlocked = await this.unlockWithPassword(password);
|
||||
if (!unlocked) {
|
||||
new Notice(PWM_TEXT.encryptionPasswordInvalid);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.context.updatePluginConfig({
|
||||
persistEncryptionPassword: true,
|
||||
savedEncryptionPassword: password,
|
||||
});
|
||||
await this.context.savePluginConfig();
|
||||
new Notice(PWM_TEXT.persistEncryptionPasswordEnabledNotice);
|
||||
return true;
|
||||
}
|
||||
|
||||
async openStorageFolder() {
|
||||
const storageFolderPath = this.context.getStorageFolder();
|
||||
return this.openFolder(storageFolderPath, undefined, PWM_TEXT.openStorageFolderFailed);
|
||||
}
|
||||
|
||||
private shouldVerifyEncryption() {
|
||||
const config = this.context.pluginConfig;
|
||||
const state = this.context.getEncryptionState();
|
||||
if (!config.encryptionEnabled || !config.encryptionVerifier) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!state.hasUnlockedData || !state.encryptionPassword || !state.lastVerifiedAt) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (config.encryptionUnlockMode === 'always') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (config.encryptionUnlockMode === 'interval') {
|
||||
const intervalMs = config.encryptionRecheckIntervalMinutes * 60 * 1000;
|
||||
return Date.now() - state.lastVerifiedAt >= intervalMs;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async unlockWithPassword(password: string) {
|
||||
const config = this.context.pluginConfig;
|
||||
if (!config.encryptionVerifier) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(password, config.encryptionVerifier);
|
||||
if (!valid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.context.getEncryptionState().hasEncryptedStorage) {
|
||||
const loaded = await this.context.getStorageStore().loadData(config, password);
|
||||
if (!loaded) {
|
||||
return false;
|
||||
}
|
||||
this.context.replaceData(loaded);
|
||||
}
|
||||
|
||||
this.context.setEncryptionState({
|
||||
encryptionPassword: password,
|
||||
lastVerifiedAt: Date.now(),
|
||||
hasUnlockedData: true,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private async tryUnlockWithSavedPassword() {
|
||||
const config = this.context.pluginConfig;
|
||||
if (!config.persistEncryptionPassword || !config.savedEncryptionPassword) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const state = this.context.getEncryptionState();
|
||||
if (state.hasUnlockedData && state.encryptionPassword === config.savedEncryptionPassword) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const unlocked = await this.unlockWithPassword(config.savedEncryptionPassword);
|
||||
if (unlocked) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.context.updatePluginConfig({
|
||||
persistEncryptionPassword: false,
|
||||
savedEncryptionPassword: '',
|
||||
});
|
||||
await this.context.savePluginConfig();
|
||||
return false;
|
||||
}
|
||||
|
||||
private async requestSinglePassword(title: string = PWM_TEXT.unlockManagerTitle) {
|
||||
const passwords = await this.requestPasswords(title, [
|
||||
{ key: 'password', label: PWM_TEXT.currentEncryptionPassword },
|
||||
]);
|
||||
return passwords?.password?.trim() ?? '';
|
||||
}
|
||||
|
||||
private async requestPasswords(title: string, fields: PasswordPromptField[]) {
|
||||
return PasswordPromptModal.open(this.app, { title, fields, confirmText: PWM_TEXT.confirm, cancelText: PWM_TEXT.cancel });
|
||||
}
|
||||
|
||||
private async openFolder(path: string, successMessage?: string, failedMessage?: string) {
|
||||
const adapter = this.app.vault.adapter;
|
||||
if (!(adapter instanceof FileSystemAdapter)) {
|
||||
new Notice(failedMessage ?? PWM_TEXT.openStorageFolderFailed);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await adapter.mkdir(path).catch(() => undefined);
|
||||
|
||||
const shell = (window as Window & {
|
||||
require?: (module: string) => { shell?: { openPath: (path: string) => Promise<string> } };
|
||||
}).require?.('electron')?.shell;
|
||||
const opened = await shell?.openPath(adapter.getFilePath(path));
|
||||
if (opened === '') {
|
||||
if (successMessage) {
|
||||
new Notice(successMessage);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// fall through to failure notice
|
||||
}
|
||||
|
||||
new Notice(failedMessage ?? PWM_TEXT.openStorageFolderFailed);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
479
src/services/plugin-context.ts
Normal file
479
src/services/plugin-context.ts
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
import { Notice } from 'obsidian';
|
||||
import { DEFAULT_DATA } from '../data/defaults';
|
||||
import { normalizePasswordManagerData, normalizePluginConfig, normalizeUrls } from '../data/normalize';
|
||||
import {
|
||||
assignItemToGroup,
|
||||
createGroup,
|
||||
createItem,
|
||||
deleteItem,
|
||||
duplicateItem,
|
||||
getFallbackGroupId,
|
||||
moveGroup,
|
||||
moveGroups,
|
||||
moveItemWithinGroup,
|
||||
moveItemsWithinGroup,
|
||||
reindexOrders,
|
||||
removeItemFromGroup,
|
||||
updateGroupName,
|
||||
updateItem,
|
||||
updateItemTitle,
|
||||
} from '../data/password-library-service';
|
||||
import { PasswordStorageStore } from '../data/storage-store';
|
||||
import { PWM_TEXT } from '../lang';
|
||||
import { formatPasswordItemForCopy } from '../util/copy-format';
|
||||
import { isEncryptedLibraryPayload } from '../util/encryption';
|
||||
import { sortGroups, sortDeletedItems, sortItems } from '../util/sort';
|
||||
import type {
|
||||
DeletedPasswordItem,
|
||||
PasswordGroup,
|
||||
PasswordItem,
|
||||
PasswordManagerData,
|
||||
PwmSortMode,
|
||||
} from '../util/types';
|
||||
import type { PasswordManagerSettings, PasswordPluginConfig } from '../settings';
|
||||
|
||||
interface PasswordPluginPersistence {
|
||||
loadLegacyData: () => Promise<unknown>;
|
||||
savePluginConfig: (config: PasswordPluginConfig) => Promise<void>;
|
||||
}
|
||||
|
||||
export class PasswordPluginContext {
|
||||
data: PasswordManagerData = structuredClone(DEFAULT_DATA);
|
||||
pluginConfig: PasswordPluginConfig = normalizePluginConfig(undefined);
|
||||
|
||||
private encryptionPassword = '';
|
||||
private lastVerifiedAt = 0;
|
||||
private hasEncryptedStorage = false;
|
||||
private hasUnlockedData = true;
|
||||
private ensureEncryptionWriteAccess: () => Promise<boolean> = async () => true;
|
||||
|
||||
constructor(
|
||||
private readonly storageStore: PasswordStorageStore,
|
||||
private readonly persistence: PasswordPluginPersistence,
|
||||
private readonly pluginId: string,
|
||||
) {}
|
||||
|
||||
setEncryptionWriteGuard(handler: () => Promise<boolean>) {
|
||||
this.ensureEncryptionWriteAccess = handler;
|
||||
}
|
||||
|
||||
async loadPluginData() {
|
||||
const legacyPluginData = await this.persistence.loadLegacyData();
|
||||
this.pluginConfig = normalizePluginConfig(legacyPluginData);
|
||||
await this.storageStore.migrateLegacyTrash(this.pluginConfig, this.pluginId);
|
||||
|
||||
const stored = await this.storageStore.readStoredData(this.pluginConfig);
|
||||
this.hasEncryptedStorage = this.pluginConfig.encryptionEnabled && isEncryptedLibraryPayload(stored);
|
||||
|
||||
if (this.hasEncryptedStorage) {
|
||||
this.data = structuredClone(DEFAULT_DATA);
|
||||
this.hasUnlockedData = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let storedData = await this.storageStore.loadData(this.pluginConfig);
|
||||
if (!storedData && legacyPluginData && typeof legacyPluginData === 'object' && ('groups' in legacyPluginData || 'items' in legacyPluginData)) {
|
||||
storedData = normalizePasswordManagerData(legacyPluginData);
|
||||
await this.storageStore.saveData(this.pluginConfig, storedData, this.encryptionPassword || undefined);
|
||||
}
|
||||
|
||||
this.data = normalizePasswordManagerData(storedData ?? DEFAULT_DATA);
|
||||
reindexOrders(this.data);
|
||||
this.hasUnlockedData = true;
|
||||
}
|
||||
|
||||
async savePluginData() {
|
||||
reindexOrders(this.data);
|
||||
this.pruneExpiredTrashItems();
|
||||
|
||||
if (this.pluginConfig.encryptionEnabled) {
|
||||
const unlocked = await this.ensureEncryptionWriteAccess();
|
||||
if (!unlocked) {
|
||||
throw new Error(PWM_TEXT.encryptionRequiredNotice);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.pluginConfig.autoBackupEnabled) {
|
||||
await this.storageStore.pruneBackups(this.pluginConfig);
|
||||
|
||||
const intervalMs = this.pluginConfig.autoBackupIntervalMinutes * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const shouldBackup = !this.pluginConfig.lastAutoBackupAt || now - this.pluginConfig.lastAutoBackupAt >= intervalMs;
|
||||
if (shouldBackup) {
|
||||
await this.storageStore.createAutoBackup(this.pluginConfig, this.data, this.encryptionPassword || undefined);
|
||||
this.pluginConfig = normalizePluginConfig({
|
||||
...this.pluginConfig,
|
||||
lastAutoBackupAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.persistence.savePluginConfig(this.pluginConfig);
|
||||
await this.storageStore.saveData(this.pluginConfig, this.data, this.encryptionPassword || undefined);
|
||||
this.hasEncryptedStorage = this.pluginConfig.encryptionEnabled;
|
||||
}
|
||||
|
||||
async savePluginConfig() {
|
||||
this.pluginConfig = normalizePluginConfig(this.pluginConfig);
|
||||
await this.persistence.savePluginConfig(this.pluginConfig);
|
||||
}
|
||||
|
||||
async applyTrashRetentionPolicy() {
|
||||
this.pluginConfig = normalizePluginConfig(this.pluginConfig);
|
||||
this.pruneExpiredTrashItems();
|
||||
|
||||
if (this.pluginConfig.encryptionEnabled) {
|
||||
const unlocked = await this.ensureEncryptionWriteAccess();
|
||||
if (!unlocked) {
|
||||
throw new Error(PWM_TEXT.encryptionRequiredNotice);
|
||||
}
|
||||
}
|
||||
|
||||
await this.persistence.savePluginConfig(this.pluginConfig);
|
||||
await this.storageStore.saveData(this.pluginConfig, this.data, this.encryptionPassword || undefined);
|
||||
this.hasEncryptedStorage = this.pluginConfig.encryptionEnabled;
|
||||
}
|
||||
|
||||
async createBackupNow() {
|
||||
try {
|
||||
if (this.pluginConfig.encryptionEnabled) {
|
||||
const success = await this.storageStore.createEncryptedManualBackup(this.pluginConfig);
|
||||
if (!success) {
|
||||
throw new Error('Failed to create encrypted backup');
|
||||
}
|
||||
} else {
|
||||
reindexOrders(this.data);
|
||||
await this.storageStore.createManualBackup(this.pluginConfig, this.data, this.encryptionPassword || undefined);
|
||||
}
|
||||
new Notice(PWM_TEXT.backupCreated);
|
||||
return true;
|
||||
} catch {
|
||||
new Notice(PWM_TEXT.backupCreateFailed);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
updatePluginConfig(patch: Partial<PasswordPluginConfig>) {
|
||||
this.pluginConfig = normalizePluginConfig({
|
||||
...this.pluginConfig,
|
||||
...patch,
|
||||
});
|
||||
}
|
||||
|
||||
updateSettings(patch: Partial<PasswordManagerSettings>) {
|
||||
this.data.settings = {
|
||||
...this.data.settings,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
replaceData(data: PasswordManagerData) {
|
||||
this.data = normalizePasswordManagerData(data);
|
||||
reindexOrders(this.data);
|
||||
this.hasUnlockedData = true;
|
||||
}
|
||||
|
||||
getStorageFolder() {
|
||||
return this.storageStore.getStorageFolder(this.pluginConfig);
|
||||
}
|
||||
|
||||
getStorageStore() {
|
||||
return this.storageStore;
|
||||
}
|
||||
|
||||
getEncryptionState() {
|
||||
return {
|
||||
encryptionPassword: this.encryptionPassword,
|
||||
lastVerifiedAt: this.lastVerifiedAt,
|
||||
hasEncryptedStorage: this.hasEncryptedStorage,
|
||||
hasUnlockedData: this.hasUnlockedData,
|
||||
};
|
||||
}
|
||||
|
||||
setEncryptionState(patch: Partial<ReturnType<PasswordPluginContext['getEncryptionState']>>) {
|
||||
if (patch.encryptionPassword !== undefined) {
|
||||
this.encryptionPassword = patch.encryptionPassword;
|
||||
}
|
||||
if (patch.lastVerifiedAt !== undefined) {
|
||||
this.lastVerifiedAt = patch.lastVerifiedAt;
|
||||
}
|
||||
if (patch.hasEncryptedStorage !== undefined) {
|
||||
this.hasEncryptedStorage = patch.hasEncryptedStorage;
|
||||
}
|
||||
if (patch.hasUnlockedData !== undefined) {
|
||||
this.hasUnlockedData = patch.hasUnlockedData;
|
||||
}
|
||||
}
|
||||
|
||||
createGroup(name?: string) {
|
||||
return createGroup(this.data, name);
|
||||
}
|
||||
|
||||
updateGroupName(groupId: string, name: string) {
|
||||
return updateGroupName(this.data, groupId, name);
|
||||
}
|
||||
|
||||
createItem(groupId: string) {
|
||||
return createItem(this.data, groupId);
|
||||
}
|
||||
|
||||
duplicateItem(itemId: string) {
|
||||
return duplicateItem(this.data, itemId);
|
||||
}
|
||||
|
||||
updateItemTitle(itemId: string, title: string) {
|
||||
return updateItemTitle(this.data, itemId, title);
|
||||
}
|
||||
|
||||
updateItem(itemId: string, patch: Partial<Omit<PasswordItem, 'id'>>) {
|
||||
updateItem(this.data, itemId, patch);
|
||||
}
|
||||
|
||||
setGroupSort(mode: PwmSortMode) {
|
||||
this.data.view.groupSort = mode;
|
||||
}
|
||||
|
||||
setItemSort(mode: PwmSortMode) {
|
||||
this.data.view.itemSort = mode;
|
||||
}
|
||||
|
||||
deleteGroup(groupId: string) {
|
||||
return this.deleteGroups([groupId]);
|
||||
}
|
||||
|
||||
deleteGroups(groupIds: string[]) {
|
||||
const targetGroupIds = new Set(groupIds);
|
||||
if (!targetGroupIds.size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const remainingItems: PasswordItem[] = [];
|
||||
for (const item of this.data.items) {
|
||||
if (!item.groupIds.some((id) => targetGroupIds.has(id))) {
|
||||
remainingItems.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const remainingGroupIds = item.groupIds.filter((id) => !targetGroupIds.has(id));
|
||||
if (!remainingGroupIds.length) {
|
||||
this.storageStore.moveItemToTrash(this.data, item);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (remainingGroupIds.length !== item.groupIds.length) {
|
||||
item.groupIds = remainingGroupIds;
|
||||
item.updatedAt = Date.now();
|
||||
changed = true;
|
||||
}
|
||||
remainingItems.push(item);
|
||||
}
|
||||
|
||||
this.data.items = remainingItems;
|
||||
const previousGroupCount = this.data.groups.length;
|
||||
this.data.groups = this.data.groups.filter((group) => !targetGroupIds.has(group.id));
|
||||
if (this.data.groups.length !== previousGroupCount) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
reindexOrders(this.data);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
async deleteItem(itemId: string) {
|
||||
const item = this.getItem(itemId);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.shouldDeleteItemDirectly(item)) {
|
||||
return !!deleteItem(this.data, itemId);
|
||||
}
|
||||
|
||||
this.storageStore.moveItemToTrash(this.data, item);
|
||||
return !!deleteItem(this.data, itemId);
|
||||
}
|
||||
|
||||
shouldDeleteItemDirectlyById(itemId: string) {
|
||||
const item = this.getItem(itemId);
|
||||
return item ? this.shouldDeleteItemDirectly(item) : false;
|
||||
}
|
||||
|
||||
deleteTrashItem(itemId: string) {
|
||||
const previousLength = this.data.trash.length;
|
||||
this.data.trash = this.data.trash.filter((item) => item.id !== itemId);
|
||||
return this.data.trash.length !== previousLength;
|
||||
}
|
||||
|
||||
restoreTrashItem(itemId: string, groupId?: string) {
|
||||
const item = this.getTrashItem(itemId);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const restoredGroupIds = groupId ? [groupId] : this.resolveRestoredGroupIds(item);
|
||||
if (!restoredGroupIds.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { deletedAt, deletedGroupNames, ...restoredItem } = item;
|
||||
void deletedAt;
|
||||
void deletedGroupNames;
|
||||
const nextItem: PasswordItem = {
|
||||
...restoredItem,
|
||||
groupIds: restoredGroupIds,
|
||||
};
|
||||
const removed = this.deleteTrashItem(itemId);
|
||||
if (!removed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.data.items.push(nextItem);
|
||||
reindexOrders(this.data);
|
||||
return true;
|
||||
}
|
||||
|
||||
getGroup(groupId: string) {
|
||||
return this.data.groups.find((group) => group.id === groupId);
|
||||
}
|
||||
|
||||
getItem(itemId: string) {
|
||||
return this.data.items.find((item) => item.id === itemId);
|
||||
}
|
||||
|
||||
getSortedGroups() {
|
||||
return sortGroups(this.data.groups, this.data.items, this.data.view.groupSort);
|
||||
}
|
||||
|
||||
getTrashGroups() {
|
||||
const groups = new Map<string, PasswordGroup>();
|
||||
this.data.trash.forEach((item) => {
|
||||
const key = new Date(item.deletedAt).toISOString().slice(0, 10);
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, {
|
||||
id: key,
|
||||
name: key,
|
||||
createdAt: item.deletedAt,
|
||||
order: item.deletedAt,
|
||||
});
|
||||
}
|
||||
});
|
||||
return [...groups.values()].sort((left, right) => right.createdAt - left.createdAt);
|
||||
}
|
||||
|
||||
getItemsByGroup(groupId: string) {
|
||||
return this.data.items.filter((item) => item.groupIds.includes(groupId));
|
||||
}
|
||||
|
||||
getSortedItemsByGroup(groupId: string) {
|
||||
return sortItems(this.getItemsByGroup(groupId), this.data.view.itemSort);
|
||||
}
|
||||
|
||||
getTrashItemsByGroup(groupId: string) {
|
||||
return sortDeletedItems(
|
||||
this.data.trash.filter((item) => new Date(item.deletedAt).toISOString().slice(0, 10) === groupId),
|
||||
this.data.view.itemSort,
|
||||
);
|
||||
}
|
||||
|
||||
getTrashItem(itemId: string) {
|
||||
return this.data.trash.find((item) => item.id === itemId);
|
||||
}
|
||||
|
||||
async copyItemAsConfiguredFormat(itemId: string) {
|
||||
const item = this.getItem(itemId) ?? this.getTrashItem(itemId);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = formatPasswordItemForCopy(item, this.data.groups, this.data.settings.copyFormat);
|
||||
await navigator.clipboard.writeText(content);
|
||||
new Notice(PWM_TEXT.copiedPasswordInfo);
|
||||
return true;
|
||||
}
|
||||
|
||||
moveGroup(groupId: string, toIndex: number) {
|
||||
moveGroup(this.data, groupId, toIndex);
|
||||
}
|
||||
|
||||
moveGroups(groupIds: string[], toIndex: number) {
|
||||
moveGroups(this.data, groupIds, toIndex);
|
||||
}
|
||||
|
||||
moveItemWithinGroup(itemId: string, toIndex: number, groupId: string) {
|
||||
moveItemWithinGroup(this.data, itemId, toIndex, groupId);
|
||||
}
|
||||
|
||||
moveItemsWithinGroup(itemIds: string[], toIndex: number, groupId: string) {
|
||||
moveItemsWithinGroup(this.data, itemIds, toIndex, groupId);
|
||||
}
|
||||
|
||||
assignItemToGroup(itemId: string, groupId: string, mode: 'move' | 'add') {
|
||||
return assignItemToGroup(this.data, itemId, groupId, mode);
|
||||
}
|
||||
|
||||
removeItemFromGroup(itemId: string, groupId: string) {
|
||||
const success = removeItemFromGroup(this.data, itemId, groupId);
|
||||
if (!success && this.getItem(itemId)?.groupIds.includes(groupId) && (this.getItem(itemId)?.groupIds.length ?? 0) <= 1) {
|
||||
new Notice(PWM_TEXT.keepOneItemGroup);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
private pruneExpiredTrashItems() {
|
||||
if (this.pluginConfig.trashRetentionDays <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const retentionMs = this.pluginConfig.trashRetentionDays * 24 * 60 * 60 * 1000;
|
||||
const minDeletedAt = Date.now() - retentionMs;
|
||||
this.data.trash = this.data.trash.filter((item) => item.deletedAt >= minDeletedAt);
|
||||
}
|
||||
|
||||
private shouldDeleteItemDirectly(item: PasswordItem) {
|
||||
return !!item.title.trim()
|
||||
&& !item.username.trim()
|
||||
&& !item.password.trim()
|
||||
&& normalizeUrls(item.urls).length === 0
|
||||
&& !item.notes.trim();
|
||||
}
|
||||
|
||||
private resolveRestoredGroupIds(item: DeletedPasswordItem) {
|
||||
const restoredGroupIds: string[] = [];
|
||||
const existingGroupIds = new Set(this.data.groups.map((group) => group.id));
|
||||
|
||||
item.groupIds.forEach((id, index) => {
|
||||
if (existingGroupIds.has(id) && !restoredGroupIds.includes(id)) {
|
||||
restoredGroupIds.push(id);
|
||||
return;
|
||||
}
|
||||
|
||||
const deletedGroupName = item.deletedGroupNames?.[index]?.trim();
|
||||
if (!deletedGroupName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingGroup = this.data.groups.find((group) => group.name === deletedGroupName);
|
||||
if (existingGroup) {
|
||||
if (!restoredGroupIds.includes(existingGroup.id)) {
|
||||
restoredGroupIds.push(existingGroup.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const createdGroup = this.createGroup(deletedGroupName);
|
||||
existingGroupIds.add(createdGroup.id);
|
||||
restoredGroupIds.push(createdGroup.id);
|
||||
});
|
||||
|
||||
if (restoredGroupIds.length) {
|
||||
return restoredGroupIds;
|
||||
}
|
||||
|
||||
const fallbackGroupId = getFallbackGroupId(this.data) ?? DEFAULT_DATA.groups[0].id;
|
||||
return fallbackGroupId ? [fallbackGroupId] : [];
|
||||
}
|
||||
}
|
||||
200
src/services/transfer-service.ts
Normal file
200
src/services/transfer-service.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { Notice, type App } from 'obsidian';
|
||||
import {
|
||||
importGroupFromText,
|
||||
importItemFromText,
|
||||
importItemsFromText,
|
||||
importLibraryFromText,
|
||||
isEncryptedLibraryImportText,
|
||||
} from '../data/import-service';
|
||||
import {
|
||||
downloadCsvGroup,
|
||||
downloadCsvGroups,
|
||||
downloadJson,
|
||||
downloadMarkdownGroup,
|
||||
downloadMarkdownGroups,
|
||||
downloadMarkdownItems,
|
||||
} from '../data/transfer';
|
||||
import { PWM_TEXT } from '../lang';
|
||||
import { appendDateTimeSuffix } from '../util/file-name';
|
||||
import type { PasswordGroup, PasswordItem } from '../util/types';
|
||||
import type { PasswordPluginContext } from './plugin-context';
|
||||
import { PasswordPromptModal } from '../ui/password-prompt-modal';
|
||||
|
||||
export class PasswordTransferService {
|
||||
constructor(
|
||||
private readonly app: App,
|
||||
private readonly context: PasswordPluginContext,
|
||||
) {}
|
||||
|
||||
async exportLibrary() {
|
||||
const exportedAt = Date.now();
|
||||
const filename = appendDateTimeSuffix('password-library.json', exportedAt);
|
||||
|
||||
if (this.context.pluginConfig.encryptionEnabled) {
|
||||
const exported = await this.context.getStorageStore().downloadEncryptedLibrary(this.context.pluginConfig, filename);
|
||||
if (!exported) {
|
||||
throw new Error('Failed to export encrypted library');
|
||||
}
|
||||
new Notice(PWM_TEXT.exportSuccess);
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadJson(filename, {
|
||||
version: 1,
|
||||
kind: 'library',
|
||||
exportedAt,
|
||||
data: this.context.data,
|
||||
});
|
||||
new Notice(PWM_TEXT.exportSuccess);
|
||||
}
|
||||
|
||||
async exportGroup(groupId: string, format: 'json' | 'markdown' | 'csv' = 'json') {
|
||||
const group = this.context.getGroup(groupId);
|
||||
if (!group) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const exportedAt = Date.now();
|
||||
const items = this.context.getItemsByGroup(groupId);
|
||||
if (format === 'markdown') {
|
||||
await downloadMarkdownGroup(appendDateTimeSuffix(`${group.name || 'group'}.md`, exportedAt), group, items);
|
||||
} else if (format === 'csv') {
|
||||
await downloadCsvGroup(appendDateTimeSuffix(`${group.name || 'group'}.csv`, exportedAt), group, items);
|
||||
} else {
|
||||
await downloadJson(appendDateTimeSuffix(`${group.name || 'group'}.json`, exportedAt), {
|
||||
version: 1,
|
||||
kind: 'group',
|
||||
exportedAt,
|
||||
data: {
|
||||
group,
|
||||
items,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async exportGroups(groupIds: string[], format: 'json' | 'markdown' | 'csv' = 'json') {
|
||||
const groupsWithItems = groupIds
|
||||
.map((groupId) => {
|
||||
const group = this.context.getGroup(groupId);
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
group,
|
||||
items: this.context.getItemsByGroup(groupId),
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is { group: PasswordGroup; items: PasswordItem[] } => !!entry);
|
||||
|
||||
if (!groupsWithItems.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exportedAt = Date.now();
|
||||
if (format === 'markdown') {
|
||||
await downloadMarkdownGroups(appendDateTimeSuffix('selected-groups.md', exportedAt), groupsWithItems);
|
||||
} else if (format === 'csv') {
|
||||
await downloadCsvGroups(appendDateTimeSuffix('selected-groups.csv', exportedAt), groupsWithItems);
|
||||
} else {
|
||||
await downloadJson(appendDateTimeSuffix('selected-groups.json', exportedAt), {
|
||||
version: 1,
|
||||
kind: 'groups',
|
||||
exportedAt,
|
||||
data: {
|
||||
groups: groupsWithItems,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
new Notice(PWM_TEXT.exportSuccess);
|
||||
}
|
||||
|
||||
async exportItem(itemId: string) {
|
||||
const item = this.context.getItem(itemId);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exportedAt = Date.now();
|
||||
await downloadJson(appendDateTimeSuffix(`${item.title || 'item'}.json`, exportedAt), {
|
||||
version: 1,
|
||||
kind: 'item',
|
||||
exportedAt,
|
||||
data: item,
|
||||
});
|
||||
new Notice(PWM_TEXT.exportSuccess);
|
||||
}
|
||||
|
||||
async exportItems(itemIds: string[], format: 'json' | 'markdown') {
|
||||
const items = itemIds
|
||||
.map((itemId) => this.context.getItem(itemId))
|
||||
.filter((item): item is PasswordItem => !!item);
|
||||
if (!items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exportedAt = Date.now();
|
||||
if (format === 'markdown') {
|
||||
await downloadMarkdownItems(appendDateTimeSuffix('selected-items.md', exportedAt), items, this.context.data.groups);
|
||||
} else {
|
||||
await downloadJson(appendDateTimeSuffix('selected-items.json', exportedAt), {
|
||||
version: 1,
|
||||
kind: 'items',
|
||||
exportedAt,
|
||||
data: items,
|
||||
});
|
||||
}
|
||||
new Notice(PWM_TEXT.exportSuccess);
|
||||
}
|
||||
|
||||
async importLibraryFromText(text: string) {
|
||||
try {
|
||||
const encryptedImport = isEncryptedLibraryImportText(text);
|
||||
const password = encryptedImport
|
||||
? (await PasswordPromptModal.open(this.app, {
|
||||
title: PWM_TEXT.unlockManagerTitle,
|
||||
fields: [{ key: 'password', label: PWM_TEXT.currentEncryptionPassword }],
|
||||
confirmText: PWM_TEXT.confirm,
|
||||
cancelText: PWM_TEXT.cancel,
|
||||
}))?.password?.trim()
|
||||
: undefined;
|
||||
|
||||
if (encryptedImport && !password) {
|
||||
throw new Error('Missing encryption password');
|
||||
}
|
||||
|
||||
const imported = await importLibraryFromText(text, password);
|
||||
this.context.replaceData(imported);
|
||||
} catch {
|
||||
throw new Error(PWM_TEXT.importFailed);
|
||||
}
|
||||
}
|
||||
|
||||
async importGroupFromText(text: string) {
|
||||
try {
|
||||
return importGroupFromText(text, this.context.data);
|
||||
} catch {
|
||||
throw new Error(PWM_TEXT.importFailed);
|
||||
}
|
||||
}
|
||||
|
||||
async importItemFromText(text: string, groupId: string) {
|
||||
try {
|
||||
return importItemFromText(text, this.context.data, groupId);
|
||||
} catch {
|
||||
throw new Error(PWM_TEXT.importFailed);
|
||||
}
|
||||
}
|
||||
|
||||
async importItemsFromText(text: string, groupId: string) {
|
||||
try {
|
||||
return importItemsFromText(text, this.context.data, groupId);
|
||||
} catch {
|
||||
throw new Error(PWM_TEXT.importFailed);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/services/ui-helpers.ts
Normal file
19
src/services/ui-helpers.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { setIcon } from 'obsidian';
|
||||
|
||||
export const createIconButton = (
|
||||
container: HTMLElement,
|
||||
icon: string,
|
||||
label: string,
|
||||
onClick: () => void | Promise<void>,
|
||||
) => {
|
||||
const button = container.createEl('button', {
|
||||
cls: 'clickable-icon pwm-icon-button',
|
||||
attr: { 'aria-label': label, type: 'button' },
|
||||
});
|
||||
setIcon(button, icon);
|
||||
button.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
await onClick();
|
||||
});
|
||||
return button;
|
||||
};
|
||||
400
src/settings.ts
Normal file
400
src/settings.ts
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
import type { EncryptedPasswordVerifier } from './util/types';
|
||||
import { PluginSettingTab, Setting, type App } from 'obsidian';
|
||||
import { PWM_TEXT } from './lang';
|
||||
import type PasswordManagerPlugin from './main';
|
||||
import type { PasswordCopyFormat, PasswordUnlockMode } from './util/types';
|
||||
|
||||
export interface PasswordManagerSettings {
|
||||
confirmBeforeDelete: boolean;
|
||||
copyFormat: PasswordCopyFormat;
|
||||
showItemUsername: boolean;
|
||||
showItemUrl: boolean;
|
||||
showItemGroupTags: boolean;
|
||||
showItemNotes: boolean;
|
||||
}
|
||||
|
||||
export interface PasswordPluginConfig {
|
||||
storageFolderName: string;
|
||||
autoBackupEnabled: boolean;
|
||||
autoBackupCount: number;
|
||||
autoBackupIntervalMinutes: number;
|
||||
trashRetentionDays: number;
|
||||
lastAutoBackupAt: number;
|
||||
encryptionEnabled: boolean;
|
||||
encryptionUnlockMode: PasswordUnlockMode;
|
||||
encryptionRecheckIntervalMinutes: number;
|
||||
encryptionVerifier: EncryptedPasswordVerifier | null;
|
||||
persistEncryptionPassword: boolean;
|
||||
savedEncryptionPassword: string;
|
||||
modalWidthExpr: string;
|
||||
modalHeightExpr: string;
|
||||
columnRatioExpr: string;
|
||||
columnRatioLocked: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PASSWORD_MANAGER_SETTINGS: PasswordManagerSettings = {
|
||||
confirmBeforeDelete: true,
|
||||
copyFormat: 'markdown',
|
||||
showItemUsername: true,
|
||||
showItemUrl: true,
|
||||
showItemGroupTags: true,
|
||||
showItemNotes: true,
|
||||
};
|
||||
|
||||
export const DEFAULT_PASSWORD_PLUGIN_CONFIG: PasswordPluginConfig = {
|
||||
storageFolderName: '.password',
|
||||
autoBackupEnabled: true,
|
||||
autoBackupCount: 20,
|
||||
autoBackupIntervalMinutes: 5,
|
||||
trashRetentionDays: 150,
|
||||
lastAutoBackupAt: 0,
|
||||
encryptionEnabled: false,
|
||||
encryptionUnlockMode: 'session',
|
||||
encryptionRecheckIntervalMinutes: 30,
|
||||
encryptionVerifier: null,
|
||||
persistEncryptionPassword: false,
|
||||
savedEncryptionPassword: '',
|
||||
modalWidthExpr: '92vw, 1200px',
|
||||
modalHeightExpr: '80vh, 800px',
|
||||
columnRatioExpr: '1,1,2',
|
||||
columnRatioLocked: true,
|
||||
};
|
||||
|
||||
const removeFromTabOrder = (element: HTMLElement | null) => {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.tabIndex = -1;
|
||||
};
|
||||
|
||||
export class PasswordManagerSettingTab extends PluginSettingTab {
|
||||
private isSyncingEncryptionToggle = false;
|
||||
|
||||
constructor(app: App, private readonly plugin: PasswordManagerPlugin) {
|
||||
super(app, plugin);
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
containerEl.createEl('h2', { text: PWM_TEXT.settingsTitle });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(PWM_TEXT.storageFolderSetting)
|
||||
.setDesc(PWM_TEXT.storageFolderSettingDesc)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder('.password')
|
||||
.setValue(this.plugin.pluginConfig.storageFolderName)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.updatePluginConfig({ storageFolderName: value });
|
||||
await this.plugin.savePluginConfig();
|
||||
}),
|
||||
)
|
||||
.addExtraButton((button) => {
|
||||
button.setIcon('folder-open');
|
||||
button.setTooltip(PWM_TEXT.openStorageFolder);
|
||||
removeFromTabOrder(button.extraSettingsEl);
|
||||
button.onClick(async () => {
|
||||
await this.plugin.openStorageFolder();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(PWM_TEXT.confirmDeleteSetting)
|
||||
.setDesc(PWM_TEXT.confirmDeleteSettingDesc)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.data.settings.confirmBeforeDelete)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.updateSettings({ confirmBeforeDelete: value });
|
||||
await this.plugin.savePluginData();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(PWM_TEXT.copyFormatSetting)
|
||||
.setDesc(PWM_TEXT.copyFormatSettingDesc)
|
||||
.addDropdown((dropdown) =>
|
||||
dropdown
|
||||
.addOption('markdown', PWM_TEXT.copyFormatMarkdown)
|
||||
.addOption('plain-text', PWM_TEXT.copyFormatPlainText)
|
||||
.addOption('callout', PWM_TEXT.copyFormatCallout)
|
||||
.setValue(this.plugin.data.settings.copyFormat)
|
||||
.onChange(async (value: PasswordCopyFormat) => {
|
||||
this.plugin.updateSettings({ copyFormat: value });
|
||||
await this.plugin.savePluginData();
|
||||
}),
|
||||
);
|
||||
|
||||
containerEl.createEl('h3', { text: PWM_TEXT.modalSettingsTitle });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(PWM_TEXT.modalWidthSetting)
|
||||
.setDesc(PWM_TEXT.modalWidthSettingDesc)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder('92vw, 1200px')
|
||||
.setValue(this.plugin.pluginConfig.modalWidthExpr)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.updatePluginConfig({ modalWidthExpr: value });
|
||||
await this.plugin.savePluginConfig();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(PWM_TEXT.modalHeightSetting)
|
||||
.setDesc(PWM_TEXT.modalHeightSettingDesc)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder('80vh, 800px')
|
||||
.setValue(this.plugin.pluginConfig.modalHeightExpr)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.updatePluginConfig({ modalHeightExpr: value });
|
||||
await this.plugin.savePluginConfig();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(PWM_TEXT.columnRatioSetting)
|
||||
.setDesc(PWM_TEXT.columnRatioSettingDesc)
|
||||
.addText((text) => {
|
||||
const applyColumnRatio = async () => {
|
||||
this.plugin.updatePluginConfig({ columnRatioExpr: text.getValue() });
|
||||
await this.plugin.savePluginConfig();
|
||||
this.plugin.refreshManagerLayouts();
|
||||
};
|
||||
|
||||
text
|
||||
.setPlaceholder('1,1,2')
|
||||
.setValue(this.plugin.pluginConfig.columnRatioExpr)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.updatePluginConfig({ columnRatioExpr: value });
|
||||
await this.plugin.savePluginConfig();
|
||||
});
|
||||
|
||||
text.inputEl.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
void applyColumnRatio();
|
||||
});
|
||||
})
|
||||
.addExtraButton((button) => {
|
||||
button.setIcon('reset');
|
||||
button.setTooltip(PWM_TEXT.columnRatioReset);
|
||||
removeFromTabOrder(button.extraSettingsEl);
|
||||
button.onClick(async () => {
|
||||
this.plugin.updatePluginConfig({ columnRatioExpr: '1,1,2', columnRatioLocked: true });
|
||||
await this.plugin.savePluginConfig();
|
||||
this.plugin.refreshManagerLayouts();
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
|
||||
containerEl.createEl('h3', { text: PWM_TEXT.encryptionSettingsTitle });
|
||||
containerEl.createEl('p', {
|
||||
text: PWM_TEXT.encryptionSettingsTitleDesc,
|
||||
cls: 'setting-item-description',
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(PWM_TEXT.encryptionEnabledSetting)
|
||||
.setDesc(PWM_TEXT.encryptionEnabledSettingDesc)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.pluginConfig.encryptionEnabled)
|
||||
.onChange(async (value) => {
|
||||
if (this.isSyncingEncryptionToggle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousValue = this.plugin.pluginConfig.encryptionEnabled;
|
||||
this.isSyncingEncryptionToggle = true;
|
||||
try {
|
||||
const success = value
|
||||
? await this.plugin.enableEncryption()
|
||||
: await this.plugin.disableEncryption();
|
||||
if (!success) {
|
||||
toggle.setValue(previousValue);
|
||||
}
|
||||
} catch {
|
||||
toggle.setValue(previousValue);
|
||||
} finally {
|
||||
this.isSyncingEncryptionToggle = false;
|
||||
this.display();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (this.plugin.pluginConfig.encryptionEnabled) {
|
||||
new Setting(containerEl)
|
||||
.setName(PWM_TEXT.encryptionUnlockModeSetting)
|
||||
.setDesc(PWM_TEXT.encryptionUnlockModeSettingDesc)
|
||||
.addDropdown((dropdown) =>
|
||||
dropdown
|
||||
.addOption('session', PWM_TEXT.encryptionUnlockModeSession)
|
||||
.addOption('interval', PWM_TEXT.encryptionUnlockModeInterval)
|
||||
.addOption('always', PWM_TEXT.encryptionUnlockModeAlways)
|
||||
.setValue(this.plugin.pluginConfig.encryptionUnlockMode)
|
||||
.onChange(async (value: PasswordUnlockMode) => {
|
||||
this.plugin.updatePluginConfig({ encryptionUnlockMode: value });
|
||||
await this.plugin.savePluginConfig();
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(PWM_TEXT.persistEncryptionPasswordSetting)
|
||||
.setDesc(PWM_TEXT.persistEncryptionPasswordSettingDesc)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.pluginConfig.persistEncryptionPassword)
|
||||
.onChange(async (value) => {
|
||||
await this.plugin.setPersistEncryptionPassword(value);
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
|
||||
if (this.plugin.pluginConfig.encryptionUnlockMode === 'interval') {
|
||||
new Setting(containerEl)
|
||||
.setName(PWM_TEXT.encryptionRecheckIntervalSetting)
|
||||
.setDesc(PWM_TEXT.encryptionRecheckIntervalSettingDesc)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder('30')
|
||||
.setValue(String(this.plugin.pluginConfig.encryptionRecheckIntervalMinutes))
|
||||
.onChange(async (value) => {
|
||||
const parsed = Number(value);
|
||||
this.plugin.updatePluginConfig({ encryptionRecheckIntervalMinutes: Number.isFinite(parsed) ? parsed : 30 });
|
||||
await this.plugin.savePluginConfig();
|
||||
}),
|
||||
)
|
||||
.addExtraButton((button) => {
|
||||
button.setIcon('reset');
|
||||
button.setTooltip(PWM_TEXT.encryptionRecheckIntervalReset);
|
||||
removeFromTabOrder(button.extraSettingsEl);
|
||||
button.onClick(async () => {
|
||||
this.plugin.updatePluginConfig({ encryptionRecheckIntervalMinutes: 30 });
|
||||
await this.plugin.savePluginConfig();
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(PWM_TEXT.changeEncryptionPassword)
|
||||
.addButton((button) => {
|
||||
button.setButtonText(PWM_TEXT.changeEncryptionPasswordAction);
|
||||
removeFromTabOrder(button.buttonEl);
|
||||
button.onClick(async () => {
|
||||
const success = await this.plugin.changeEncryptionPassword();
|
||||
if (success) {
|
||||
this.display();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
containerEl.createEl('h3', { text: PWM_TEXT.backupSettingsTitle });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(PWM_TEXT.createBackupNow)
|
||||
.setDesc(PWM_TEXT.createBackupNowDesc)
|
||||
.addButton((button) => {
|
||||
button.setButtonText(PWM_TEXT.createBackupNow);
|
||||
removeFromTabOrder(button.buttonEl);
|
||||
button.onClick(async () => {
|
||||
await this.plugin.createBackupNow();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(PWM_TEXT.autoBackupEnabledSetting)
|
||||
.setDesc(PWM_TEXT.autoBackupEnabledSettingDesc)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.pluginConfig.autoBackupEnabled)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.updatePluginConfig({ autoBackupEnabled: value });
|
||||
await this.plugin.savePluginConfig();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(PWM_TEXT.autoBackupCountSetting)
|
||||
.setDesc(PWM_TEXT.autoBackupCountSettingDesc)
|
||||
.addSlider((slider) =>
|
||||
slider
|
||||
.setLimits(0, 50, 1)
|
||||
.setValue(this.plugin.pluginConfig.autoBackupCount)
|
||||
.setDynamicTooltip()
|
||||
.onChange(async (value) => {
|
||||
this.plugin.updatePluginConfig({ autoBackupCount: value });
|
||||
await this.plugin.savePluginConfig();
|
||||
}),
|
||||
)
|
||||
.addExtraButton((button) => {
|
||||
button.setIcon('reset');
|
||||
button.setTooltip(PWM_TEXT.autoBackupCountReset);
|
||||
removeFromTabOrder(button.extraSettingsEl);
|
||||
button.onClick(async () => {
|
||||
this.plugin.updatePluginConfig({ autoBackupCount: 20 });
|
||||
await this.plugin.savePluginConfig();
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(PWM_TEXT.autoBackupIntervalSetting)
|
||||
.setDesc(PWM_TEXT.autoBackupIntervalSettingDesc)
|
||||
.addSlider((slider) =>
|
||||
slider
|
||||
.setLimits(1, 60, 1)
|
||||
.setValue(this.plugin.pluginConfig.autoBackupIntervalMinutes)
|
||||
.setDynamicTooltip()
|
||||
.onChange(async (value) => {
|
||||
this.plugin.updatePluginConfig({ autoBackupIntervalMinutes: value });
|
||||
await this.plugin.savePluginConfig();
|
||||
}),
|
||||
)
|
||||
.addExtraButton((button) => {
|
||||
button.setIcon('reset');
|
||||
button.setTooltip(PWM_TEXT.autoBackupIntervalReset);
|
||||
removeFromTabOrder(button.extraSettingsEl);
|
||||
button.onClick(async () => {
|
||||
this.plugin.updatePluginConfig({ autoBackupIntervalMinutes: 5 });
|
||||
await this.plugin.savePluginConfig();
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(PWM_TEXT.trashRetentionDaysSetting)
|
||||
.setDesc(PWM_TEXT.trashRetentionDaysSettingDesc)
|
||||
.addSlider((slider) =>
|
||||
slider
|
||||
.setLimits(0, 365, 1)
|
||||
.setValue(this.plugin.pluginConfig.trashRetentionDays)
|
||||
.setDynamicTooltip()
|
||||
.onChange(async (value) => {
|
||||
this.plugin.updatePluginConfig({ trashRetentionDays: value });
|
||||
await this.plugin.applyTrashRetentionPolicy();
|
||||
}),
|
||||
)
|
||||
.addExtraButton((button) => {
|
||||
button.setIcon('reset');
|
||||
button.setTooltip(PWM_TEXT.trashRetentionDaysReset);
|
||||
removeFromTabOrder(button.extraSettingsEl);
|
||||
button.onClick(async () => {
|
||||
this.plugin.updatePluginConfig({ trashRetentionDays: 150 });
|
||||
await this.plugin.applyTrashRetentionPolicy();
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
634
src/styles.less
Normal file
634
src/styles.less
Normal file
|
|
@ -0,0 +1,634 @@
|
|||
.modal.pwm-modal {
|
||||
overflow: hidden;
|
||||
|
||||
.modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.pwm-modal-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
flex: 0 0 auto;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.pwm-modal-title-count {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.pwm-modal-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 680px;
|
||||
margin-right: 40px;
|
||||
}
|
||||
|
||||
.pwm-modal-header-search-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: min(320px, 38vw);
|
||||
// margin: 0 0 0 auto;
|
||||
}
|
||||
|
||||
.pwm-modal-header-search {
|
||||
width: 100%;
|
||||
padding-left: 12px;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.pwm-modal-header-search-icon {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke-width: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.pwm-modal-header-search-clear {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 50%;
|
||||
z-index: 1;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.pwm-modal-header-search-clear.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pwm-root {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 6px 320px 6px minmax(340px, 1fr);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pwm-resize-handle {
|
||||
position: relative;
|
||||
width: 6px;
|
||||
min-width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
transform: translateX(-50%);
|
||||
background: var(--background-modifier-border);
|
||||
transition: background-color 120ms ease, opacity 120ms ease;
|
||||
}
|
||||
|
||||
&:hover::before,
|
||||
&.is-resizing::before {
|
||||
background: var(--interactive-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.pwm-root.is-column-ratio-locked {
|
||||
.pwm-resize-handle {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
|
||||
&::before {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pwm-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pwm-groups-column,
|
||||
.pwm-items-column,
|
||||
.pwm-details-column {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pwm-details-column,
|
||||
.pwm-details-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pwm-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--font-ui-medium);
|
||||
}
|
||||
}
|
||||
|
||||
.pwm-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pwm-actions,
|
||||
.pwm-inline-actions,
|
||||
.pwm-footer-actions,
|
||||
.pwm-item-row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pwm-actions,
|
||||
.pwm-footer-actions,
|
||||
.pwm-item-row-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pwm-footer-actions {
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pwm-icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
color 140ms ease,
|
||||
border-color 140ms ease,
|
||||
box-shadow 140ms ease,
|
||||
transform 140ms ease;
|
||||
|
||||
// &:hover {
|
||||
// background: var(--background-modifier-hover);
|
||||
// transform: translateY(-1px);
|
||||
// }
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--interactive-accent) 28%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.pwm-icon-button.pwm-button-warning {
|
||||
color: var(--text-warning);
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--text-warning) 16%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.pwm-icon-button.pwm-button-success {
|
||||
color: var(--text-success, var(--color-green, var(--text-accent)));
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--text-success, var(--color-green, var(--text-accent))) 16%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.pwm-modal button:not(.pwm-icon-button):not(.pwm-tag-remove) {
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
border-color 140ms ease,
|
||||
box-shadow 140ms ease,
|
||||
transform 140ms ease;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--interactive-accent) 24%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.pwm-modal button:not(.pwm-icon-button):not(.pwm-tag-remove):not(.mod-cta):hover {
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.pwm-modal button.mod-cta:hover {
|
||||
box-shadow: 0 8px 20px color-mix(in srgb, var(--interactive-accent) 22%, transparent);
|
||||
}
|
||||
|
||||
.pwm-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.pwm-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 6px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
background: var(--background-primary);
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 0 0 rgba(0, 0, 0, 0);
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
border-color 160ms ease,
|
||||
background-color 160ms ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
// transform: translateY(-2px);
|
||||
box-shadow: 0 10px 24px color-mix(in srgb, var(--background-modifier-box-shadow, #000) 18%, transparent);
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
border-color: color-mix(in srgb, var(--interactive-accent) 50%, var(--background-modifier-border));
|
||||
background: color-mix(in srgb, var(--interactive-accent) 10%, var(--background-primary));
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border-color: var(--interactive-accent);
|
||||
box-shadow: inset 0 0 0 1px var(--interactive-accent);
|
||||
// background: color-mix(in srgb, var(--interactive-accent) 14%, var(--background-primary));
|
||||
}
|
||||
|
||||
&.is-drop-target {
|
||||
outline: 1px dashed var(--interactive-accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.pwm-draggable-row[draggable='true'] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.pwm-item-row {
|
||||
position: relative;
|
||||
align-items: flex-start;
|
||||
min-height: 76px;
|
||||
|
||||
.pwm-item-row-actions-bottom {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.pwm-item-row-actions-top {
|
||||
opacity: 0;
|
||||
pointer-events: auto;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
&:hover .pwm-item-row-actions-bottom,
|
||||
&:is(.is-pinned) .pwm-item-row-actions-top,
|
||||
.pwm-item-row-actions-top:hover,
|
||||
.pwm-item-row-actions-top:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover .pwm-item-row-actions-bottom,
|
||||
&:is(.is-pinned) .pwm-item-row-actions-bottom {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.pwm-item-row-actions-top,
|
||||
.pwm-item-row-actions-bottom {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.pwm-item-row-actions-top {
|
||||
top: 8px;
|
||||
}
|
||||
|
||||
.pwm-item-row.is-pinned .pwm-item-row-actions-top .pwm-icon-button,
|
||||
.pwm-pin-button {
|
||||
color: var(--text-error);
|
||||
}
|
||||
|
||||
.pwm-item-row-actions-bottom {
|
||||
bottom: 8px;
|
||||
}
|
||||
|
||||
.pwm-root.is-item-drag-move {
|
||||
.pwm-item-row[draggable='true'] {
|
||||
cursor: grab;
|
||||
}
|
||||
}
|
||||
|
||||
.pwm-root.is-item-drag-add {
|
||||
|
||||
.pwm-group-row,
|
||||
.pwm-item-row[draggable='true'] {
|
||||
cursor: copy;
|
||||
}
|
||||
}
|
||||
|
||||
.pwm-item-body,
|
||||
.pwm-item-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pwm-group-name,
|
||||
.pwm-item-title,
|
||||
.pwm-item-subtitle,
|
||||
.pwm-item-group-tag {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pwm-group-name {
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.pwm-item-title {
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.pwm-item-subtitle {
|
||||
font-size: var(--font-ui-smaller);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.pwm-item-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pwm-item-group-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pwm-badge {
|
||||
flex-shrink: 0;
|
||||
min-width: 22px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--background-modifier-border);
|
||||
text-align: center;
|
||||
font-size: var(--font-ui-smaller);
|
||||
}
|
||||
|
||||
.pwm-empty {
|
||||
padding: 16px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.pwm-detail {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pwm-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
.pwm-textarea-row {
|
||||
position: relative;
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pwm-floating-actions {
|
||||
top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.pwm-field-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pwm-url-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pwm-url-row {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pwm-url-empty {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-smaller);
|
||||
}
|
||||
|
||||
.pwm-input-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&.has-leading-icon input {
|
||||
padding-left: 36px;
|
||||
}
|
||||
|
||||
&.has-floating-actions input {
|
||||
padding-right: 72px;
|
||||
}
|
||||
|
||||
&.has-leading-icon.has-floating-actions input {
|
||||
padding-right: 104px;
|
||||
}
|
||||
}
|
||||
|
||||
.pwm-input-prefix {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.pwm-floating-actions {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 6px;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.pwm-rename-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pwm-tags-footer {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pwm-tags-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pwm-details-bottom-toolbar {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.pwm-footer-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pwm-tags-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-smaller);
|
||||
}
|
||||
|
||||
.pwm-tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pwm-group-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0 8px 8px 0;
|
||||
padding: 6px 8px !important;
|
||||
border-radius: 5px !important;
|
||||
}
|
||||
|
||||
.pwm-tag-remove {
|
||||
border: none;
|
||||
background: transparent;
|
||||
background-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
height: fit-content;
|
||||
border-radius: 4px;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
color 140ms ease,
|
||||
box-shadow 140ms ease,
|
||||
transform 140ms ease;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, currentColor 12%, transparent) !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 20%, transparent) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.pwm-tags-empty {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pwm-confirm-modal {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
body.pwm-column-resizing,
|
||||
body.pwm-column-resizing * {
|
||||
cursor: col-resize !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
2138
src/ui/password-manager-modal.ts
Normal file
2138
src/ui/password-manager-modal.ts
Normal file
File diff suppressed because it is too large
Load diff
128
src/ui/password-prompt-modal.ts
Normal file
128
src/ui/password-prompt-modal.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { ButtonComponent, Modal, Setting, setIcon, type App } from 'obsidian';
|
||||
import { PWM_TEXT } from '../lang';
|
||||
|
||||
const removeFromTabOrder = (element: HTMLElement | null) => {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.tabIndex = -1;
|
||||
};
|
||||
|
||||
export interface PasswordPromptField {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
interface PasswordPromptOptions {
|
||||
title: string;
|
||||
fields: PasswordPromptField[];
|
||||
confirmText: string;
|
||||
cancelText: string;
|
||||
}
|
||||
|
||||
export class PasswordPromptModal extends Modal {
|
||||
private readonly values = new Map<string, string>();
|
||||
private resolver!: (value: Record<string, string> | null) => void;
|
||||
private isResolved = false;
|
||||
|
||||
constructor(app: App, private readonly options: PasswordPromptOptions) {
|
||||
super(app);
|
||||
}
|
||||
|
||||
private submit() {
|
||||
const result = Object.fromEntries(this.options.fields.map((field) => [field.key, this.values.get(field.key) ?? '']));
|
||||
this.finish(result);
|
||||
}
|
||||
|
||||
static open(app: App, options: PasswordPromptOptions) {
|
||||
return new Promise<Record<string, string> | null>((resolve) => {
|
||||
const modal = new PasswordPromptModal(app, options);
|
||||
modal.resolver = resolve;
|
||||
modal.open();
|
||||
});
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
this.titleEl.setText(this.options.title);
|
||||
|
||||
for (const field of this.options.fields) {
|
||||
new Setting(contentEl)
|
||||
.setName(field.label)
|
||||
.addText((text) => {
|
||||
text.inputEl.type = 'password';
|
||||
text.setPlaceholder(field.placeholder ?? '');
|
||||
text.setValue(field.value ?? '');
|
||||
this.values.set(field.key, field.value ?? '');
|
||||
text.inputEl.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
this.submit();
|
||||
}
|
||||
});
|
||||
text.onChange((value) => {
|
||||
this.values.set(field.key, value);
|
||||
});
|
||||
|
||||
const inputContainer = text.inputEl.parentElement;
|
||||
if (inputContainer) {
|
||||
inputContainer.addClass('pwm-input-row', 'has-floating-actions');
|
||||
const actions = inputContainer.createDiv({ cls: 'pwm-inline-actions pwm-floating-actions' });
|
||||
const toggleButton = actions.createEl('button', {
|
||||
cls: 'clickable-icon pwm-icon-button',
|
||||
attr: { type: 'button', 'aria-label': PWM_TEXT.showPassword },
|
||||
});
|
||||
removeFromTabOrder(toggleButton);
|
||||
setIcon(toggleButton, 'eye');
|
||||
toggleButton.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
const isHidden = text.inputEl.type === 'password';
|
||||
text.inputEl.type = isHidden ? 'text' : 'password';
|
||||
setIcon(toggleButton, isHidden ? 'eye-off' : 'eye');
|
||||
toggleButton.setAttr('aria-label', isHidden ? PWM_TEXT.hidePassword : PWM_TEXT.showPassword);
|
||||
});
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (field === this.options.fields[0]) {
|
||||
text.inputEl.focus();
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
const actions = contentEl.createDiv({ cls: 'modal-button-container' });
|
||||
const cancelButton = new ButtonComponent(actions)
|
||||
.setButtonText(this.options.cancelText)
|
||||
.onClick(() => {
|
||||
this.finish(null);
|
||||
});
|
||||
removeFromTabOrder(cancelButton.buttonEl);
|
||||
|
||||
const confirmButton = new ButtonComponent(actions)
|
||||
.setButtonText(this.options.confirmText)
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
this.submit();
|
||||
});
|
||||
removeFromTabOrder(confirmButton.buttonEl);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
this.contentEl.empty();
|
||||
if (!this.isResolved) {
|
||||
this.resolver(null);
|
||||
this.isResolved = true;
|
||||
}
|
||||
}
|
||||
|
||||
private finish(value: Record<string, string> | null) {
|
||||
this.isResolved = true;
|
||||
this.close();
|
||||
this.resolver(value);
|
||||
}
|
||||
}
|
||||
152
src/util/copy-format.ts
Normal file
152
src/util/copy-format.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { PWM_TEXT } from '../lang';
|
||||
import type { PasswordCopyFormat, PasswordGroup, PasswordItem } from './types';
|
||||
|
||||
const COPY_FIELD_LABELS = {
|
||||
title: PWM_TEXT.copyFieldTitle,
|
||||
username: PWM_TEXT.copyFieldUsername,
|
||||
password: PWM_TEXT.copyFieldPassword,
|
||||
url: PWM_TEXT.copyFieldUrl,
|
||||
notes: PWM_TEXT.copyFieldNotes,
|
||||
} as const;
|
||||
|
||||
type ParsedCopyItem = {
|
||||
title?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
url?: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
const COPY_FIELD_KEY_BY_LABEL: Record<string, keyof ParsedCopyItem> = {
|
||||
[COPY_FIELD_LABELS.title]: 'title',
|
||||
[COPY_FIELD_LABELS.username]: 'username',
|
||||
[COPY_FIELD_LABELS.password]: 'password',
|
||||
[COPY_FIELD_LABELS.url]: 'url',
|
||||
[COPY_FIELD_LABELS.notes]: 'notes',
|
||||
};
|
||||
|
||||
function formatLine(label: string, value: string) {
|
||||
return `${label}:${value.trim()}`;
|
||||
}
|
||||
|
||||
function wrapInlineCode(value: string) {
|
||||
const normalized = value.trim();
|
||||
return normalized ? `\`${normalized}\`` : '';
|
||||
}
|
||||
|
||||
function wrapMarkdownLink(value: string) {
|
||||
const normalized = value.trim();
|
||||
return normalized ? `<${normalized}>` : '';
|
||||
}
|
||||
|
||||
function unwrapMarkdownValue(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (normalized.startsWith('`') && normalized.endsWith('`')) {
|
||||
return normalized.slice(1, -1).trim();
|
||||
}
|
||||
|
||||
if (normalized.startsWith('<') && normalized.endsWith('>')) {
|
||||
return normalized.slice(1, -1).trim();
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function applyParsedCopyField(parsed: ParsedCopyItem, label: string, value: string) {
|
||||
const fieldKey = COPY_FIELD_KEY_BY_LABEL[label.trim()];
|
||||
if (!fieldKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
parsed[fieldKey] = unwrapMarkdownValue(value);
|
||||
}
|
||||
|
||||
function getGroupNames(item: PasswordItem, groups: PasswordGroup[]) {
|
||||
const groupNameMap = new Map(groups.map((group) => [group.id, group.name]));
|
||||
return item.groupIds.map((groupId) => groupNameMap.get(groupId)).filter((name): name is string => !!name && !!name.trim());
|
||||
}
|
||||
|
||||
export function formatPasswordItemForCopy(
|
||||
item: PasswordItem,
|
||||
groups: PasswordGroup[],
|
||||
format: PasswordCopyFormat,
|
||||
) {
|
||||
const title = item.title.trim();
|
||||
const username = item.username.trim();
|
||||
const password = item.password.trim();
|
||||
const url = (item.urls[0] ?? '').trim();
|
||||
const notes = item.notes.trim();
|
||||
void getGroupNames(item, groups);
|
||||
|
||||
switch (format) {
|
||||
case 'plain-text':
|
||||
return [
|
||||
formatLine(COPY_FIELD_LABELS.title, title),
|
||||
formatLine(COPY_FIELD_LABELS.username, username),
|
||||
formatLine(COPY_FIELD_LABELS.password, password),
|
||||
formatLine(COPY_FIELD_LABELS.url, url),
|
||||
formatLine(COPY_FIELD_LABELS.notes, notes),
|
||||
].join('\n');
|
||||
case 'callout':
|
||||
return [
|
||||
`> [!info] ${title}`,
|
||||
`> ${COPY_FIELD_LABELS.username}:${wrapInlineCode(username)}`,
|
||||
`> ${COPY_FIELD_LABELS.password}:${wrapInlineCode(password)}`,
|
||||
`> ${COPY_FIELD_LABELS.url}:${wrapMarkdownLink(url)}`,
|
||||
`> ${COPY_FIELD_LABELS.notes}:${notes}`,
|
||||
].join('\n');
|
||||
case 'markdown':
|
||||
default:
|
||||
return [
|
||||
`### ${title}`,
|
||||
'',
|
||||
`- ${COPY_FIELD_LABELS.username}:${wrapInlineCode(username)}`,
|
||||
`- ${COPY_FIELD_LABELS.password}:${wrapInlineCode(password)}`,
|
||||
`- ${COPY_FIELD_LABELS.url}:${wrapMarkdownLink(url)}`,
|
||||
`- ${COPY_FIELD_LABELS.notes}:${notes}`,
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePasswordItemFromCopy(text: string) {
|
||||
const normalizedText = text.replace(/\r\n/g, '\n').trim();
|
||||
const parsed: ParsedCopyItem = {};
|
||||
if (!normalizedText) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const lines = normalizedText.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('### ')) {
|
||||
parsed.title = line.slice(4).trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('## ')) {
|
||||
parsed.title = line.slice(3).trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
const calloutTitleMatch = line.match(/^>\s*\[!info\]\s*(.*)$/);
|
||||
if (calloutTitleMatch) {
|
||||
parsed.title = calloutTitleMatch[1].trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
const fieldLine = line.replace(/^>\s*/, '').replace(/^-\s+/, '');
|
||||
const separatorIndex = fieldLine.indexOf(':');
|
||||
if (separatorIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const label = fieldLine.slice(0, separatorIndex);
|
||||
const value = fieldLine.slice(separatorIndex + 1);
|
||||
applyParsedCopyField(parsed, label, value);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
27
src/util/duplicate-title.ts
Normal file
27
src/util/duplicate-title.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { PWM_TEXT } from '../lang';
|
||||
import type { PasswordItem } from './types';
|
||||
|
||||
export function getNextDuplicatedTitle(items: PasswordItem[], title: string) {
|
||||
const match = title.match(/^(.*?)(?:\s*\((\d+)\))?$/);
|
||||
const baseTitle = match?.[1]?.trim() || title.trim() || PWM_TEXT.untitledItem;
|
||||
const escapedBaseTitle = escapeRegExp(baseTitle);
|
||||
const duplicateRegex = new RegExp(`^${escapedBaseTitle}(?:\\s*\\((\\d+)\\))?$`);
|
||||
|
||||
let maxIndex = 1;
|
||||
items.forEach((item) => {
|
||||
const duplicateMatch = item.title.match(duplicateRegex);
|
||||
if (!duplicateMatch) {
|
||||
return;
|
||||
}
|
||||
const nextIndex = duplicateMatch[1] ? Number(duplicateMatch[1]) : 1;
|
||||
if (Number.isFinite(nextIndex)) {
|
||||
maxIndex = Math.max(maxIndex, nextIndex);
|
||||
}
|
||||
});
|
||||
|
||||
return `${baseTitle} (${maxIndex + 1})`;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
120
src/util/encryption.ts
Normal file
120
src/util/encryption.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import type {
|
||||
EncryptedPasswordLibraryPayload,
|
||||
EncryptedPasswordVerifier,
|
||||
PasswordManagerData,
|
||||
} from './types';
|
||||
|
||||
const KEY_LENGTH = 256;
|
||||
const ITERATIONS = 250000;
|
||||
const IV_LENGTH = 12;
|
||||
const SALT_LENGTH = 16;
|
||||
const PASSWORD_VERIFIER_TEXT = 'obsidian-password-manager:password-verifier';
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
export function isEncryptedLibraryPayload(value: unknown): value is EncryptedPasswordLibraryPayload {
|
||||
return !!value && typeof value === 'object' && (value as EncryptedPasswordLibraryPayload).kind === 'encrypted-library';
|
||||
}
|
||||
|
||||
export function isEncryptedPasswordVerifier(value: unknown): value is EncryptedPasswordVerifier {
|
||||
return !!value && typeof value === 'object' && (value as EncryptedPasswordVerifier).kind === 'password-verifier';
|
||||
}
|
||||
|
||||
export async function encryptPasswordManagerData(data: PasswordManagerData, password: string) {
|
||||
const encrypted = await encryptText(JSON.stringify(data), password);
|
||||
return {
|
||||
version: 1 as const,
|
||||
kind: 'encrypted-library' as const,
|
||||
encryptedAt: Date.now(),
|
||||
salt: encrypted.salt,
|
||||
iv: encrypted.iv,
|
||||
cipherText: encrypted.cipherText,
|
||||
};
|
||||
}
|
||||
|
||||
export async function decryptPasswordManagerData(payload: EncryptedPasswordLibraryPayload, password: string) {
|
||||
const plainText = await decryptText(payload, password);
|
||||
return JSON.parse(plainText) as PasswordManagerData;
|
||||
}
|
||||
|
||||
export async function createPasswordVerifier(password: string) {
|
||||
const encrypted = await encryptText(PASSWORD_VERIFIER_TEXT, password);
|
||||
return {
|
||||
version: 1 as const,
|
||||
kind: 'password-verifier' as const,
|
||||
createdAt: Date.now(),
|
||||
salt: encrypted.salt,
|
||||
iv: encrypted.iv,
|
||||
cipherText: encrypted.cipherText,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, verifier: EncryptedPasswordVerifier) {
|
||||
try {
|
||||
const plainText = await decryptText(verifier, password);
|
||||
return plainText === PASSWORD_VERIFIER_TEXT;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function encryptText(plainText: string, password: string) {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
||||
const key = await deriveKey(password, salt);
|
||||
const cipherBuffer = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
key,
|
||||
encoder.encode(plainText),
|
||||
);
|
||||
return {
|
||||
salt: bytesToBase64(salt),
|
||||
iv: bytesToBase64(iv),
|
||||
cipherText: bytesToBase64(new Uint8Array(cipherBuffer)),
|
||||
};
|
||||
}
|
||||
|
||||
async function decryptText(
|
||||
payload: Pick<EncryptedPasswordLibraryPayload, 'salt' | 'iv' | 'cipherText'>,
|
||||
password: string,
|
||||
) {
|
||||
const salt = base64ToBytes(payload.salt);
|
||||
const iv = base64ToBytes(payload.iv);
|
||||
const cipherBytes = base64ToBytes(payload.cipherText);
|
||||
const key = await deriveKey(password, salt);
|
||||
const plainBuffer = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
key,
|
||||
cipherBytes,
|
||||
);
|
||||
return decoder.decode(plainBuffer);
|
||||
}
|
||||
|
||||
async function deriveKey(password: string, salt: Uint8Array) {
|
||||
const baseKey = await crypto.subtle.importKey('raw', encoder.encode(password), 'PBKDF2', false, ['deriveKey']);
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: salt as BufferSource,
|
||||
iterations: ITERATIONS,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
baseKey,
|
||||
{ name: 'AES-GCM', length: KEY_LENGTH },
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array) {
|
||||
let binary = '';
|
||||
for (const value of bytes) {
|
||||
binary += String.fromCharCode(value);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToBytes(value: string) {
|
||||
const binary = atob(value);
|
||||
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
}
|
||||
79
src/util/file-name.ts
Normal file
79
src/util/file-name.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { PWM_TEXT } from '../lang';
|
||||
|
||||
export function formatDateTimeSuffix(timestamp = Date.now()) {
|
||||
const date = new Date(timestamp);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
const milliseconds = String(date.getMilliseconds()).padStart(3, '0');
|
||||
return `${year}${month}${day}-${hours}${minutes}${seconds}-${milliseconds}`;
|
||||
}
|
||||
|
||||
const INVALID_FILE_NAME_CHARACTERS = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*']);
|
||||
const TRAILING_FILE_NAME_CHARACTERS_REGEX = /[.\s]+$/;
|
||||
const WINDOWS_RESERVED_FILE_NAMES = new Set([
|
||||
'CON',
|
||||
'PRN',
|
||||
'AUX',
|
||||
'NUL',
|
||||
'COM1',
|
||||
'COM2',
|
||||
'COM3',
|
||||
'COM4',
|
||||
'COM5',
|
||||
'COM6',
|
||||
'COM7',
|
||||
'COM8',
|
||||
'COM9',
|
||||
'LPT1',
|
||||
'LPT2',
|
||||
'LPT3',
|
||||
'LPT4',
|
||||
'LPT5',
|
||||
'LPT6',
|
||||
'LPT7',
|
||||
'LPT8',
|
||||
'LPT9',
|
||||
]);
|
||||
|
||||
export function validateFileSafeName(value: string) {
|
||||
const trimmedValue = value.trim();
|
||||
if (!trimmedValue) {
|
||||
return PWM_TEXT.fileNameEmpty;
|
||||
}
|
||||
|
||||
const hasInvalidCharacter = [...trimmedValue].some((char) => {
|
||||
const codePoint = char.codePointAt(0) ?? 0;
|
||||
return INVALID_FILE_NAME_CHARACTERS.has(char) || codePoint <= 0x1F;
|
||||
});
|
||||
if (hasInvalidCharacter) {
|
||||
return PWM_TEXT.fileNameInvalidChars;
|
||||
}
|
||||
|
||||
if (TRAILING_FILE_NAME_CHARACTERS_REGEX.test(trimmedValue)) {
|
||||
return PWM_TEXT.fileNameTrailingChars;
|
||||
}
|
||||
|
||||
const extensionIndex = trimmedValue.indexOf('.');
|
||||
const baseName = (extensionIndex >= 0 ? trimmedValue.slice(0, extensionIndex) : trimmedValue).trim().toUpperCase();
|
||||
if (WINDOWS_RESERVED_FILE_NAMES.has(baseName)) {
|
||||
return PWM_TEXT.fileNameWindowsReserved;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function appendDateTimeSuffix(filename: string, timestamp = Date.now()) {
|
||||
const suffix = formatDateTimeSuffix(timestamp);
|
||||
const extensionIndex = filename.lastIndexOf('.');
|
||||
if (extensionIndex <= 0) {
|
||||
return `${filename}-${suffix}`;
|
||||
}
|
||||
|
||||
const name = filename.slice(0, extensionIndex);
|
||||
const extension = filename.slice(extensionIndex);
|
||||
return `${name}-${suffix}${extension}`;
|
||||
}
|
||||
3
src/util/id.ts
Normal file
3
src/util/id.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function createId() {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
7
src/util/search.ts
Normal file
7
src/util/search.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export function normalizeSearchKeyword(keyword: string) {
|
||||
return keyword.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function includesKeyword(value: string, keyword: string) {
|
||||
return value.toLowerCase().includes(keyword);
|
||||
}
|
||||
107
src/util/sort.ts
Normal file
107
src/util/sort.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import type { DeletedPasswordItem, PasswordGroup, PasswordItem, PwmSortMode } from './types';
|
||||
|
||||
export function sortGroups(groups: PasswordGroup[], items: PasswordItem[], mode: PwmSortMode) {
|
||||
const itemCountByGroupId = new Map<string, number>();
|
||||
items.forEach((item) => {
|
||||
item.groupIds.forEach((groupId) => {
|
||||
itemCountByGroupId.set(groupId, (itemCountByGroupId.get(groupId) ?? 0) + 1);
|
||||
});
|
||||
});
|
||||
|
||||
return [...groups].sort((left, right) =>
|
||||
compareGroupBySortMode(
|
||||
left,
|
||||
right,
|
||||
itemCountByGroupId.get(left.id) ?? 0,
|
||||
itemCountByGroupId.get(right.id) ?? 0,
|
||||
mode,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function sortItems(items: PasswordItem[], mode: PwmSortMode) {
|
||||
return [...items].sort((left, right) =>
|
||||
comparePinned(left.pinned, right.pinned)
|
||||
|| compareItemBySortMode(left, right, mode),
|
||||
);
|
||||
}
|
||||
|
||||
export function sortDeletedItems(items: DeletedPasswordItem[], mode: PwmSortMode) {
|
||||
return [...items].sort((left, right) => compareItemBySortMode(left, right, mode));
|
||||
}
|
||||
|
||||
function comparePinned(leftPinned: boolean, rightPinned: boolean) {
|
||||
return Number(rightPinned) - Number(leftPinned);
|
||||
}
|
||||
|
||||
function compareGroupBySortMode(
|
||||
left: PasswordGroup,
|
||||
right: PasswordGroup,
|
||||
leftItemCount: number,
|
||||
rightItemCount: number,
|
||||
mode: PwmSortMode,
|
||||
) {
|
||||
switch (mode) {
|
||||
case 'item-count-asc':
|
||||
return leftItemCount - rightItemCount
|
||||
|| left.name.localeCompare(right.name, 'zh-Hans-CN')
|
||||
|| left.order - right.order;
|
||||
case 'item-count-desc':
|
||||
return rightItemCount - leftItemCount
|
||||
|| left.name.localeCompare(right.name, 'zh-Hans-CN')
|
||||
|| left.order - right.order;
|
||||
default:
|
||||
return compareBySortMode(left.name, right.name, left.createdAt, right.createdAt, left.order, right.order, mode);
|
||||
}
|
||||
}
|
||||
|
||||
function compareItemBySortMode(left: PasswordItem | DeletedPasswordItem, right: PasswordItem | DeletedPasswordItem, mode: PwmSortMode) {
|
||||
switch (mode) {
|
||||
case 'updated-asc':
|
||||
return left.updatedAt - right.updatedAt
|
||||
|| left.title.localeCompare(right.title, 'zh-Hans-CN')
|
||||
|| left.order - right.order;
|
||||
case 'updated-desc':
|
||||
return right.updatedAt - left.updatedAt
|
||||
|| left.title.localeCompare(right.title, 'zh-Hans-CN')
|
||||
|| left.order - right.order;
|
||||
case 'deleted-asc':
|
||||
return compareDeletedAt(left, right)
|
||||
|| left.title.localeCompare(right.title, 'zh-Hans-CN')
|
||||
|| left.order - right.order;
|
||||
case 'deleted-desc':
|
||||
return compareDeletedAt(right, left)
|
||||
|| left.title.localeCompare(right.title, 'zh-Hans-CN')
|
||||
|| left.order - right.order;
|
||||
default:
|
||||
return compareBySortMode(left.title, right.title, left.createdAt, right.createdAt, left.order, right.order, mode);
|
||||
}
|
||||
}
|
||||
|
||||
function compareDeletedAt(left: PasswordItem | DeletedPasswordItem, right: PasswordItem | DeletedPasswordItem) {
|
||||
return ('deletedAt' in left ? left.deletedAt : 0) - ('deletedAt' in right ? right.deletedAt : 0);
|
||||
}
|
||||
|
||||
export function compareBySortMode(
|
||||
leftName: string,
|
||||
rightName: string,
|
||||
leftCreatedAt: number,
|
||||
rightCreatedAt: number,
|
||||
leftOrder: number,
|
||||
rightOrder: number,
|
||||
mode: PwmSortMode,
|
||||
) {
|
||||
switch (mode) {
|
||||
case 'name-asc':
|
||||
return leftName.localeCompare(rightName, 'zh-Hans-CN') || leftOrder - rightOrder;
|
||||
case 'name-desc':
|
||||
return rightName.localeCompare(leftName, 'zh-Hans-CN') || leftOrder - rightOrder;
|
||||
case 'created-asc':
|
||||
return leftCreatedAt - rightCreatedAt || leftOrder - rightOrder;
|
||||
case 'created-desc':
|
||||
return rightCreatedAt - leftCreatedAt || leftOrder - rightOrder;
|
||||
case 'custom':
|
||||
default:
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
}
|
||||
132
src/util/types.ts
Normal file
132
src/util/types.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import type { PasswordManagerSettings, PasswordPluginConfig } from './settings';
|
||||
|
||||
export type PwmSortMode =
|
||||
| 'custom'
|
||||
| 'name-asc'
|
||||
| 'name-desc'
|
||||
| 'created-asc'
|
||||
| 'created-desc'
|
||||
| 'updated-asc'
|
||||
| 'updated-desc'
|
||||
| 'deleted-asc'
|
||||
| 'deleted-desc'
|
||||
| 'item-count-asc'
|
||||
| 'item-count-desc';
|
||||
export type PasswordCopyFormat = 'markdown' | 'plain-text' | 'callout';
|
||||
export type PasswordUnlockMode = 'session' | 'interval' | 'always';
|
||||
|
||||
export interface PasswordItem {
|
||||
id: string;
|
||||
groupIds: string[];
|
||||
title: string;
|
||||
username: string;
|
||||
password: string;
|
||||
urls: string[];
|
||||
notes: string;
|
||||
pinned: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface DeletedPasswordItem extends PasswordItem {
|
||||
deletedAt: number;
|
||||
deletedGroupNames?: string[];
|
||||
}
|
||||
|
||||
export interface PasswordTrashData {
|
||||
items: DeletedPasswordItem[];
|
||||
}
|
||||
|
||||
export interface PasswordGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: number;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface PasswordManagerViewState {
|
||||
groupSort: PwmSortMode;
|
||||
itemSort: PwmSortMode;
|
||||
lastMode: 'default' | 'trash';
|
||||
lastSelectedGroupId: string;
|
||||
lastSelectedItemId: string;
|
||||
groupColumnWidth: number;
|
||||
itemColumnWidth: number;
|
||||
}
|
||||
|
||||
export interface PasswordManagerSettings {
|
||||
confirmBeforeDelete: boolean;
|
||||
copyFormat: PasswordCopyFormat;
|
||||
showItemUsername: boolean;
|
||||
showItemUrl: boolean;
|
||||
showItemGroupTags: boolean;
|
||||
showItemNotes: boolean;
|
||||
}
|
||||
|
||||
export interface EncryptedPasswordLibraryPayload {
|
||||
version: 1;
|
||||
kind: 'encrypted-library';
|
||||
encryptedAt: number;
|
||||
salt: string;
|
||||
iv: string;
|
||||
cipherText: string;
|
||||
}
|
||||
|
||||
export interface EncryptedPasswordVerifier {
|
||||
version: 1;
|
||||
kind: 'password-verifier';
|
||||
createdAt: number;
|
||||
salt: string;
|
||||
iv: string;
|
||||
cipherText: string;
|
||||
}
|
||||
|
||||
export interface PasswordPluginConfig {
|
||||
storageFolderName: string;
|
||||
autoBackupEnabled: boolean;
|
||||
autoBackupCount: number;
|
||||
autoBackupIntervalMinutes: number;
|
||||
trashRetentionDays: number;
|
||||
lastAutoBackupAt: number;
|
||||
encryptionEnabled: boolean;
|
||||
encryptionUnlockMode: PasswordUnlockMode;
|
||||
encryptionRecheckIntervalMinutes: number;
|
||||
encryptionVerifier: EncryptedPasswordVerifier | null;
|
||||
persistEncryptionPassword: boolean;
|
||||
savedEncryptionPassword: string;
|
||||
modalWidthExpr: string;
|
||||
modalHeightExpr: string;
|
||||
columnRatioExpr: string;
|
||||
columnRatioLocked: boolean;
|
||||
}
|
||||
|
||||
export interface PasswordManagerData {
|
||||
groups: PasswordGroup[];
|
||||
items: PasswordItem[];
|
||||
trash: DeletedPasswordItem[];
|
||||
view: PasswordManagerViewState;
|
||||
settings: PasswordManagerSettings;
|
||||
}
|
||||
|
||||
export interface PwmFieldAction {
|
||||
icon: string;
|
||||
label: string;
|
||||
onClick: (input: HTMLInputElement | HTMLTextAreaElement, button: HTMLButtonElement) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface PwmTextFieldOptions {
|
||||
leadingIcon?: string;
|
||||
}
|
||||
|
||||
export interface PasswordManagerExportPayload {
|
||||
version: 1;
|
||||
kind: 'library' | 'group' | 'groups' | 'item' | 'items';
|
||||
exportedAt: number;
|
||||
data:
|
||||
| PasswordManagerData
|
||||
| { group: PasswordGroup; items: PasswordItem[] }
|
||||
| { groups: Array<{ group: PasswordGroup; items: PasswordItem[] }> }
|
||||
| PasswordItem
|
||||
| PasswordItem[];
|
||||
}
|
||||
8
styles.css
Normal file
8
styles.css
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
|
||||
This CSS file will be included with your plugin, and
|
||||
available in the app when your plugin is enabled.
|
||||
|
||||
If your plugin does not need CSS, delete this file.
|
||||
|
||||
*/
|
||||
30
tsconfig.json
Normal file
30
tsconfig.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitThis": true,
|
||||
"noImplicitReturns": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"strictBindCallApply": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"useUnknownInCatchVariables": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
17
version-bump.mjs
Normal file
17
version-bump.mjs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const targetVersion = process.env.npm_package_version;
|
||||
|
||||
// read minAppVersion from manifest.json and bump version to target version
|
||||
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||
const { minAppVersion } = manifest;
|
||||
manifest.version = targetVersion;
|
||||
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||
|
||||
// update versions.json with target version and minAppVersion from manifest.json
|
||||
// but only if the target version is not already in versions.json
|
||||
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
|
||||
if (!Object.values(versions).includes(minAppVersion)) {
|
||||
versions[targetVersion] = minAppVersion;
|
||||
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
|
||||
}
|
||||
3
versions.json
Normal file
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"1.0.0": "0.15.0"
|
||||
}
|
||||
Loading…
Reference in a new issue