feat: comprehensive bug fixes, FolderOpener, English README, LICENSE

- FolderOpener: new utility replacing electron.shell for cross-platform folder/file opening
- AnnotationDecorator: fix decoration state management and auto-healed rendering
- SelectionPopover: fix layout and interaction bugs
- SidebarView: fix card display and agent button states
- SettingsTab: settings UI improvements and fixes
- APIExecuteModal: fix provider selection and connection test
- DiffModal: improve chunk display and accept/reject flow
- CommandConfirmModal: fix command preview and shell escaping
- AgentSelectModal: fix agent detection display
- AnnotationStore: fix sidecar read/write edge cases
- BottomToolbar: fix mobile toolbar layout
- Exporters: fix export path and content generation
- path.ts: fix path utilities for hidden directories
- icons.ts: update icon references
- esbuild.config.mjs: build config updates
- styles.css: major CSS refactoring and polish
- manifest.json: bump version
- Add LICENSE (MIT)
- Add README_en.md (English README)
- Add docs/obsidian-hidden-dir-pitfalls.md
- Add src/svg.d.ts for SVG type declarations
This commit is contained in:
megan-blue 2026-06-16 00:03:02 +08:00
parent dc78adbc53
commit b000420beb
27 changed files with 2275 additions and 1274 deletions

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 婧晶
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

339
README.md
View file

@ -4,73 +4,76 @@
<img src="img/logo.gif" width="128" height="128" alt="MultiAIEdit Logo">
</p>
> Obsidian 阅读标注与 AI 批阅插件。阅读时高亮记笔记,批阅时留下修改意见,一键交给外部 AI 执行修改。
> An Obsidian plugin for reading annotations and AI-powered batch review. Highlight and take notes while reading, leave review comments, then hand them off to external AI agents for execution — all in one click.
[中文文档](README_zh.md)
---
## 目标人群与场景
## Target Users & Scenarios
**谁在用**
**Who is it for**
- **产品经理 / 编辑 / 写作者**:批阅产品文档、方案、周报,需要大量微调但不想逐条手改
- **研究者**:阅读论文 / 报告时标记重点、补充想法
- **团队负责人**:审阅下属文档时给出修改意见
- **开发者**审阅技术文档、README、PRD与 CLI Agent 用户高度重叠
- **Product managers / Editors / Writers**: Review product docs, proposals, weekly reports — lots of small tweaks, but don't want to edit each one manually
- **Researchers**: Mark key points and add thoughts while reading papers / reports
- **Team leads**: Give revision feedback on team members' documents
- **Developers**: Review tech docs, READMEs, PRDs — high overlap with CLI Agent users
**解决什么问题**
**What problem does it solve**
在 Obsidian 里阅读和批阅文档时,想法和修改意见分散在脑中或聊天窗口里,难以低成本记录、汇总并交给 AI 批量执行。现有高亮 / 笔记插件停在"标注记录",没有打通**阅读标注 → 批阅意见 → 外部 AI 修改**的链路。
When reading and reviewing documents in Obsidian, ideas and revision notes end up scattered across your mind or chat windows. It's hard to capture them at low cost, aggregate them, and hand them to AI for batch execution. Existing highlight / note plugins stop at "annotation recording" — none bridge the gap from **reading annotations → review comments → external AI modification**.
---
## 主要功能
## Key Features
### 阅读模式
### Reading Mode
- **4 色高亮**:选中文本后单击颜色,即刻标注,不污染原文(存储于 sidecar JSON
- **阅读笔记**:选中文本后点击 Note 展开输入区,快速添加文字笔记,与原文绑定
- **4-color highlights**: Select text → click a color → instant highlight, no modification to the original file (stored in sidecar JSON)
- **Reading notes**: Select text → click Note to expand the input area → add text notes, bound to the original text
### 批阅模式
### Review Mode
- **删除线Delete**:一键标记"强删除/合并"意图,选中即生效
- **自然语言批阅意见**:点击 Note 展开输入区,直接写"改得更口语化""这里补一个案例",不强迫分类
- **侧边栏批阅列表**:所有意见汇总,支持跳转、编辑、删除
- **Strikethrough (Delete)**: One click to mark text as "strongly delete / merge" intent
- **Natural language review comments**: Click Note to expand the input area, write things like "make this more conversational" or "add an example here" — no forced categorization
- **Sidebar review list**: All comments aggregated, with jump / edit / delete support
### 浮窗交互
### Floating Toolbar
选中文本后弹出浮窗,纵向三段式布局:
Select text and a floating toolbar appears with a vertical three-section layout:
1. **模式胶囊**:顶部 [阅读] / [批阅] 快速切换
2. **按钮行**:阅读模式 → 4 色高亮 + Note批阅模式 → Delete + Note
3. **Note 展开区**:点击 Note 后向下展开,含颜色标记 + 标签 + 输入框 + Save
1. **Mode capsule**: Top [Reading] / [Review] quick switch
2. **Button row**: Reading mode → 4 color highlights + Note; Review mode → Delete + Note
3. **Note expansion area**: Click Note to expand downward, with color marker + label + input field + Save
### AI 执行方式(四条路径)
### AI Execution (Four Paths)
| 路径 | 优先级 | 平台 | 说明 |
|------|--------|------|------|
| 导出批阅文件 | P0 | 全平台 | 生成 Markdown 指令文件,可上传给任意 AI |
| 复制 Prompt | P0 | 全平台 | 原文 + 批阅意见一键复制到剪贴板 |
| CLI 一键执行 | P1 | 桌面端 | 调用 Claude Code / Codex / Aider / Gemini CLI 等,自动唤起终端 |
| API Key 直调 | P2 | 桌面端 | BYOK 直连 Anthropic / OpenAI / DeepSeek / Gemini / 自定义端点,插件内闭环 |
| Path | Priority | Platform | Description |
|------|----------|----------|-------------|
| Export review file | P0 | All | Generate Markdown instruction file, uploadable to any AI |
| Copy Prompt | P0 | All | Original text + review comments copied to clipboard in one click |
| CLI one-click execution | P1 | Desktop | Call Claude Code / Codex / Aider / Gemini CLI etc., auto-launch terminal |
| API Key direct call | P2 | Desktop | BYOK direct connect to Anthropic / OpenAI / DeepSeek / Gemini / custom endpoint, closed loop within plugin |
### Diff 确认与自动重锚(桌面端)
### Diff Preview & Auto-Reanchoring (Desktop)
AI 或 API 修改文件后,插件自动比对差异,提供**全部接受 / 逐块接受 / 回滚**。
After AI or API modifies a file, the plugin automatically compares differences and offers **Accept All / Accept by Chunk / Rollback**.
确认后自动重锚批注位置:
1. **方案 BDiff 回锚)**:基于 LCS diff 计算旧偏移 → 新偏移映射,精准更新
2. **方案 A模糊定位兜底**:编辑距离 + trigram 相似度滑动窗口,自动修复漂移批注
3. 已执行的批阅意见自动移除,失效阅读批注自动清理
After confirmation, annotation positions are automatically re-anchored:
1. **Strategy B (Diff re-anchor)**: Compute old offset → new offset mapping based on LCS diff, precise update
2. **Strategy A (fuzzy locate fallback)**: Edit distance + trigram similarity sliding window, auto-repair drifted annotations
3. Executed review comments are automatically removed; invalid reading annotations auto-cleaned
---
## 使用方式
## Getting Started
### 1. 安装
### 1. Installation
> 当前为开发版,尚未上架 Obsidian 社区插件市场。
> Currently a development release — not yet on the Obsidian Community Plugins marketplace.
**手动安装:**
**Manual install:**
```bash
git clone https://github.com/your-repo/obsidian-plugin-multiaiedit
@ -79,38 +82,38 @@ npm install
npm run build
```
`main.js`、`manifest.json`、`styles.css` 复制到 Vault 的 `.obsidian/plugins/multiaiedit/` 目录,重启 Obsidian 后在「设置 → 第三方插件」中启用。
Copy `main.js`, `manifest.json`, and `styles.css` to your Vault's `.obsidian/plugins/multiaiedit/` directory, then restart Obsidian and enable the plugin under **Settings → Community plugins**.
---
### 2. 阅读标注
### 2. Reading Annotations
1. 打开任意 Markdown 文件
2. 点击左侧 Ribbon 高亮笔图标或命令面板搜索「打开 MultiAIEdit 侧边栏」
3. 选择顶部胶囊 **「阅读」**
4. 选中文本 → 浮层出现模式胶囊 + 颜色按钮 → 单击颜色完成高亮
5. 点击 **Note** 展开输入区,添加文字说明后点 Save
1. Open any Markdown file
2. Click the highlighter icon in the left Ribbon, or search "Open MultiAIEdit Sidebar" in the Command Palette
3. Select the **Reading** capsule at the top
4. Select text → floating toolbar appears with mode capsule + color buttons → click a color to highlight
5. Click **Note** to expand the input area, add text notes, then click Save
---
### 3. 批阅文档
### 3. Reviewing Documents
1. 切换浮窗或侧边栏顶部胶囊到 **「批阅」**
2. 选中要修改的文本
3. 点击 **Delete** 切换删除线(再次点击可取消),或点击 **Note** 展开输入区写修改意见
4. 所有意见汇总在侧边栏批阅列表
1. Switch the floating toolbar or sidebar top capsule to **Review**
2. Select the text you want to revise
3. Click **Delete** to toggle strikethrough (click again to remove), or click **Note** to expand the input area and write review comments
4. All comments are aggregated in the sidebar review list
---
### 4. 导出给 AI 执行
### 4. Export for AI Execution
**方式 A导出文件 / 复制 Prompt全平台**
**Method A: Export File / Copy Prompt (All platforms)**
点击侧边栏底部「导出批阅文件」或「复制 Prompt」将生成的 Markdown 指令文件上传给 ChatGPT、Claude Web 等任意 AI 工具。
Click "Export Review File" or "Copy Prompt" at the bottom of the sidebar, then upload the generated Markdown instruction file to any AI tool like ChatGPT, Claude Web, etc.
**方式 BCLI 一键执行(桌面端)**
**Method B: CLI One-Click Execution (Desktop)**
需先安装任意一款 Agent CLI
First install any Agent CLI:
```bash
npm i -g @anthropic-ai/claude-code # Claude Code
@ -119,129 +122,129 @@ pip install aider-chat # Aider
npm i -g @google/gemini-cli # Gemini CLI
```
在侧边栏底部点击 Agent 按钮 → 选择已安装的 Agent → 确认命令 → 终端自动执行 → 执行完成后自动弹出 Diff 预览。
Click an Agent button at the bottom of the sidebar → select an installed Agent → confirm command → terminal auto-executes → Diff preview pops up after completion.
未安装的 Agent 按钮灰显,点击后复制命令到剪贴板手动执行。
Uninstalled Agent buttons appear grayed out; clicking copies the command to clipboard for manual execution.
**方式 CAPI Key 直调(桌面端)**
**Method C: API Key Direct Call (Desktop)**
无需安装任何 CLI 工具,在插件内直接调用模型 API。
No CLI tools needed — call model APIs directly from within the plugin.
1. 打开「设置 → API 直调」
2. 选择 ProviderAnthropic / OpenAI / DeepSeek / Gemini / 自定义端点)
3. 填写 API Key 和模型名
4. 点击「测试」验证连接
5. 在命令面板(`Cmd+P`)搜索「**API Key 直调执行**」或使用侧边栏按钮
6. 确认隐私提示 → 插件调用 API → 自动弹出 Diff 预览
1. Open **Settings → API Direct Call**
2. Select Provider (Anthropic / OpenAI / DeepSeek / Gemini / Custom Endpoint)
3. Enter API Key and model name
4. Click "Test" to verify the connection
5. Search "**API Key Direct Call Execute**" in the Command Palette (`Cmd+P`) or use the sidebar button
6. Confirm the privacy notice → plugin calls API → Diff preview auto-pops up
---
### 5. Diff 预览与确认
### 5. Diff Preview & Confirmation
AI 执行修改后,插件展示逐行差异:
After AI executes modifications, the plugin displays line-by-line differences:
- **全部接受**:保留所有 AI 修改
- **逐块接受**:勾选想保留的修改片段
- **回滚**:恢复原文,所有修改丢弃
- **Accept All**: Keep all AI modifications
- **Accept by Chunk**: Select which modification chunks to keep
- **Rollback**: Restore original text, discard all changes
确认后自动重锚批注,已执行的批阅意见自动移除。
After confirmation, annotations are automatically re-anchored and executed review comments are removed.
---
## 设置项
## Settings
### 基础设置
### General
| 设置 | 说明 | 默认值 |
|------|------|--------|
| 默认模式 | 侧边栏初始状态(阅读/批阅/全部) | 阅读 |
| 上下文长度 | 批注前后保存的字符数,用于锚点定位 | 50 |
| Sidecar 目录 | 批注 JSON 存储位置 | `.multiaiedit/annotations` |
| 导出目录 | 批阅文件保存位置 | `.multiaiedit/exports` |
| 导出时附带阅读笔记 | 将阅读笔记作为参考上下文一并导出 | 否 |
| Setting | Description | Default |
|---------|-------------|---------|
| Default mode | Sidebar initial state (Reading / Review / All) | Reading |
| Context length | Characters saved before and after annotation, used for anchor positioning | 50 |
| Sidecar directory | Annotation JSON storage location | `.multiaiedit/annotations` |
| Export directory | Review file save location | `.multiaiedit/exports` |
| Include reading notes on export | Export reading notes as reference context alongside review comments | No |
### Agent 与终端(桌面端)
### Agent & Terminal (Desktop)
| 设置 | 说明 | 默认值 |
|------|------|--------|
| 终端应用 | macOS CLI 执行使用的终端 | Terminal |
| 自定义命令规则 | 用户自定义 Agent CLI 命令模板 | — |
| Setting | Description | Default |
|---------|-------------|---------|
| Terminal app | Terminal used for CLI execution on macOS | Terminal |
| Custom command rules | User-defined Agent CLI command templates | — |
预设 Agent5 个):
Predefined Agents (5):
| Agent | 检测命令 | 安装方式 |
|-------|---------|---------|
| Agent | Detection command | Install method |
|-------|------------------|----------------|
| Claude Code | `which claude` | `npm i -g @anthropic-ai/claude-code` |
| Claude Internal | `which claude-internal` | 内部版本 |
| Claude Internal | `which claude-internal` | Internal version |
| Codex CLI | `which codex` | `npm i -g @openai/codex` |
| Aider | `which aider` | `pip install aider-chat` |
| Gemini CLI | `which gemini` | `npm i -g @google/gemini-cli` |
可用模板变量:`{{vaultPath}}` `{{instructionFile}}` `{{filePath}}` `{{fileName}}` `{{prompt}}`
Available template variables: `{{vaultPath}}` `{{instructionFile}}` `{{filePath}}` `{{fileName}}` `{{prompt}}`
### API 直调(桌面端)
### API Direct Call (Desktop)
| 设置 | 说明 | 默认值 |
|------|------|--------|
| Provider | Anthropic / OpenAI / DeepSeek / Gemini / 自定义 | Anthropic |
| API Key | 仅保存在本地,不上传 | — |
| 模型 | 留空使用默认值 | 按 Provider 不同 |
| 自定义端点 URL | OpenAI 兼容格式(仅自定义模式) | — |
| 最大输出 Token | API 调用的最大返回 Token 数 | 4096 |
| 测试连接 | 发送最小请求验证 API Key 是否有效 | — |
| Setting | Description | Default |
|---------|-------------|---------|
| Provider | Anthropic / OpenAI / DeepSeek / Gemini / Custom | Anthropic |
| API Key | Stored locally only, never uploaded | — |
| Model | Leave empty for default | Varies by provider |
| Custom endpoint URL | OpenAI-compatible format (Custom mode only) | — |
| Max output tokens | Maximum tokens for API response | 4096 |
| Test connection | Send minimal request to verify API Key validity | — |
---
## 支持的 AI 工具
## Supported AI Tools
| AI 工具 | 接入方式 |
|---------|----------|
| Claude Code | CLI 一键执行 |
| Claude Internal | CLI 一键执行 |
| Codex CLI | CLI 一键执行 |
| Aider | CLI 一键执行 |
| Gemini CLI | CLI 一键执行 |
| 自定义 Agent CLI | CLI 一键执行 |
| Anthropic API | API Key 直调 |
| OpenAI API | API Key 直调 |
| DeepSeek API | API Key 直调 |
| Google Gemini API | API Key 直调 |
| 自定义 OpenAI 兼容端点 | API Key 直调 |
| ChatGPT Web / Claude Web | 导出文件 / 复制 Prompt |
| AI Tool | Access method |
|---------|---------------|
| Claude Code | CLI one-click execution |
| Claude Internal | CLI one-click execution |
| Codex CLI | CLI one-click execution |
| Aider | CLI one-click execution |
| Gemini CLI | CLI one-click execution |
| Custom Agent CLI | CLI one-click execution |
| Anthropic API | API Key direct call |
| OpenAI API | API Key direct call |
| DeepSeek API | API Key direct call |
| Google Gemini API | API Key direct call |
| Custom OpenAI-compatible endpoint | API Key direct call |
| ChatGPT Web / Claude Web | Export file / Copy Prompt |
---
## 平台支持
## Platform Support
| 功能 | 桌面端 | 移动端 |
|------|--------|--------|
| 阅读高亮 / 笔记 | ✅ | ✅(底部工具栏) |
| 批阅意见 | ✅ | ✅(底部工具栏) |
| 侧边栏 | ✅ | ✅ |
| 导出批阅文件 | ✅ | ✅ |
| 复制 Prompt | ✅ | ✅ |
| CLI 一键执行 | ✅macOS 完整Win/Linux 复制命令) | ❌ |
| API Key 直调 | ✅ | ❌ |
| Diff 预览与确认 | ✅ | ❌ |
| 自动重锚 | ✅ | ❌ |
| Feature | Desktop | Mobile |
|---------|---------|--------|
| Reading highlights / notes | ✅ | ✅ (bottom toolbar) |
| Review comments | ✅ | ✅ (bottom toolbar) |
| Sidebar | ✅ | ✅ |
| Export review file | ✅ | ✅ |
| Copy Prompt | ✅ | ✅ |
| CLI one-click execution | ✅ (full on macOS, copy command on Win/Linux) | ❌ |
| API Key direct call | ✅ | ❌ |
| Diff preview & confirmation | ✅ | ❌ |
| Auto-reanchoring | ✅ | ❌ |
> 移动端使用底部工具栏BottomToolbar替代桌面端浮窗避开 iOS/Android 系统选区菜单冲突。
> Mobile uses a bottom toolbar (BottomToolbar) instead of the desktop floating toolbar to avoid conflicts with iOS/Android system selection menus.
---
## 数据存储
## Data Storage
批注以 **sidecar JSON** 格式存储在 `.multiaiedit/annotations/` 目录下,不修改原始 Markdown 文件。
Annotations are stored as **sidecar JSON** in the `.multiaiedit/annotations/` directory — original Markdown files are never modified.
```
vault/
├── .multiaiedit/
│ ├── annotations/ # 批注数据(每个文件一个 JSON
│ └── exports/ # 导出的批阅指令文件
├── 你的文档.md # 原文不被修改
│ ├── annotations/ # Annotation data (one JSON per file)
│ └── exports/ # Exported review instruction files
├── your-doc.md # Original file is untouched
```
批注 JSON 结构AnnotationFile
Annotation JSON structure (AnnotationFile):
```json
{
@ -252,61 +255,61 @@ vault/
{
"id": "ann_xxx",
"type": "highlight | note | review",
"selectedText": "选中的文本",
"selectedText": "selected text",
"contextBefore": "...",
"contextAfter": "...",
"lineHint": 5,
"occurrenceIndex": 0,
"highlightColor": "yellow | blue | green | purple",
"noteText": "阅读笔记内容",
"reviewText": "批阅意见内容",
"noteText": "reading note content",
"reviewText": "review comment content",
"strike": false
}
]
}
```
API Key 仅保存在 Obsidian 本地插件数据(`this.saveData()`),不上传,不经过任何代理。
API Keys are stored only in Obsidian's local plugin data (`this.saveData()`), never uploaded, never proxied.
---
## 锚点定位机制
## Anchor Positioning
批注不存储偏移量from/to加载时通过文本搜索动态计算
Annotations do not store offsets (from/to); positions are computed dynamically at load time via text search:
1. **精确匹配**`selectedText` 在文档中唯一 → 直接定位
2. **occurrenceIndex + lineHint 消歧**:重复段落用出现序号 + 行号提示
3. **模糊定位fuzzyLocate**:编辑距离 + trigram 相似度滑动窗口
4. **漂移检测**`baselineHash` 比对,文档变更后自动提醒
5. **自动修复**Agent Diff 确认后自动重锚(方案 B + 方案 A 兜底)
1. **Exact match**: `selectedText` is unique in the document → direct positioning
2. **occurrenceIndex + lineHint disambiguation**: Repeated paragraphs use occurrence sequence + line hint
3. **Fuzzy locate (fuzzyLocate)**: Edit distance + trigram similarity sliding window
4. **Drift detection**: `baselineHash` comparison, auto-alert when document changes
5. **Auto-repair**: After Agent Diff confirmation, auto-reanchor (Strategy B + Strategy A fallback)
---
## 版本路线
## Version Roadmap
| 版本 | 状态 | 主要功能 |
|------|------|----------|
| v0.1 | ✅ 完成 | 阅读高亮、批阅意见、导出文件、复制 Prompt |
| v0.2 | ✅ 完成 | CLI 一键执行、Diff 预览、自动重锚 |
| v0.3 | 规划中 | 全库标注视图、跨文件搜索、JSON 导出 |
| v0.4 | ✅ 完成 | API Key 直调Anthropic / OpenAI / DeepSeek / Gemini / 自定义) |
| v0.5 | 规划中 | MCP Server、批注集合、从其他插件迁移 |
| Version | Status | Key Features |
|---------|--------|--------------|
| v0.1 | ✅ Done | Reading highlights, review comments, export file, copy Prompt |
| v0.2 | ✅ Done | CLI one-click execution, Diff preview, auto-reanchoring |
| v0.3 | Planned | Vault-wide annotation view, cross-file search, JSON export |
| v0.4 | ✅ Done | API Key direct call (Anthropic / OpenAI / DeepSeek / Gemini / Custom) |
| v0.5 | Planned | MCP Server, annotation collections, migration from other plugins |
---
## 命令面板快捷命令
## Command Palette Shortcuts
| 命令 | 说明 |
|------|------|
| 打开 MultiAIEdit 侧边栏 | 打开右侧侧边栏 |
| 高亮(黄) | 快速黄色高亮选中文本 |
| 添加笔记 | 为选中文本添加阅读笔记 |
| 添加批阅意见 | 为选中文本添加批阅 |
| 导出批阅文件 | 导出当前文件的批阅意见 |
| 复制 Prompt | 一键复制到剪贴板 |
| 使用 Claude Code 执行 | CLI 一键执行 |
| 使用 Codex CLI 执行 | CLI 一键执行 |
| 使用 Aider 执行 | CLI 一键执行 |
| 使用 Gemini CLI 执行 | CLI 一键执行 |
| 复制 Agent 命令 | 复制命令到剪贴板 |
| API Key 直调执行 | 插件内直接调用模型 API |
| Command | Description |
|---------|-------------|
| Open MultiAIEdit Sidebar | Open the right sidebar |
| Highlight (Yellow) | Quick yellow highlight for selected text |
| Add Note | Add a reading note for selected text |
| Add Review Comment | Add a review comment for selected text |
| Export Review File | Export review comments for the current file |
| Copy Prompt | Copy to clipboard in one click |
| Execute with Claude Code | CLI one-click execution |
| Execute with Codex CLI | CLI one-click execution |
| Execute with Aider | CLI one-click execution |
| Execute with Gemini CLI | CLI one-click execution |
| Copy Agent Command | Copy command to clipboard |
| API Key Direct Call Execute | Call model API directly from plugin |

312
README_zh.md Normal file
View file

@ -0,0 +1,312 @@
# MultiAIEdit
<p align="center">
<img src="img/logo.gif" width="128" height="128" alt="MultiAIEdit Logo">
</p>
> Obsidian 阅读标注与 AI 批量批阅插件。阅读时高亮记笔记,批阅时留下修改意见,多条意见一键交给外部 AI 执行修改。
[English](README.md)
---
## 目标人群与场景
**谁在用**
- **产品经理 / 编辑 / 写作者**:批阅产品文档、方案、周报,需要大量微调但不想逐条手改
- **研究者**:阅读论文 / 报告时标记重点、补充想法
- **团队负责人**:审阅下属文档时给出修改意见
- **开发者**审阅技术文档、README、PRD与 CLI Agent 用户高度重叠
**解决什么问题**
在 Obsidian 里阅读和批阅文档时,想法和修改意见分散在脑中或聊天窗口里,难以低成本记录、汇总并交给 AI 批量执行。现有高亮 / 笔记插件停在"标注记录",没有打通**阅读标注 → 批阅意见 → 外部 AI 修改**的链路。
---
## 主要功能
### 阅读模式
- **4 色高亮**:选中文本后单击颜色,即刻标注,不污染原文(存储于 sidecar JSON
- **阅读笔记**:选中文本后点击 Note 展开输入区,快速添加文字笔记,与原文绑定
### 批阅模式
- **删除线Delete**:一键标记"强删除/合并"意图,选中即生效
- **自然语言批阅意见**:点击 Note 展开输入区,直接写"改得更口语化""这里补一个案例",不强迫分类
- **侧边栏批阅列表**:所有意见汇总,支持跳转、编辑、删除
### 浮窗交互
选中文本后弹出浮窗,纵向三段式布局:
1. **模式胶囊**:顶部 [阅读] / [批阅] 快速切换
2. **按钮行**:阅读模式 → 4 色高亮 + Note批阅模式 → Delete + Note
3. **Note 展开区**:点击 Note 后向下展开,含颜色标记 + 标签 + 输入框 + Save
### AI 执行方式(四条路径)
| 路径 | 优先级 | 平台 | 说明 |
|------|--------|------|------|
| 导出批阅文件 | P0 | 全平台 | 生成 Markdown 指令文件,可上传给任意 AI |
| 复制 Prompt | P0 | 全平台 | 原文 + 批阅意见一键复制到剪贴板 |
| CLI 一键执行 | P1 | 桌面端 | 调用 Claude Code / Codex / Aider / Gemini CLI 等,自动唤起终端 |
| API Key 直调 | P2 | 桌面端 | BYOK 直连 Anthropic / OpenAI / DeepSeek / Gemini / 自定义端点,插件内闭环 |
### Diff 确认与自动重锚(桌面端)
AI 或 API 修改文件后,插件自动比对差异,提供**全部接受 / 逐块接受 / 回滚**。
确认后自动重锚批注位置:
1. **方案 BDiff 回锚)**:基于 LCS diff 计算旧偏移 → 新偏移映射,精准更新
2. **方案 A模糊定位兜底**:编辑距离 + trigram 相似度滑动窗口,自动修复漂移批注
3. 已执行的批阅意见自动移除,失效阅读批注自动清理
---
## 使用方式
### 1. 安装
> 当前为开发版,尚未上架 Obsidian 社区插件市场。
**手动安装:**
```bash
git clone https://github.com/your-repo/obsidian-plugin-multiaiedit
cd obsidian-plugin-multiaiedit
npm install
npm run build
```
`main.js`、`manifest.json`、`styles.css` 复制到 Vault 的 `.obsidian/plugins/multiaiedit/` 目录,重启 Obsidian 后在「设置 → 第三方插件」中启用。
---
### 2. 阅读标注
1. 打开任意 Markdown 文件
2. 点击左侧 Ribbon 高亮笔图标或命令面板搜索「打开 MultiAIEdit 侧边栏」
3. 选择顶部胶囊 **「阅读」**
4. 选中文本 → 浮层出现模式胶囊 + 颜色按钮 → 单击颜色完成高亮
5. 点击 **Note** 展开输入区,添加文字说明后点 Save
---
### 3. 批阅文档
1. 切换浮窗或侧边栏顶部胶囊到 **「批阅」**
2. 选中要修改的文本
3. 点击 **Delete** 切换删除线(再次点击可取消),或点击 **Note** 展开输入区写修改意见
4. 所有意见汇总在侧边栏批阅列表
---
### 4. 导出给 AI 执行
**方式 A导出文件 / 复制 Prompt全平台**
点击侧边栏底部「导出批阅文件」或「复制 Prompt」将生成的 Markdown 指令文件上传给 ChatGPT、Claude Web 等任意 AI 工具。
**方式 BCLI 一键执行(桌面端)**
需先安装任意一款 Agent CLI
```bash
npm i -g @anthropic-ai/claude-code # Claude Code
npm i -g @openai/codex # Codex CLI
pip install aider-chat # Aider
npm i -g @google/gemini-cli # Gemini CLI
```
在侧边栏底部点击 Agent 按钮 → 选择已安装的 Agent → 确认命令 → 终端自动执行 → 执行完成后自动弹出 Diff 预览。
未安装的 Agent 按钮灰显,点击后复制命令到剪贴板手动执行。
**方式 CAPI Key 直调(桌面端)**
无需安装任何 CLI 工具,在插件内直接调用模型 API。
1. 打开「设置 → API 直调」
2. 选择 ProviderAnthropic / OpenAI / DeepSeek / Gemini / 自定义端点)
3. 填写 API Key 和模型名
4. 点击「测试」验证连接
5. 在命令面板(`Cmd+P`)搜索「**API Key 直调执行**」或使用侧边栏按钮
6. 确认隐私提示 → 插件调用 API → 自动弹出 Diff 预览
---
### 5. Diff 预览与确认
AI 执行修改后,插件展示逐行差异:
- **全部接受**:保留所有 AI 修改
- **逐块接受**:勾选想保留的修改片段
- **回滚**:恢复原文,所有修改丢弃
确认后自动重锚批注,已执行的批阅意见自动移除。
---
## 设置项
### 基础设置
| 设置 | 说明 | 默认值 |
|------|------|--------|
| 默认模式 | 侧边栏初始状态(阅读/批阅/全部) | 阅读 |
| 上下文长度 | 批注前后保存的字符数,用于锚点定位 | 50 |
| Sidecar 目录 | 批注 JSON 存储位置 | `.multiaiedit/annotations` |
| 导出目录 | 批阅文件保存位置 | `.multiaiedit/exports` |
| 导出时附带阅读笔记 | 将阅读笔记作为参考上下文一并导出 | 否 |
### Agent 与终端(桌面端)
| 设置 | 说明 | 默认值 |
|------|------|--------|
| 终端应用 | macOS CLI 执行使用的终端 | Terminal |
| 自定义命令规则 | 用户自定义 Agent CLI 命令模板 | — |
预设 Agent4 个):
| Agent | 检测命令 | 安装方式 |
|-------|---------|---------|
| Claude Code | `which claude` | `npm i -g @anthropic-ai/claude-code` |
| Codex CLI | `which codex` | `npm i -g @openai/codex` |
| Aider | `which aider` | `pip install aider-chat` |
| Gemini CLI | `which gemini` | `npm i -g @google/gemini-cli` |
可用模板变量:`{{vaultPath}}` `{{instructionFile}}` `{{filePath}}` `{{fileName}}` `{{prompt}}`
### API 直调(桌面端)
| 设置 | 说明 | 默认值 |
|------|------|--------|
| Provider | Anthropic / OpenAI / DeepSeek / Gemini / 自定义 | Anthropic |
| API Key | 仅保存在本地,不上传 | — |
| 模型 | 留空使用默认值 | 按 Provider 不同 |
| 自定义端点 URL | OpenAI 兼容格式(仅自定义模式) | — |
| 最大输出 Token | API 调用的最大返回 Token 数 | 4096 |
| 测试连接 | 发送最小请求验证 API Key 是否有效 | — |
---
## 支持的 AI 工具
| AI 工具 | 接入方式 |
|---------|----------|
| Claude Code | CLI 一键执行 |
| Codex CLI | CLI 一键执行 |
| Aider | CLI 一键执行 |
| Gemini CLI | CLI 一键执行 |
| 自定义 Agent CLI | CLI 一键执行 |
| Anthropic API | API Key 直调 |
| OpenAI API | API Key 直调 |
| DeepSeek API | API Key 直调 |
| Google Gemini API | API Key 直调 |
| 自定义 OpenAI 兼容端点 | API Key 直调 |
| ChatGPT Web / Claude Web | 导出文件 / 复制 Prompt |
---
## 平台支持
| 功能 | 桌面端 | 移动端 |
|------|--------|--------|
| 阅读高亮 / 笔记 | ✅ | ✅(底部工具栏) |
| 批阅意见 | ✅ | ✅(底部工具栏) |
| 侧边栏 | ✅ | ✅ |
| 导出批阅文件 | ✅ | ✅ |
| 复制 Prompt | ✅ | ✅ |
| CLI 一键执行 | ✅macOS 完整Win/Linux 复制命令) | ❌ |
| API Key 直调 | ✅ | ❌ |
| Diff 预览与确认 | ✅ | ❌ |
| 自动重锚 | ✅ | ❌ |
> 移动端使用底部工具栏BottomToolbar替代桌面端浮窗避开 iOS/Android 系统选区菜单冲突。
---
## 数据存储
批注以 **sidecar JSON** 格式存储在 `.multiaiedit/annotations/` 目录下,不修改原始 Markdown 文件。
```
vault/
├── .multiaiedit/
│ ├── annotations/ # 批注数据(每个文件一个 JSON
│ └── exports/ # 导出的批阅指令文件
├── 你的文档.md # 原文不被修改
```
批注 JSON 结构AnnotationFile
```json
{
"version": 1,
"filePath": "path/to/doc.md",
"baselineHash": "sha256...",
"annotations": [
{
"id": "ann_xxx",
"type": "highlight | note | review",
"selectedText": "选中的文本",
"contextBefore": "...",
"contextAfter": "...",
"lineHint": 5,
"occurrenceIndex": 0,
"highlightColor": "yellow | blue | green | purple",
"noteText": "阅读笔记内容",
"reviewText": "批阅意见内容",
"strike": false
}
]
}
```
API Key 仅保存在 Obsidian 本地插件数据(`this.saveData()`),不上传,不经过任何代理。
---
## 锚点定位机制
批注不存储偏移量from/to加载时通过文本搜索动态计算
1. **精确匹配**`selectedText` 在文档中唯一 → 直接定位
2. **occurrenceIndex + lineHint 消歧**:重复段落用出现序号 + 行号提示
3. **模糊定位fuzzyLocate**:编辑距离 + trigram 相似度滑动窗口
4. **漂移检测**`baselineHash` 比对,文档变更后自动提醒
5. **自动修复**Agent Diff 确认后自动重锚(方案 B + 方案 A 兜底)
---
## 版本路线
| 版本 | 状态 | 主要功能 |
|------|------|----------|
| v0.1 | ✅ 完成 | 阅读高亮、批阅意见、导出文件、复制 Prompt |
| v0.2 | ✅ 完成 | CLI 一键执行、Diff 预览、自动重锚 |
| v0.3 | 规划中 | 全库标注视图、跨文件搜索、JSON 导出 |
| v0.4 | ✅ 完成 | API Key 直调Anthropic / OpenAI / DeepSeek / Gemini / 自定义) |
| v0.5 | 规划中 | MCP Server、批注集合、从其他插件迁移 |
---
## 命令面板快捷命令
| 命令 | 说明 |
|------|------|
| 打开 MultiAIEdit 侧边栏 | 打开右侧侧边栏 |
| 高亮(黄) | 快速黄色高亮选中文本 |
| 添加笔记 | 为选中文本添加阅读笔记 |
| 添加批阅意见 | 为选中文本添加批阅 |
| 导出批阅文件 | 导出当前文件的批阅意见 |
| 复制 Prompt | 一键复制到剪贴板 |
| 使用 Claude Code 执行 | CLI 一键执行 |
| 使用 Codex CLI 执行 | CLI 一键执行 |
| 使用 Aider 执行 | CLI 一键执行 |
| 使用 Gemini CLI 执行 | CLI 一键执行 |
| 复制 Agent 命令 | 复制命令到剪贴板 |
| API Key 直调执行 | 插件内直接调用模型 API |

View file

@ -0,0 +1,164 @@
# Obsidian 插件开发踩坑:隐藏目录 + Finder 打开
> 2026-06-15 实战总结,来自 Promptuary 插件导出流程改造
---
## 问题 1`require('electron').shell` 在渲染进程不可用
### 现象
调用 `shell.openPath()``shell.showItemInFolder()`Finder 完全没有反应,也没有报错。代码静默失败。
### 根因
Obsidian 插件运行在 Electron **渲染进程**renderer process。`shell` 模块属于**主进程**main process模块。
在渲染进程中 `require('electron')` 只返回渲染进程可用的 API
| 模块 | 渲染进程可用 |
|------|:---:|
| `clipboard` | ✅ |
| `ipcRenderer` | ✅ |
| `nativeImage` | ✅ |
| `webFrame` | ✅ |
| **`shell`** | ❌ |
| **`dialog`** | ❌ |
| **`app`** | ❌ |
| **`BrowserWindow`** | ❌ |
调用 `shell.openPath()` 时,`shell` 为 `undefined`,访问其方法抛出 TypeError但 try-catch 吞掉了错误,导致静默失败。
### 解决方案
`child_process.exec()` 执行系统命令(已在 TerminalLauncher 中验证可用):
| 功能 | macOS | Windows | Linux |
|------|-------|---------|-------|
| 打开文件夹 | `open "path"` | `explorer "path"` | `xdg-open "path"` |
| **定位文件**(等价 showItemInFolder | `open -R "path"` | `explorer /select,"path"` | `xdg-open "parent_dir"` |
```typescript
import { exec } from "child_process";
function revealInFileManager(absPath: string): void {
const cmd = process.platform === "darwin"
? `open -R "${absPath}"`
: process.platform === "win32"
? `explorer /select,"${absPath}"`
: `xdg-open "${path.dirname(absPath)}"`;
exec(cmd);
}
```
### 参考
- Electron 官方文档:[Main Process Modules vs Renderer Process Modules](https://www.electronjs.org/docs/latest/api/shell)
- Obsidian 插件全部运行在渲染进程,无法直接访问主进程模块
---
## 问题 2`vault.create()` 对隐藏目录返回 null
### 现象
```typescript
const created = await this.app.vault.create('.promptuary/exports/file.md', content);
console.log(created); // null
created.path; // TypeError: Cannot read properties of null
```
### 根因
Obsidian 的 Vault 只索引**可见路径**下的文件。以 `.` 开头的隐藏目录不属于 Vault 索引范围。
`vault.create()` 的行为:
- 可见路径(如 `Notes/file.md`):创建文件,返回 `TFile`
- 隐藏路径(如 `.promptuary/file.md`):创建文件到磁盘,但 **不加入索引**,返回 `null`
这不抛错,但后续访问 `created.path` 就会 TypeError。
### 解决方案
对隐藏目录下的文件,使用 `adapter.write()` 代替 `vault.create()`
```typescript
const adapter = this.app.vault.adapter;
// 确保目录存在
if (!(await adapter.exists(dirPath))) {
await adapter.mkdir(dirPath);
}
// 直接写入,不依赖 Vault 索引
await adapter.write(filePath, content);
// 返回路径字符串,不依赖 TFile
return filePath;
```
**对比**
| 方法 | 适用场景 | 返回值 | Vault 索引 |
|------|---------|--------|:---:|
| `vault.create(path, content)` | 可见目录 | `TFile \| null` | ✅ 加入 |
| `adapter.write(path, content)` | 任意路径 | `void` | ❌ 不加入 |
| `vault.read(file)` | 读取 TFile | `string` | — |
| `adapter.read(path)` | 读取任意路径 | `string` | — |
---
## 问题 3隐藏目录 + Finder 的交互设计
### 设计决策
导出目录改为 `.promptuary/exports`(隐藏,不污染工作区),导出后用 Finder 定位文件。
- `shell.openPath(隐藏目录)` → Finder 打开了但默认不显示隐藏文件,用户看不到内容
- `open -R "具体文件"` → Finder 打开父目录并**选中该文件**,用户一眼能看到
### 最终实现
```
导出批注 → adapter.write() 写入 .promptuary/exports/ → exec('open -R') 在 Finder 定位
```
### 迁移逻辑
旧用户可能还在用可见目录,`loadSettings()` 自动迁移:
```typescript
const LEGACY_EXPORT_DIRS = ["Promptuary/exports", "MultiAIEdit/exports", ".multiaiedit/exports"];
if (LEGACY_EXPORT_DIRS.includes(this.settings.exportDir)) {
this.settings.exportDir = ".promptuary/exports";
await this.saveSettings();
}
```
---
## 快速参考
```
Obsidian 插件渲染进程限制:
├── require('electron').shell ❌ 不可用(主进程模块)
├── require('child_process') ✅ 可用
├── require('fs') ✅ 可用
├── require('path') ✅ 可用
└── require('os') ✅ 可用
隐藏目录操作:
├── vault.create() ❌ 返回 null
├── vault.read() ❌ 找不到 TFile
├── adapter.write() ✅ 直接写磁盘
├── adapter.read() ✅ 直接读磁盘
├── adapter.exists() ✅ 检查存在
└── adapter.mkdir() ✅ 创建目录
Finder 打开:
├── shell.openPath() ❌ 渲染进程不可用
├── shell.showItemInFolder() ❌ 渲染进程不可用
├── exec('open "dir"') ✅ 打开文件夹
├── exec('open -R "file"') ✅ 定位文件(推荐)
└── exec('explorer /select,"file"') ✅ Windows 定位文件
```

View file

@ -37,6 +37,9 @@ const context = await esbuild.context({
treeShaking: true,
outfile: "main.js",
minify: prod,
loader: {
".svg": "text",
},
});
if (prod) {

354
main.js

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,6 @@
{
"id": "multiaiedit",
"name": "MultiAIEdit",
"id": "promptuary",
"name": "Promptuary",
"version": "0.2.3",
"minAppVersion": "1.4.0",
"description": "阅读时高亮/记笔记,批阅时快速留下修改意见,一键导出给外部 AI 或 Agent 执行修改。",

View file

@ -1,5 +1,5 @@
{
"name": "obsidian-multiaiedit",
"name": "obsidian-promptuary",
"version": "0.2.0",
"description": "Obsidian 阅读标注与 AI 批阅插件——阅读时高亮/记笔记,批阅时快速留下修改意见,一键导出给外部 AI 或 Agent 执行修改。",
"main": "main.js",

View file

@ -9,7 +9,6 @@ export interface AgentSelectResult {
/** Initial char → accent color bucket */
const AGENT_COLORS: Record<string, { bg: string; border: string; text: string }> = {
C: { bg: "rgba(167,139,250,0.10)", border: "rgba(167,139,250,0.20)", text: "rgba(167,139,250,0.90)" },
I: { bg: "rgba(167,139,250,0.10)", border: "rgba(167,139,250,0.20)", text: "rgba(167,139,250,0.90)" },
O: { bg: "rgba(74,222,128,0.08)", border: "rgba(74,222,128,0.15)", text: "rgba(74,222,128,0.80)" },
A: { bg: "rgba(96,165,250,0.08)", border: "rgba(96,165,250,0.15)", text: "rgba(96,165,250,0.80)" },
G: { bg: "rgba(251,191,36,0.08)", border: "rgba(251,191,36,0.15)", text: "rgba(251,191,36,0.80)" },
@ -46,18 +45,18 @@ export class AgentSelectModal extends Modal {
onOpen(): void {
const { modalEl } = this;
modalEl.empty();
modalEl.addClass("mae-agent-select-modal");
modalEl.addClass("prm-agent-select-modal");
// ── Header ──
const header = modalEl.createDiv({ cls: "mae-asm-header" });
const headerLeft = header.createDiv({ cls: "mae-asm-header-left" });
const iconWrap = headerLeft.createDiv({ cls: "mae-asm-icon" });
const header = modalEl.createDiv({ cls: "prm-asm-header" });
const headerLeft = header.createDiv({ cls: "prm-asm-header-left" });
const iconWrap = headerLeft.createDiv({ cls: "prm-asm-icon" });
setIcon(iconWrap, "bot");
const titleWrap = headerLeft.createDiv();
titleWrap.createDiv({ cls: "mae-asm-title", text: "Select Agent" });
titleWrap.createDiv({ cls: "mae-asm-subtitle", text: "Choose an AI agent to execute edits" });
titleWrap.createDiv({ cls: "prm-asm-title", text: "Select Agent" });
titleWrap.createDiv({ cls: "prm-asm-subtitle", text: "Choose an AI agent to execute edits" });
const closeBtn = header.createEl("button", { cls: "mae-asm-close" });
const closeBtn = header.createEl("button", { cls: "prm-asm-close" });
closeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
closeBtn.onclick = () => {
this.resolve?.({ rule: null });
@ -65,7 +64,7 @@ export class AgentSelectModal extends Modal {
};
// ── Agent list ──
const list = modalEl.createDiv({ cls: "mae-asm-list" });
const list = modalEl.createDiv({ cls: "prm-asm-list" });
// Pre-select first installed agent
const firstInstalled = this.agents.find((a) => a.installed);
@ -79,36 +78,34 @@ export class AgentSelectModal extends Modal {
const isActive = this.selected?.id === rule.id;
const row = list.createDiv({
cls: "mae-asm-row" + (isActive ? " active" : "") + (!installed ? " disabled" : ""),
cls: "prm-asm-row" + (isActive ? " active" : "") + (!installed ? " disabled" : ""),
});
rowEls.set(rule.id, row);
// Avatar
const avatar = row.createDiv({ cls: "mae-asm-avatar" });
const avatar = row.createDiv({ cls: "prm-asm-avatar" });
avatar.style.background = col.bg;
avatar.style.borderColor = col.border;
// Use "CI" for Claude Internal to distinguish from Claude Code
const avatarChar = rule.id === "claude-internal" ? "CI" : rule.label[0];
avatar.createSpan({ cls: "mae-asm-avatar-char", text: avatarChar });
(avatar.querySelector(".mae-asm-avatar-char") as HTMLElement).style.color = col.text;
avatar.createSpan({ cls: "prm-asm-avatar-char", text: rule.label[0] });
(avatar.querySelector(".prm-asm-avatar-char") as HTMLElement).style.color = col.text;
// Info
const info2 = row.createDiv({ cls: "mae-asm-info" });
const nameRow = info2.createDiv({ cls: "mae-asm-name-row" });
nameRow.createSpan({ cls: "mae-asm-name", text: rule.label });
const info2 = row.createDiv({ cls: "prm-asm-info" });
const nameRow = info2.createDiv({ cls: "prm-asm-name-row" });
nameRow.createSpan({ cls: "prm-asm-name", text: rule.label });
const badge = nameRow.createSpan({
cls: "mae-asm-badge " + (installed ? "installed" : "missing"),
cls: "prm-asm-badge " + (installed ? "installed" : "missing"),
text: installed ? "installed" : "missing",
});
info2.createDiv({
cls: "mae-asm-meta",
cls: "prm-asm-meta",
text: rule.vendor ? `${rule.vendor} · ${rule.installHint}` : rule.installHint,
});
// Radio
const radio = row.createDiv({ cls: "mae-asm-radio" + (isActive ? " selected" : "") });
if (isActive) radio.createDiv({ cls: "mae-asm-radio-dot" });
const radio = row.createDiv({ cls: "prm-asm-radio" + (isActive ? " selected" : "") });
if (isActive) radio.createDiv({ cls: "prm-asm-radio-dot" });
if (installed) {
row.onclick = () => {
@ -116,31 +113,31 @@ export class AgentSelectModal extends Modal {
// Update all rows
for (const [id, el] of rowEls) {
el.removeClass("active");
const r = el.querySelector(".mae-asm-radio");
const r = el.querySelector(".prm-asm-radio");
r?.removeClass("selected");
r?.querySelector(".mae-asm-radio-dot")?.remove();
r?.querySelector(".prm-asm-radio-dot")?.remove();
}
row.addClass("active");
radio.addClass("selected");
if (!radio.querySelector(".mae-asm-radio-dot")) {
radio.createDiv({ cls: "mae-asm-radio-dot" });
if (!radio.querySelector(".prm-asm-radio-dot")) {
radio.createDiv({ cls: "prm-asm-radio-dot" });
}
};
}
}
// ── Footer ──
const footer = modalEl.createDiv({ cls: "mae-asm-footer" });
const footer = modalEl.createDiv({ cls: "prm-asm-footer" });
const configBtn = footer.createEl("button", { cls: "mae-asm-config-btn", text: "+ Configure custom agent" });
const configBtn = footer.createEl("button", { cls: "prm-asm-config-btn", text: "+ Configure custom agent" });
configBtn.onclick = () => {
(this.app as any).setting?.open();
(this.app as any).setting?.openTabById?.("multiaiedit");
(this.app as any).setting?.openTabById?.("promptuary");
this.resolve?.({ rule: null });
this.close();
};
const confirmBtn = footer.createEl("button", { cls: "mae-asm-confirm-btn", text: "Confirm" });
const confirmBtn = footer.createEl("button", { cls: "prm-asm-confirm-btn", text: "Confirm" });
confirmBtn.onclick = () => {
this.resolve?.({ rule: this.selected });
this.close();

View file

@ -5,7 +5,6 @@ import { CommandRule } from "./CommandRuleStore";
/** Initial char → accent color bucket (same as AgentSelectModal) */
const AGENT_COLORS: Record<string, { bg: string; border: string; text: string }> = {
C: { bg: "rgba(167,139,250,0.10)", border: "rgba(167,139,250,0.20)", text: "rgba(167,139,250,0.90)" },
I: { bg: "rgba(167,139,250,0.10)", border: "rgba(167,139,250,0.20)", text: "rgba(167,139,250,0.90)" },
O: { bg: "rgba(74,222,128,0.08)", border: "rgba(74,222,128,0.15)", text: "rgba(74,222,128,0.80)" },
A: { bg: "rgba(96,165,250,0.08)", border: "rgba(96,165,250,0.15)", text: "rgba(96,165,250,0.80)" },
G: { bg: "rgba(251,191,36,0.08)", border: "rgba(251,191,36,0.15)", text: "rgba(251,191,36,0.80)" },
@ -49,23 +48,23 @@ export class CommandConfirmModal extends Modal {
onOpen(): void {
const { modalEl } = this;
modalEl.empty();
modalEl.addClass("mae-confirm-modal");
modalEl.addClass("prm-confirm-modal");
const col = colorFor(this.rule.label);
// ── Header ──
const header = modalEl.createDiv({ cls: "mae-ccm-header" });
const headerLeft = header.createDiv({ cls: "mae-ccm-header-left" });
const iconWrap = headerLeft.createDiv({ cls: "mae-ccm-icon" });
const header = modalEl.createDiv({ cls: "prm-ccm-header" });
const headerLeft = header.createDiv({ cls: "prm-ccm-header-left" });
const iconWrap = headerLeft.createDiv({ cls: "prm-ccm-icon" });
setIcon(iconWrap, "terminal");
const titleWrap = headerLeft.createDiv();
titleWrap.createDiv({ cls: "mae-ccm-title", text: "Confirm Execution" });
titleWrap.createDiv({ cls: "prm-ccm-title", text: "Confirm Execution" });
titleWrap.createDiv({
cls: "mae-ccm-subtitle",
cls: "prm-ccm-subtitle",
text: `${this.rule.label} · about to run in Terminal`,
});
const closeBtn = header.createEl("button", { cls: "mae-ccm-close" });
const closeBtn = header.createEl("button", { cls: "prm-ccm-close" });
closeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
closeBtn.onclick = () => {
this.resolve?.(false);
@ -73,61 +72,60 @@ export class CommandConfirmModal extends Modal {
};
// ── Agent badge row ──
const badgeRow = modalEl.createDiv({ cls: "mae-ccm-agent-row" });
const agentAvatar = badgeRow.createDiv({ cls: "mae-ccm-agent-avatar" });
const badgeRow = modalEl.createDiv({ cls: "prm-ccm-agent-row" });
const agentAvatar = badgeRow.createDiv({ cls: "prm-ccm-agent-avatar" });
agentAvatar.style.background = col.bg;
agentAvatar.style.borderColor = col.border;
const avatarChar = this.rule.id === "claude-internal" ? "CI" : this.rule.label[0];
const avatarSpan = agentAvatar.createSpan({ cls: "mae-ccm-agent-char", text: avatarChar });
const avatarSpan = agentAvatar.createSpan({ cls: "prm-ccm-agent-char", text: this.rule.label[0] });
avatarSpan.style.color = col.text;
badgeRow.createSpan({ cls: "mae-ccm-agent-name", text: this.rule.label });
badgeRow.createSpan({ cls: "prm-ccm-agent-name", text: this.rule.label });
const badge = badgeRow.createSpan({
cls: "mae-ccm-agent-badge installed",
cls: "prm-ccm-agent-badge installed",
text: "installed",
});
if (this.rule.vendor) {
badgeRow.createSpan({
cls: "mae-ccm-agent-vendor",
cls: "prm-ccm-agent-vendor",
text: `${this.rule.vendor}`,
});
}
// ── Command display ──
const cmdWrap = modalEl.createDiv({ cls: "mae-ccm-cmd-wrap" });
const cmdBox = cmdWrap.createDiv({ cls: "mae-ccm-cmd-box" });
const cmdHeader = cmdBox.createDiv({ cls: "mae-ccm-cmd-header" });
cmdHeader.createSpan({ cls: "mae-ccm-cmd-label", text: "COMMAND" });
cmdBox.createEl("code", { cls: "mae-ccm-code", text: this.command });
const cmdWrap = modalEl.createDiv({ cls: "prm-ccm-cmd-wrap" });
const cmdBox = cmdWrap.createDiv({ cls: "prm-ccm-cmd-box" });
const cmdHeader = cmdBox.createDiv({ cls: "prm-ccm-cmd-header" });
cmdHeader.createSpan({ cls: "prm-ccm-cmd-label", text: "COMMAND" });
cmdBox.createEl("code", { cls: "prm-ccm-code", text: this.command });
// ── Warning ──
const warnBox = modalEl.createDiv({ cls: "mae-ccm-warning" });
const warnIcon = warnBox.createDiv({ cls: "mae-ccm-warning-icon" });
const warnBox = modalEl.createDiv({ cls: "prm-ccm-warning" });
const warnIcon = warnBox.createDiv({ cls: "prm-ccm-warning-icon" });
setIcon(warnIcon, "alert-triangle");
warnBox.createEl("p", {
cls: "mae-ccm-warning-text",
cls: "prm-ccm-warning-text",
text: "请仔细检查命令内容确认后将在终端中执行。Agent 将直接修改磁盘上的文件。",
});
// ── Instruction file hint (optional) ──
if (this.instructionFile) {
const hintRow = modalEl.createDiv({ cls: "mae-ccm-hint" });
const hintIcon = hintRow.createDiv({ cls: "mae-ccm-hint-icon" });
const hintRow = modalEl.createDiv({ cls: "prm-ccm-hint" });
const hintIcon = hintRow.createDiv({ cls: "prm-ccm-hint-icon" });
setIcon(hintIcon, "file-text");
hintRow.createSpan({
cls: "mae-ccm-hint-text",
cls: "prm-ccm-hint-text",
text: `指令文件: ${this.instructionFile}`,
});
}
// ── Footer ──
const footer = modalEl.createDiv({ cls: "mae-ccm-footer" });
const footerLeft = footer.createDiv({ cls: "mae-ccm-footer-left" });
const cancelBtn = footerLeft.createEl("button", { cls: "mae-ccm-btn mae-ccm-btn-cancel" });
const cancelInner = cancelBtn.createSpan({ cls: "mae-ccm-btn-inner" });
const cancelIcon = cancelInner.createSpan({ cls: "mae-ccm-btn-icon" });
const footer = modalEl.createDiv({ cls: "prm-ccm-footer" });
const footerLeft = footer.createDiv({ cls: "prm-ccm-footer-left" });
const cancelBtn = footerLeft.createEl("button", { cls: "prm-ccm-btn prm-ccm-btn-cancel" });
const cancelInner = cancelBtn.createSpan({ cls: "prm-ccm-btn-inner" });
const cancelIcon = cancelInner.createSpan({ cls: "prm-ccm-btn-icon" });
setIcon(cancelIcon, "x");
cancelInner.createSpan({ text: "Cancel" });
cancelBtn.onclick = () => {
@ -135,10 +133,10 @@ export class CommandConfirmModal extends Modal {
this.close();
};
const footerRight = footer.createDiv({ cls: "mae-ccm-footer-right" });
const copyBtn = footerRight.createEl("button", { cls: "mae-ccm-btn mae-ccm-btn-copy" });
const copyInner = copyBtn.createSpan({ cls: "mae-ccm-btn-inner" });
const copyIcon = copyInner.createSpan({ cls: "mae-ccm-btn-icon" });
const footerRight = footer.createDiv({ cls: "prm-ccm-footer-right" });
const copyBtn = footerRight.createEl("button", { cls: "prm-ccm-btn prm-ccm-btn-copy" });
const copyInner = copyBtn.createSpan({ cls: "prm-ccm-btn-inner" });
const copyIcon = copyInner.createSpan({ cls: "prm-ccm-btn-icon" });
setIcon(copyIcon, "copy");
copyInner.createSpan({ text: "Copy Command" });
copyBtn.onclick = () => {
@ -148,9 +146,9 @@ export class CommandConfirmModal extends Modal {
this.close();
};
const execBtn = footerRight.createEl("button", { cls: "mae-ccm-btn mae-ccm-btn-exec" });
const execInner = execBtn.createSpan({ cls: "mae-ccm-btn-inner" });
const execIcon = execInner.createSpan({ cls: "mae-ccm-btn-icon" });
const execBtn = footerRight.createEl("button", { cls: "prm-ccm-btn prm-ccm-btn-exec" });
const execInner = execBtn.createSpan({ cls: "prm-ccm-btn-inner" });
const execIcon = execInner.createSpan({ cls: "prm-ccm-btn-icon" });
setIcon(execIcon, "play");
execInner.createSpan({ text: "Execute" });
execBtn.onclick = () => {

View file

@ -25,16 +25,6 @@ export const PRESET_RULES: CommandRule[] = [
installHint: "npm i -g @anthropic-ai/claude-code",
isPreset: true,
},
{
id: "claude-internal",
label: "Claude Internal",
vendor: "Anthropic (internal)",
detectCmd: "which claude-internal",
template:
'cd {{vaultPath}} && claude-internal --config-dir ~/.claude-internal "读取 {{instructionFile}},按批注指令修改对应文件"',
installHint: "内部版本,需要内部权限",
isPreset: true,
},
{
id: "codex",
label: "Codex CLI",

View file

@ -61,7 +61,7 @@ function launchMacOS(opts: LaunchOptions): void {
// Write the command to a temp shell script to avoid all AppleScript quoting issues.
// The script path is safe ASCII — no special chars to escape.
const tmpScript = nodePath.join(os.tmpdir(), `mae-agent-${Date.now()}.sh`);
const tmpScript = nodePath.join(os.tmpdir(), `prm-agent-${Date.now()}.sh`);
fs.writeFileSync(tmpScript, `#!/bin/bash\n${opts.command}\n`, { mode: 0o755 });
// Schedule cleanup after 60 s (well after execution starts)

View file

@ -1,6 +1,6 @@
import { App, TAbstractFile, TFile, EventRef, Events } from "obsidian";
import { Annotation, AnnotationFile, emptyAnnotationFile, FILE_VERSION } from "./AnnotationModel";
import { sidecarPath } from "../utils/path";
import { sidecarPath, ensureDir, removeEmptyAncestors } from "../utils/path";
import { sha256 } from "../utils/hash";
const WRITE_DEBOUNCE_MS = 300;
@ -138,7 +138,7 @@ export class AnnotationStore extends Events {
json.filePath = filePath;
return json;
} catch (e) {
console.warn("[MultiAIEdit] failed to load sidecar", path, e);
console.warn("[Promptuary] failed to load sidecar", path, e);
return emptyAnnotationFile(filePath);
}
}
@ -148,13 +148,11 @@ export class AnnotationStore extends Events {
const path = sidecarPath(this.rootDir, filePath);
const dir = path.slice(0, path.lastIndexOf("/"));
try {
if (!(await adapter.exists(dir))) {
await adapter.mkdir(dir);
}
await ensureDir(adapter, dir);
const json = JSON.stringify(data, null, 2);
await adapter.write(path, json);
} catch (e) {
console.error("[MultiAIEdit] failed to write sidecar", path, e);
console.error("[Promptuary] failed to write sidecar", path, e);
}
}
@ -175,11 +173,17 @@ export class AnnotationStore extends Events {
const data = JSON.parse(raw) as AnnotationFile;
data.filePath = file.path;
for (const ann of data.annotations) ann.filePath = file.path;
// Ensure new directory structure exists
const newDir = newSidecar.slice(0, newSidecar.lastIndexOf("/"));
await ensureDir(adapter, newDir);
await adapter.write(newSidecar, JSON.stringify(data, null, 2));
await adapter.remove(oldSidecar);
// Clean up empty ancestor directories left by the old path
const oldDir = oldSidecar.slice(0, oldSidecar.lastIndexOf("/"));
await removeEmptyAncestors(adapter, this.rootDir, oldDir);
}
} catch (e) {
console.warn("[MultiAIEdit] rename sidecar failed", e);
console.warn("[Promptuary] rename sidecar failed", e);
}
if (this.cache.has(oldPath)) {
const data = this.cache.get(oldPath)!;
@ -201,12 +205,21 @@ export class AnnotationStore extends Events {
const orphans = `${this.rootDir}/orphans`;
try {
if (await adapter.exists(sidecar)) {
if (!(await adapter.exists(orphans))) await adapter.mkdir(orphans);
const target = `${orphans}/${sidecar.split("/").pop()}`;
await ensureDir(adapter, orphans);
// Preserve the original path structure in orphans for readability:
// .promptuary/annotations/笔记/文档.md.json
// → .promptuary/annotations/orphans/笔记/文档.md.json
const relativePath = sidecar.slice(this.rootDir.length + 1);
const target = `${orphans}/${relativePath}`;
const targetDir = target.slice(0, target.lastIndexOf("/"));
await ensureDir(adapter, targetDir);
await adapter.rename(sidecar, target);
// Clean up empty ancestor directories left by the deleted file
const fileDir = sidecar.slice(0, sidecar.lastIndexOf("/"));
await removeEmptyAncestors(adapter, this.rootDir, fileDir);
}
} catch (e) {
console.warn("[MultiAIEdit] orphan sidecar move failed", e);
console.warn("[Promptuary] orphan sidecar move failed", e);
}
this.cache.delete(file.path);
this.trigger("change", file.path);

View file

@ -53,25 +53,25 @@ export class APIConfirmModal extends Modal {
onOpen(): void {
const { modalEl } = this;
modalEl.empty();
modalEl.addClass("mae-api-confirm-modal");
modalEl.addClass("prm-api-confirm-modal");
const col = colorFor(this.config.provider);
const providerName = PROVIDER_LABEL[this.config.provider];
const modelName = this.config.model || PROVIDER_DEFAULTS[this.config.provider].model;
// ── Header ──
const header = modalEl.createDiv({ cls: "mae-apm-header" });
const headerLeft = header.createDiv({ cls: "mae-apm-header-left" });
const iconWrap = headerLeft.createDiv({ cls: "mae-apm-icon" });
const header = modalEl.createDiv({ cls: "prm-apm-header" });
const headerLeft = header.createDiv({ cls: "prm-apm-header-left" });
const iconWrap = headerLeft.createDiv({ cls: "prm-apm-icon" });
setIcon(iconWrap, "zap");
const titleWrap = headerLeft.createDiv();
titleWrap.createDiv({ cls: "mae-apm-title", text: "API 批阅确认" });
titleWrap.createDiv({ cls: "prm-apm-title", text: "API 批阅确认" });
titleWrap.createDiv({
cls: "mae-apm-subtitle",
cls: "prm-apm-subtitle",
text: `${providerName} · 即将发送至 API`,
});
const closeBtn = header.createEl("button", { cls: "mae-apm-close" });
const closeBtn = header.createEl("button", { cls: "prm-apm-close" });
closeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
closeBtn.onclick = () => {
this.resolve?.({ action: "cancel" });
@ -79,26 +79,26 @@ export class APIConfirmModal extends Modal {
};
// ── Provider badge row ──
const badgeRow = modalEl.createDiv({ cls: "mae-apm-provider-row" });
const providerAvatar = badgeRow.createDiv({ cls: "mae-apm-provider-avatar" });
const badgeRow = modalEl.createDiv({ cls: "prm-apm-provider-row" });
const providerAvatar = badgeRow.createDiv({ cls: "prm-apm-provider-avatar" });
providerAvatar.style.background = col.bg;
providerAvatar.style.borderColor = col.border;
const avatarChar = providerAvatar.createSpan({ cls: "mae-apm-provider-char", text: providerName[0] });
const avatarChar = providerAvatar.createSpan({ cls: "prm-apm-provider-char", text: providerName[0] });
avatarChar.style.color = col.text;
badgeRow.createSpan({ cls: "mae-apm-provider-name", text: providerName });
const badge = badgeRow.createSpan({ cls: "mae-apm-provider-badge", text: "provider" });
badgeRow.createSpan({ cls: "prm-apm-provider-name", text: providerName });
const badge = badgeRow.createSpan({ cls: "prm-apm-provider-badge", text: "provider" });
badge.style.background = col.bg;
badge.style.borderColor = col.border;
badge.style.color = col.text;
// ── Info table ──
const infoWrap = modalEl.createDiv({ cls: "mae-apm-info-wrap" });
const infoBox = infoWrap.createDiv({ cls: "mae-apm-info-box" });
const infoWrap = modalEl.createDiv({ cls: "prm-apm-info-wrap" });
const infoBox = infoWrap.createDiv({ cls: "prm-apm-info-box" });
const addRow = (label: string, value: string, warn = false) => {
const row = infoBox.createDiv({ cls: "mae-apm-info-row" });
row.createSpan({ cls: "mae-apm-info-label", text: label });
const val = row.createSpan({ cls: warn ? "mae-apm-info-value mae-apm-info-warn" : "mae-apm-info-value", text: value });
const row = infoBox.createDiv({ cls: "prm-apm-info-row" });
row.createSpan({ cls: "prm-apm-info-label", text: label });
const val = row.createSpan({ cls: warn ? "prm-apm-info-value prm-apm-info-warn" : "prm-apm-info-value", text: value });
return val;
};
@ -107,19 +107,19 @@ export class APIConfirmModal extends Modal {
addRow("Tokens", `~${this.estimatedTokens.toLocaleString()}`, this.estimatedTokens > 50_000);
// ── Warning ──
const warnBox = modalEl.createDiv({ cls: "mae-apm-warning" });
const warnIcon = warnBox.createDiv({ cls: "mae-apm-warning-icon" });
const warnBox = modalEl.createDiv({ cls: "prm-apm-warning" });
const warnIcon = warnBox.createDiv({ cls: "prm-apm-warning-icon" });
setIcon(warnIcon, "alert-triangle");
warnBox.createEl("p", {
cls: "mae-apm-warning-text",
cls: "prm-apm-warning-text",
text: "原文与批阅意见将发送至所选 Provider请确认不含敏感信息。数据将离开本地 Vault。",
});
// ── Footer ──
const footer = modalEl.createDiv({ cls: "mae-apm-footer" });
const cancelBtn = footer.createEl("button", { cls: "mae-apm-btn mae-apm-btn-cancel" });
const cancelInner = cancelBtn.createSpan({ cls: "mae-apm-btn-inner" });
const cancelIcon = cancelInner.createSpan({ cls: "mae-apm-btn-icon" });
const footer = modalEl.createDiv({ cls: "prm-apm-footer" });
const cancelBtn = footer.createEl("button", { cls: "prm-apm-btn prm-apm-btn-cancel" });
const cancelInner = cancelBtn.createSpan({ cls: "prm-apm-btn-inner" });
const cancelIcon = cancelInner.createSpan({ cls: "prm-apm-btn-icon" });
setIcon(cancelIcon, "x");
cancelInner.createSpan({ text: "取消" });
cancelBtn.onclick = () => {
@ -127,9 +127,9 @@ export class APIConfirmModal extends Modal {
this.close();
};
const execBtn = footer.createEl("button", { cls: "mae-apm-btn mae-apm-btn-exec" });
const execInner = execBtn.createSpan({ cls: "mae-apm-btn-inner" });
const execIcon = execInner.createSpan({ cls: "mae-apm-btn-icon" });
const execBtn = footer.createEl("button", { cls: "prm-apm-btn prm-apm-btn-exec" });
const execInner = execBtn.createSpan({ cls: "prm-apm-btn-inner" });
const execIcon = execInner.createSpan({ cls: "prm-apm-btn-icon" });
setIcon(execIcon, "send");
execInner.createSpan({ text: "确认执行" });
execBtn.onclick = () => {
@ -180,25 +180,25 @@ export class APIProgressModal extends Modal {
onOpen(): void {
const { modalEl } = this;
modalEl.empty();
modalEl.addClass("mae-api-progress-modal");
modalEl.addClass("prm-api-progress-modal");
// ── Header ──
const header = modalEl.createDiv({ cls: "mae-ppm-header" });
const headerLeft = header.createDiv({ cls: "mae-ppm-header-left" });
const iconWrap = headerLeft.createDiv({ cls: "mae-ppm-icon" });
const header = modalEl.createDiv({ cls: "prm-ppm-header" });
const headerLeft = header.createDiv({ cls: "prm-ppm-header-left" });
const iconWrap = headerLeft.createDiv({ cls: "prm-ppm-icon" });
setIcon(iconWrap, "zap");
const titleWrap = headerLeft.createDiv();
titleWrap.createDiv({ cls: "mae-ppm-title", text: "API 批阅中…" });
titleWrap.createDiv({ cls: "mae-ppm-subtitle", text: "正在调用 API" });
titleWrap.createDiv({ cls: "prm-ppm-title", text: "API 批阅中…" });
titleWrap.createDiv({ cls: "prm-ppm-subtitle", text: "正在调用 API" });
// ── Content area (re-rendered by setState) ──
this.contentArea = modalEl.createDiv({ cls: "mae-ppm-content" });
this.contentArea = modalEl.createDiv({ cls: "prm-ppm-content" });
// ── Footer ──
const footer = modalEl.createDiv({ cls: "mae-ppm-footer" });
const closeBtnWrap = footer.createDiv({ cls: "mae-ppm-footer-right" });
const closeBtn = closeBtnWrap.createEl("button", { cls: "mae-ppm-btn mae-ppm-btn-close" });
const closeInner = closeBtn.createSpan({ cls: "mae-ppm-btn-inner" });
const footer = modalEl.createDiv({ cls: "prm-ppm-footer" });
const closeBtnWrap = footer.createDiv({ cls: "prm-ppm-footer-right" });
const closeBtn = closeBtnWrap.createEl("button", { cls: "prm-ppm-btn prm-ppm-btn-close" });
const closeInner = closeBtn.createSpan({ cls: "prm-ppm-btn-inner" });
closeInner.createSpan({ text: "关闭" });
closeBtn.onclick = () => {
this.resolve?.(null);
@ -206,9 +206,9 @@ export class APIProgressModal extends Modal {
};
closeBtn.style.display = "none"; // hidden during calling
const diffBtn = closeBtnWrap.createEl("button", { cls: "mae-ppm-btn mae-ppm-btn-diff" });
const diffInner = diffBtn.createSpan({ cls: "mae-ppm-btn-inner" });
const diffIcon = diffInner.createSpan({ cls: "mae-ppm-btn-icon" });
const diffBtn = closeBtnWrap.createEl("button", { cls: "prm-ppm-btn prm-ppm-btn-diff" });
const diffInner = diffBtn.createSpan({ cls: "prm-ppm-btn-inner" });
const diffIcon = diffInner.createSpan({ cls: "prm-ppm-btn-icon" });
setIcon(diffIcon, "git-compare");
diffInner.createSpan({ text: "查看 Diff" });
diffBtn.onclick = () => {
@ -230,32 +230,32 @@ export class APIProgressModal extends Modal {
area.empty();
// Footer buttons
const footerRight = this.modalEl.querySelector(".mae-ppm-footer-right") as HTMLElement;
const closeBtn = footerRight?.querySelector(".mae-ppm-btn-close") as HTMLElement;
const diffBtn = footerRight?.querySelector(".mae-ppm-btn-diff") as HTMLElement;
const footerRight = this.modalEl.querySelector(".prm-ppm-footer-right") as HTMLElement;
const closeBtn = footerRight?.querySelector(".prm-ppm-btn-close") as HTMLElement;
const diffBtn = footerRight?.querySelector(".prm-ppm-btn-diff") as HTMLElement;
if (closeBtn) closeBtn.style.display = "none";
if (diffBtn) diffBtn.style.display = "none";
if (this.state.phase === "calling") {
area.createDiv({ cls: "mae-ppm-spinner" });
area.createEl("p", { cls: "mae-ppm-calling-text", text: "正在调用 API请稍候…" });
area.createDiv({ cls: "prm-ppm-spinner" });
area.createEl("p", { cls: "prm-ppm-calling-text", text: "正在调用 API请稍候…" });
return;
}
if (this.state.phase === "error") {
const errBox = area.createDiv({ cls: "mae-ppm-error" });
const errIcon = errBox.createDiv({ cls: "mae-ppm-error-icon" });
const errBox = area.createDiv({ cls: "prm-ppm-error" });
const errIcon = errBox.createDiv({ cls: "prm-ppm-error-icon" });
setIcon(errIcon, "x-circle");
errBox.createEl("p", { cls: "mae-ppm-error-text", text: `调用失败:${this.state.message}` });
errBox.createEl("p", { cls: "prm-ppm-error-text", text: `调用失败:${this.state.message}` });
if (closeBtn) closeBtn.style.display = "";
return;
}
// done
const doneBox = area.createDiv({ cls: "mae-ppm-success" });
const doneIcon = doneBox.createDiv({ cls: "mae-ppm-success-icon" });
const doneBox = area.createDiv({ cls: "prm-ppm-success" });
const doneIcon = doneBox.createDiv({ cls: "prm-ppm-success-icon" });
setIcon(doneIcon, "check-circle");
doneBox.createEl("p", { cls: "mae-ppm-success-text", text: "API 返回成功,即将打开 Diff 预览" });
doneBox.createEl("p", { cls: "prm-ppm-success-text", text: "API 返回成功,即将打开 Diff 预览" });
if (diffBtn) diffBtn.style.display = "";
}

View file

@ -89,27 +89,27 @@ export class DiffModal extends Modal {
onOpen(): void {
const { modalEl } = this;
modalEl.empty();
modalEl.addClass("mae-diff-modal");
modalEl.addClass("prm-diff-modal");
const { added, removed } = countChangedLines(this.blocks);
// ── Header ──
const header = modalEl.createDiv({ cls: "mae-dm-header" });
const headerLeft = header.createDiv({ cls: "mae-dm-header-left" });
const iconWrap = headerLeft.createDiv({ cls: "mae-dm-icon" });
const header = modalEl.createDiv({ cls: "prm-dm-header" });
const headerLeft = header.createDiv({ cls: "prm-dm-header-left" });
const iconWrap = headerLeft.createDiv({ cls: "prm-dm-icon" });
iconWrap.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="6" y1="20" x2="6" y2="4"/><line x1="18" y1="4" x2="12" y2="4"/><polyline points="12 4 9 7 12 10"/><line x1="6" y1="14" x2="12" y2="14"/><polyline points="12 14 15 11 12 8" transform="rotate(180 12 11)"/></svg>`;
const titleWrap = headerLeft.createDiv();
titleWrap.createDiv({ cls: "mae-dm-title", text: "Diff Preview" });
titleWrap.createDiv({ cls: "prm-dm-title", text: "Diff Preview" });
titleWrap.createDiv({
cls: "mae-dm-subtitle",
cls: "prm-dm-subtitle",
text: `${this.fileName}${this.hunks.length} changes proposed`,
});
const headerRight = header.createDiv({ cls: "mae-dm-header-right" });
const hunkBadge = headerRight.createSpan({ cls: "mae-dm-hunk-badge" });
const headerRight = header.createDiv({ cls: "prm-dm-header-right" });
const hunkBadge = headerRight.createSpan({ cls: "prm-dm-hunk-badge" });
hunkBadge.setText(`${this.hunks.length} hunks`);
const closeBtn = headerRight.createEl("button", { cls: "mae-dm-close" });
const closeBtn = headerRight.createEl("button", { cls: "prm-dm-close" });
closeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
closeBtn.onclick = () => {
this.result = { action: "reject" };
@ -117,10 +117,10 @@ export class DiffModal extends Modal {
};
// ── Hunk list ──
const content = modalEl.createDiv({ cls: "mae-dm-content" });
const content = modalEl.createDiv({ cls: "prm-dm-content" });
if (this.hunks.length === 0) {
content.createDiv({ cls: "mae-dm-empty", text: "No changes detected." });
content.createDiv({ cls: "prm-dm-empty", text: "No changes detected." });
} else {
this.hunks.forEach((hunk, idx) => {
this.renderHunk(content, hunk, idx);
@ -128,23 +128,23 @@ export class DiffModal extends Modal {
}
// ── Footer ──
const footer = modalEl.createDiv({ cls: "mae-dm-footer" });
const footer = modalEl.createDiv({ cls: "prm-dm-footer" });
const statsEl = footer.createDiv({ cls: "mae-dm-stats" });
statsEl.createSpan({ cls: "mae-dm-stat-label", text: "Changes: " });
statsEl.createSpan({ cls: "mae-dm-stat-add", text: `+${added} lines` });
statsEl.createSpan({ cls: "mae-dm-stat-del", text: `-${removed} lines` });
const statsEl = footer.createDiv({ cls: "prm-dm-stats" });
statsEl.createSpan({ cls: "prm-dm-stat-label", text: "Changes: " });
statsEl.createSpan({ cls: "prm-dm-stat-add", text: `+${added} lines` });
statsEl.createSpan({ cls: "prm-dm-stat-del", text: `-${removed} lines` });
const btns = footer.createDiv({ cls: "mae-dm-btns" });
const btns = footer.createDiv({ cls: "prm-dm-btns" });
const rollbackBtn = btns.createEl("button", { cls: "mae-dm-btn-rollback" });
const rollbackBtn = btns.createEl("button", { cls: "prm-dm-btn-rollback" });
rollbackBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-5h-2"/></svg> Rollback all`;
rollbackBtn.onclick = () => {
this.result = { action: "reject" };
this.close();
};
const applyBtn = btns.createEl("button", { cls: "mae-dm-btn-accept" });
const applyBtn = btns.createEl("button", { cls: "prm-dm-btn-accept" });
applyBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> Apply selected`;
applyBtn.onclick = () => {
this.result = this.buildResultFromAcceptMap();
@ -179,53 +179,53 @@ export class DiffModal extends Modal {
}
private renderHunk(parent: HTMLElement, hunk: Hunk, idx: number): void {
const wrap = parent.createDiv({ cls: "mae-dm-hunk" });
const wrap = parent.createDiv({ cls: "prm-dm-hunk" });
// Hunk header
const hunkHeader = wrap.createDiv({ cls: "mae-dm-hunk-header" });
const hunkLeft = hunkHeader.createDiv({ cls: "mae-dm-hunk-header-left" });
hunkLeft.createSpan({ cls: "mae-dm-hunk-pos", text: hunk.header });
const hunkHeader = wrap.createDiv({ cls: "prm-dm-hunk-header" });
const hunkLeft = hunkHeader.createDiv({ cls: "prm-dm-hunk-header-left" });
hunkLeft.createSpan({ cls: "prm-dm-hunk-pos", text: hunk.header });
const hunkActions = hunkHeader.createDiv({ cls: "mae-dm-hunk-actions" });
const hunkActions = hunkHeader.createDiv({ cls: "prm-dm-hunk-actions" });
// Reject first, Accept second (right of Reject per design spec)
const rejectBtn = hunkActions.createEl("button", { cls: "mae-dm-hunk-btn reject" });
const rejectBtn = hunkActions.createEl("button", { cls: "prm-dm-hunk-btn reject" });
rejectBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> Reject`;
const acceptBtn = hunkActions.createEl("button", { cls: "mae-dm-hunk-btn accept" });
const acceptBtn = hunkActions.createEl("button", { cls: "prm-dm-hunk-btn accept" });
acceptBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> Accept`;
acceptBtn.onclick = () => {
hunk.changeIndices.forEach(i => this.acceptMap.set(i, true));
this.hunkDecisions.set(idx, true);
// Hide the hunk with a brief fade
wrap.addClass("mae-dm-hunk-decided");
wrap.addClass("prm-dm-hunk-decided");
setTimeout(() => wrap.remove(), 220);
};
rejectBtn.onclick = () => {
hunk.changeIndices.forEach(i => this.acceptMap.set(i, false));
this.hunkDecisions.set(idx, false);
wrap.addClass("mae-dm-hunk-decided");
wrap.addClass("prm-dm-hunk-decided");
setTimeout(() => wrap.remove(), 220);
};
// Diff lines
const linesWrap = wrap.createDiv({ cls: "mae-dm-lines" });
const linesWrap = wrap.createDiv({ cls: "prm-dm-lines" });
for (const block of hunk.blocks) {
const row = linesWrap.createDiv({ cls: "mae-dm-line mae-dm-line-" + block.type });
const row = linesWrap.createDiv({ cls: "prm-dm-line prm-dm-line-" + block.type });
const lineNum = row.createSpan({ cls: "mae-dm-linenum" });
const lineNum = row.createSpan({ cls: "prm-dm-linenum" });
const n = block.type === "removed" ? block.oldLineNumber
: block.type === "added" ? block.newLineNumber
: (block.oldLineNumber ?? "");
lineNum.setText(String(n ?? ""));
const prefix = row.createSpan({ cls: "mae-dm-prefix" });
const prefix = row.createSpan({ cls: "prm-dm-prefix" });
prefix.setText(block.type === "added" ? "+" : block.type === "removed" ? "-" : " ");
row.createSpan({ cls: "mae-dm-text", text: block.content });
row.createSpan({ cls: "prm-dm-text", text: block.content });
}
}
}

View file

@ -51,7 +51,7 @@ function buildDecorations(doc: string, anns: Annotation[]): DecorationSet {
to: r.to,
deco: Decoration.mark({
class: cls,
attributes: { "data-mae-id": ann.id },
attributes: { "data-prm-id": ann.id },
}),
});
}
@ -93,12 +93,12 @@ function mergeRanges(
for (const c of covering) {
const spec = c.deco.spec;
if (spec.class) spec.class.split(/\s+/).forEach((cls: string) => classes.add(cls));
if (spec.attributes?.["data-mae-id"]) dataIds.push(spec.attributes["data-mae-id"]);
if (spec.attributes?.["data-prm-id"]) dataIds.push(spec.attributes["data-prm-id"]);
}
const mergedDeco = Decoration.mark({
class: Array.from(classes).join(" "),
attributes: dataIds.length > 0 ? { "data-mae-id": dataIds.join(",") } : undefined,
attributes: dataIds.length > 0 ? { "data-prm-id": dataIds.join(",") } : undefined,
});
result.push({ from, to, deco: mergedDeco });
}
@ -107,19 +107,19 @@ function mergeRanges(
}
function decoClassFor(ann: Annotation, status: "strict" | "fuzzy" | "auto-healed"): string | null {
const fuzzy = status === "fuzzy" ? " cm-multiaiedit-fuzzy" : "";
const healed = status === "auto-healed" ? " cm-multiaiedit-auto-healed" : "";
const fuzzy = status === "fuzzy" ? " cm-promptuary-fuzzy" : "";
const healed = status === "auto-healed" ? " cm-promptuary-auto-healed" : "";
if (ann.type === "highlight") {
const color = ann.highlightColor ?? "yellow";
return `cm-multiaiedit-highlight cm-multiaiedit-highlight-${color}${fuzzy}${healed}`;
return `cm-promptuary-highlight cm-promptuary-highlight-${color}${fuzzy}${healed}`;
}
if (ann.type === "note") {
const color = ann.highlightColor ?? "yellow";
return `cm-multiaiedit-highlight cm-multiaiedit-highlight-${color}${fuzzy}${healed}`;
return `cm-promptuary-highlight cm-promptuary-highlight-${color}${fuzzy}${healed}`;
}
if (ann.type === "review") {
let cls = "cm-multiaiedit-review";
if (ann.strike) cls += " cm-multiaiedit-strike";
let cls = "cm-promptuary-review";
if (ann.strike) cls += " cm-promptuary-strike";
return cls + fuzzy + healed;
}
return null;
@ -139,23 +139,60 @@ function decoClassFor(ann: Annotation, status: "strict" | "fuzzy" | "auto-healed
// ─────────────────────────────────────────────────────────────────────────────
/** Sentinel class added to every DOM-injected highlight span. */
const TABLE_HL_CLASS = "mae-table-hl";
const TABLE_HL_CLASS = "prm-table-hl";
class TableAnnotationPlugin {
private rafId: number | null = null;
private observer: MutationObserver | null = null;
private view: EditorView;
constructor(view: EditorView) {
this.view = view;
this.startObserving();
}
// ── MutationObserver ─────────────────────────────────────────────────────
// In LP mode, clicking a table cell injects a nested CM6 editor (<div
// class="cm-editor">) into the <td>. Clicking away removes it and the cell
// reverts to rendered HTML. Our `update()` callback does NOT fire on these
// purely visual DOM changes (no doc/viewport/annotation change), so we need
// a MutationObserver to detect when a table widget's DOM mutates — especially
// when a nested editor is removed — so we can re-inject highlights.
// ─────────────────────────────────────────────────────────────────────────
private startObserving(): void {
this.observer = new MutationObserver((mutations) => {
const relevant = mutations.some((m) => {
const target = m.target as HTMLElement;
// Change inside a table widget
if (typeof target.closest === "function" && target.closest(".cm-table-widget")) return true;
// A nested editor was added/removed
for (const node of Array.from(m.addedNodes).concat(Array.from(m.removedNodes))) {
const el = node as HTMLElement;
if (typeof el.closest !== "function") continue;
if (el.classList?.contains("cm-editor") || el.querySelector?.(".cm-editor")) return true;
}
return false;
});
if (!relevant) return;
this.scheduleApply();
});
this.observer.observe(this.view.dom, { childList: true, subtree: true });
}
private scheduleApply(): void {
if (this.rafId !== null) cancelAnimationFrame(this.rafId);
this.rafId = requestAnimationFrame(() => {
this.rafId = null;
this.applyTableHighlights(this.view);
});
}
update(update: ViewUpdate): void {
const hasAnnotationChange = update.transactions.some(
(t) => t.effects.some((e) => e.is(setAnnotationsEffect)),
);
if (!update.docChanged && !update.viewportChanged && !hasAnnotationChange) return;
// Defer until after CM6 has finished its own DOM update.
if (this.rafId !== null) cancelAnimationFrame(this.rafId);
this.rafId = requestAnimationFrame(() => {
this.rafId = null;
this.applyTableHighlights(update.view);
});
this.scheduleApply();
}
destroy(): void {
@ -163,6 +200,8 @@ class TableAnnotationPlugin {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.observer?.disconnect();
this.observer = null;
}
private applyTableHighlights(view: EditorView): void {
@ -195,7 +234,7 @@ class TableAnnotationPlugin {
// 3. Walk each visible .cm-line.
for (const lineEl of Array.from(view.dom.querySelectorAll<HTMLElement>(".cm-line"))) {
// Skip lines where CM6 already inserted our decoration spans (edit mode).
if (lineEl.querySelector("[class*='cm-multiaiedit']")) continue;
if (lineEl.querySelector("[class*='cm-promptuary']")) continue;
// Determine the document line text for this DOM element.
let lineText: string;

View file

@ -27,7 +27,7 @@ export class BottomToolbar {
constructor(private app: App, private cb: PopoverCallbacks) {
this.el = document.createElement("div");
this.el.className = "multiaiedit-bottom-toolbar";
this.el.className = "promptuary-bottom-toolbar";
this.el.style.display = "none";
document.body.appendChild(this.el);
}
@ -77,7 +77,7 @@ export class BottomToolbar {
}
private renderModeCapsule(): void {
const wrap = this.el.createDiv({ cls: "mae-popover-capsule" });
const wrap = this.el.createDiv({ cls: "prm-popover-capsule" });
const modes: Array<[ViewMode, string]> = [
["reading", "阅读"],
["reviewing", "批阅"],
@ -94,10 +94,10 @@ export class BottomToolbar {
}
private renderReadingActions(): void {
const row = this.el.createDiv({ cls: "mae-popover-actions" });
const row = this.el.createDiv({ cls: "prm-popover-actions" });
const colors: HighlightColor[] = ["yellow", "blue", "green", "purple"];
for (const color of colors) {
const dot = row.createDiv({ cls: `mae-color ${color}` });
const dot = row.createDiv({ cls: `prm-color ${color}` });
dot.title = `${color} 高亮`;
dot.onclick = () => {
this.selectedColor = color;
@ -118,10 +118,10 @@ export class BottomToolbar {
}
private renderReviewingActions(): void {
const row = this.el.createDiv({ cls: "mae-popover-actions" });
const row = this.el.createDiv({ cls: "prm-popover-actions" });
// Delete button (toggle)
const deleteBtn = row.createEl("button", { cls: "mae-delete-btn", text: "Delete" });
const deleteBtn = row.createEl("button", { cls: "prm-delete-btn", text: "Delete" });
deleteBtn.title = "标记为删除";
deleteBtn.onclick = () => {
this.strikePending = !this.strikePending;
@ -143,14 +143,14 @@ export class BottomToolbar {
};
// Note button
const noteBtn = row.createEl("button", { cls: "mae-note-btn", text: "Add Note" });
const noteBtn = row.createEl("button", { cls: "prm-note-btn", text: "Add Note" });
noteBtn.title = "添加批阅意见";
noteBtn.onclick = () => {
this.toggleNoteArea();
};
// Confirm button
const confirmBtn = row.createEl("button", { cls: "mae-review-confirm-btn", text: "✓" });
const confirmBtn = row.createEl("button", { cls: "prm-review-confirm-btn", text: "✓" });
confirmBtn.title = "确认删除";
confirmBtn.style.display = "none";
confirmBtn.onclick = () => {
@ -164,7 +164,7 @@ export class BottomToolbar {
}
private updateReviewConfirmState(): void {
const confirmBtn = this.el.querySelector(".mae-review-confirm-btn") as HTMLElement;
const confirmBtn = this.el.querySelector(".prm-review-confirm-btn") as HTMLElement;
if (confirmBtn) {
confirmBtn.style.display = (this.strikePending && !this.noteExpanded) ? "" : "none";
}
@ -172,13 +172,13 @@ export class BottomToolbar {
/** Update "删除" indicator in Note area header when Delete is toggled */
private updateStrikeIndicator(): void {
const header = this.el.querySelector(".mae-popover-note-header");
const header = this.el.querySelector(".prm-popover-note-header");
if (!header) return;
let indicator = header.querySelector(".mae-strike-indicator") as HTMLElement;
let indicator = header.querySelector(".prm-strike-indicator") as HTMLElement;
if (this.strikePending) {
if (!indicator) {
indicator = header.createSpan({ cls: "mae-strike-indicator", text: "删除" });
indicator = header.createSpan({ cls: "prm-strike-indicator", text: "删除" });
}
} else {
if (indicator) indicator.remove();
@ -186,8 +186,8 @@ export class BottomToolbar {
}
private updateNoteColorIndicator(): void {
const dot = this.el.querySelector(".mae-popover-note-header .mae-note-dot") as HTMLElement;
const label = this.el.querySelector(".mae-popover-note-header .mae-note-label") as HTMLElement;
const dot = this.el.querySelector(".prm-popover-note-header .prm-note-dot") as HTMLElement;
const label = this.el.querySelector(".prm-popover-note-header .prm-note-label") as HTMLElement;
if (!dot || !label) return;
const colorClasses = ["yellow", "blue", "green", "purple", "orange"];
for (const c of colorClasses) {
@ -200,7 +200,7 @@ export class BottomToolbar {
private toggleNoteArea(): void {
if (this.noteExpanded) {
const area = this.el.querySelector(".mae-popover-note-area");
const area = this.el.querySelector(".prm-popover-note-area");
if (area) area.remove();
this.noteExpanded = false;
this.updateReviewConfirmState();
@ -210,36 +210,36 @@ export class BottomToolbar {
this.noteExpanded = true;
this.updateReviewConfirmState();
const area = this.el.createDiv({ cls: "mae-popover-note-area" });
const area = this.el.createDiv({ cls: "prm-popover-note-area" });
// Header row
const header = area.createDiv({ cls: "mae-popover-note-header" });
const header = area.createDiv({ cls: "prm-popover-note-header" });
if (this.mode === "reviewing") {
header.createDiv({ cls: "mae-note-dot orange" });
header.createDiv({ cls: "mae-divider" });
header.createSpan({ cls: "mae-note-label orange", text: "批阅" });
header.createDiv({ cls: "prm-note-dot orange" });
header.createDiv({ cls: "prm-divider" });
header.createSpan({ cls: "prm-note-label orange", text: "批阅" });
if (this.strikePending) {
header.createSpan({ cls: "mae-strike-indicator", text: "删除" });
header.createSpan({ cls: "prm-strike-indicator", text: "删除" });
}
} else {
header.createDiv({ cls: `mae-note-dot ${this.selectedColor}` });
header.createDiv({ cls: "mae-divider" });
header.createSpan({ cls: `mae-note-label ${this.selectedColor}`, text: "笔记" });
header.createDiv({ cls: `prm-note-dot ${this.selectedColor}` });
header.createDiv({ cls: "prm-divider" });
header.createSpan({ cls: `prm-note-label ${this.selectedColor}`, text: "笔记" });
}
// Input wrapper
const inputWrap = area.createDiv({ cls: "mae-popover-note-input-wrap" });
const inputWrap = area.createDiv({ cls: "prm-popover-note-input-wrap" });
const input = inputWrap.createEl("input", {
type: "text",
cls: "mae-popover-note-input",
cls: "prm-popover-note-input",
});
input.placeholder = this.mode === "reviewing"
? "输入批阅意见…"
: "为这段高亮添加笔记…";
// Footer row
const footer = area.createDiv({ cls: "mae-popover-note-footer" });
const saveBtn = footer.createEl("button", { cls: "mae-popover-note-save", text: "Save" });
const footer = area.createDiv({ cls: "prm-popover-note-footer" });
const saveBtn = footer.createEl("button", { cls: "prm-popover-note-save", text: "Save" });
input.onkeydown = (e) => {
if (e.key === "Enter") {

View file

@ -53,7 +53,7 @@ export class SelectionPopover {
constructor(private app: App, private cb: PopoverCallbacks) {
this.el = document.createElement("div");
this.el.className = "multiaiedit-popover";
this.el.className = "promptuary-popover";
this.el.style.display = "none";
document.body.appendChild(this.el);
}
@ -68,7 +68,7 @@ export class SelectionPopover {
if (mode === this.mode) return;
// Preserve any text the user has typed into the note input before re-rendering
const savedNoteText = this.noteExpanded
? (this.el.querySelector(".mae-popover-note-input") as HTMLInputElement | null)?.value ?? ""
? (this.el.querySelector(".prm-popover-note-input") as HTMLInputElement | null)?.value ?? ""
: "";
this.mode = mode;
// Mode changed → DOM is stale, force re-render on the next show().
@ -214,9 +214,9 @@ export class SelectionPopover {
/** Title row: label left + mode capsule right */
private renderTitleRow(): void {
const row = this.el.createDiv({ cls: "mae-popover-title-row" });
const row = this.el.createDiv({ cls: "prm-popover-title-row" });
const title = row.createSpan({
cls: "mae-popover-title",
cls: "prm-popover-title",
text: this.editingAnnotationId ? "编辑记录" : "添加记录",
});
void title;
@ -225,7 +225,7 @@ export class SelectionPopover {
/** Build the inline mode-switching capsule */
private buildModeCapsule(parent: HTMLElement): void {
const wrap = parent.createDiv({ cls: "mae-popover-capsule" });
const wrap = parent.createDiv({ cls: "prm-popover-capsule" });
const modes: Array<[ViewMode, string]> = [
["reading", "阅读"],
["reviewing", "批阅"],
@ -244,16 +244,16 @@ export class SelectionPopover {
/** Render reading mode action buttons: 4 color dots + divider + Note button + mode capsule */
private renderReadingActions(): void {
const row = this.el.createDiv({ cls: "mae-popover-actions" });
const row = this.el.createDiv({ cls: "prm-popover-actions" });
const colors: HighlightColor[] = ["yellow", "blue", "green", "purple"];
for (const color of colors) {
const dot = row.createDiv({ cls: `mae-color ${color}` });
const dot = row.createDiv({ cls: `prm-color ${color}` });
if (color === this.selectedColor) dot.addClass("active");
dot.title = `${color} 高亮`;
dot.onmousedown = (e) => {
e.preventDefault();
// Update active state on all dots
row.querySelectorAll(".mae-color").forEach(d => d.removeClass("active"));
row.querySelectorAll(".prm-color").forEach(d => d.removeClass("active"));
dot.addClass("active");
this.selectedColor = color;
if (this.noteExpanded) {
@ -268,8 +268,8 @@ export class SelectionPopover {
};
}
// Vertical divider
row.createDiv({ cls: "mae-divider" });
const noteBtn = row.createEl("button", { cls: "mae-note-btn", text: "Add Note" });
row.createDiv({ cls: "prm-divider" });
const noteBtn = row.createEl("button", { cls: "prm-note-btn", text: "Add Note" });
noteBtn.title = "添加笔记";
noteBtn.onmousedown = (e) => {
e.preventDefault();
@ -279,10 +279,10 @@ export class SelectionPopover {
/** Render reviewing mode action buttons: Delete (toggle) + Note + confirm ✓ + mode capsule */
private renderReviewingActions(): void {
const row = this.el.createDiv({ cls: "mae-popover-actions" });
const row = this.el.createDiv({ cls: "prm-popover-actions" });
// Delete button (toggle — marks selection as strikethrough)
const deleteBtn = row.createEl("button", { cls: "mae-delete-btn", text: "Delete" });
const deleteBtn = row.createEl("button", { cls: "prm-delete-btn", text: "Delete" });
deleteBtn.title = "标记为删除";
deleteBtn.onmousedown = (e) => {
e.preventDefault();
@ -307,7 +307,7 @@ export class SelectionPopover {
};
// Note button
const noteBtn = row.createEl("button", { cls: "mae-note-btn", text: "Add Note" });
const noteBtn = row.createEl("button", { cls: "prm-note-btn", text: "Add Note" });
noteBtn.title = "添加批阅意见";
noteBtn.onmousedown = (e) => {
e.preventDefault();
@ -315,7 +315,7 @@ export class SelectionPopover {
};
// Confirm button — visible only when Delete is on and Note is NOT expanded
const confirmBtn = row.createEl("button", { cls: "mae-review-confirm-btn", text: "✓" });
const confirmBtn = row.createEl("button", { cls: "prm-review-confirm-btn", text: "✓" });
confirmBtn.title = "确认删除";
confirmBtn.style.display = "none";
confirmBtn.onmousedown = (e) => {
@ -332,7 +332,7 @@ export class SelectionPopover {
/** Update confirm button visibility based on strike state and note expansion */
private updateReviewConfirmState(): void {
const confirmBtn = this.el.querySelector(".mae-review-confirm-btn") as HTMLElement;
const confirmBtn = this.el.querySelector(".prm-review-confirm-btn") as HTMLElement;
if (confirmBtn) {
confirmBtn.style.display = (this.strikePending && !this.noteExpanded) ? "" : "none";
}
@ -354,7 +354,7 @@ export class SelectionPopover {
this.selectedColor = color;
this.toggleNoteArea();
// Pre-fill the input with existing text
const input = this.el.querySelector(".mae-popover-note-input") as HTMLInputElement;
const input = this.el.querySelector(".prm-popover-note-input") as HTMLInputElement;
if (input) {
input.value = text;
requestAnimationFrame(() => {
@ -368,7 +368,7 @@ export class SelectionPopover {
private toggleNoteArea(): void {
if (this.noteExpanded) {
// Collapse: remove note area
const area = this.el.querySelector(".mae-popover-note-area");
const area = this.el.querySelector(".prm-popover-note-area");
if (area) area.remove();
this.noteExpanded = false;
// Show confirm button again if strike is pending
@ -383,21 +383,21 @@ export class SelectionPopover {
// Hide confirm button (note area has its own Save)
this.updateReviewConfirmState();
const area = this.el.createDiv({ cls: "mae-popover-note-area" });
const area = this.el.createDiv({ cls: "prm-popover-note-area" });
// Input wrapper (no header — color is shown via active state on dots above)
const inputWrap = area.createDiv({ cls: "mae-popover-note-input-wrap" });
const inputWrap = area.createDiv({ cls: "prm-popover-note-input-wrap" });
const input = inputWrap.createEl("input", {
type: "text",
cls: "mae-popover-note-input",
cls: "prm-popover-note-input",
});
input.placeholder = this.mode === "reviewing"
? "输入批阅意见AI 会推断要怎么改…"
: "为这段高亮添加笔记…";
// Footer row: Save button
const footer = area.createDiv({ cls: "mae-popover-note-footer" });
const saveBtn = footer.createEl("button", { cls: "mae-popover-note-save", text: "Save" });
const footer = area.createDiv({ cls: "prm-popover-note-footer" });
const saveBtn = footer.createEl("button", { cls: "prm-popover-note-save", text: "Save" });
// Wire events
input.onkeydown = (e) => {

View file

@ -10,7 +10,7 @@ const TOKEN_WARN = 100_000;
export interface ExportOptions {
includeReadingNotes: boolean; // §4.5: default false
exportDir: string; // e.g. ".multiaiedit/exports"
exportDir: string; // e.g. ".promptuary/exports"
}
/**
@ -163,15 +163,16 @@ export class ReviewExporter {
}
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const target = `${this.exportDir}/${fileName}-${stamp}.md`;
// Use vault.create() so the file is immediately indexed in Vault
const created = await this.app.vault.create(target, md);
// Use adapter.write() instead of vault.create() — hidden dirs (.promptuary)
// are not indexed by Vault, so vault.create() returns null.
await adapter.write(target, md);
const tokens = estimateTokens(md);
if (tokens > TOKEN_WARN) {
new Notice(`已导出(约 ${tokens} tokens建议分段处理`);
} else {
new Notice(`已导出(约 ${tokens} tokens`);
}
return created.path;
return target;
}
}

View file

@ -9,6 +9,10 @@ import {
} from "obsidian";
import { registerIcons } from "./utils/icons";
// Inline SVG assets at build time via esbuild text loader
import logoSvg from "../img/logo.svg";
import emptyStateSvg from "../img/empty-state.svg";
import {
Annotation,
AnnotationType,
@ -33,7 +37,7 @@ import { BottomToolbar } from "./editor/BottomToolbar";
import { NoteModal, ReviewModal } from "./editor/NoteModal";
import {
DEFAULT_SETTINGS,
MultiAIEditSettings,
PromptuarySettings,
SettingsTab,
} from "./settings/SettingsTab";
import { SIDEBAR_VIEW_TYPE, SidebarView } from "./sidebar/SidebarView";
@ -45,6 +49,7 @@ import {
} from "./export/Exporters";
import { newAnnotationId, sha256 } from "./utils/hash";
import { isMobile } from "./utils/platform";
import { openFolderInSystem, revealInFileManager } from "./utils/FolderOpener";
import { EditorView } from "@codemirror/view";
// v0.2 Agent bridge imports
@ -74,8 +79,8 @@ import {
import { APIConfirmModal, APIProgressModal } from "./api/APIExecuteModal";
import { buildReviewMarkdown, estimateTokens } from "./export/Exporters";
export default class MultiAIEditPlugin extends Plugin {
settings: MultiAIEditSettings = DEFAULT_SETTINGS;
export default class PromptuaryPlugin extends Plugin {
settings: PromptuarySettings = DEFAULT_SETTINGS;
store!: AnnotationStore;
popover: SelectionPopover | null = null;
private toolbar: BottomToolbar | null = null;
@ -88,11 +93,11 @@ export default class MultiAIEditPlugin extends Plugin {
private fileChangeMonitor: FileChangeMonitor | null = null;
private originalTextBeforeAgent: string | null = null;
/** Logo SVG data URI (read once at load) */
logoUrl = "";
/** Logo SVG data URI (inlined at build time) */
logoUrl = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(logoSvg)))}`;
/** Empty state SVG data URI for sidebar placeholder */
emptyStateUrl = "";
/** Empty state SVG data URI (inlined at build time) */
emptyStateUrl = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(emptyStateSvg)))}`;
/** Execution state visible to sidebar for status display */
executionState: null | { type: "agent" | "api" } = null;
@ -103,18 +108,6 @@ export default class MultiAIEditPlugin extends Plugin {
// Register custom icons
registerIcons();
// Load plugin logo as data URI for sidebar & settings
try {
const svgContent = await this.app.vault.adapter.read(`${this.manifest.dir}/img/logo.svg`);
this.logoUrl = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgContent)))}`;
} catch { /* logo optional */ }
// Load empty state illustration as data URI
try {
const svgContent = await this.app.vault.adapter.read(`${this.manifest.dir}/img/empty-state.svg`);
this.emptyStateUrl = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgContent)))}`;
} catch { /* empty state optional */ }
this.store = new AnnotationStore(this.app, () => this.settings.sidecarDir);
this.store.registerVaultEvents();
@ -130,10 +123,10 @@ export default class MultiAIEditPlugin extends Plugin {
// Sidebar view
this.registerView(SIDEBAR_VIEW_TYPE, (leaf) => new SidebarView(leaf, this));
this.addRibbonIcon("highlighter", "MultiAIEdit 侧边栏", () => this.openSidebar());
this.addRibbonIcon("highlighter", "Promptuary 侧边栏", () => this.openSidebar());
this.addCommand({
id: "open-sidebar",
name: "打开 MultiAIEdit 侧边栏",
name: "打开 Promptuary 侧边栏",
callback: () => this.openSidebar(),
});
@ -152,7 +145,7 @@ export default class MultiAIEditPlugin extends Plugin {
this.registerDomEvent(document, "mousedown", (e) => {
const target = e.target as HTMLElement | null;
if (!target) return;
if (target.closest(".multiaiedit-popover")) return;
if (target.closest(".promptuary-popover")) return;
if (target.closest(".cm-editor")) {
this.isDraggingSelection = true;
this.popover?.hide();
@ -250,11 +243,17 @@ export default class MultiAIEditPlugin extends Plugin {
async loadSettings(): Promise<void> {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
// Migration: auto-migrate from hidden .multiaiedit/exports to visible MultiAIEdit/exports
if (this.settings.exportDir === ".multiaiedit/exports") {
// Migration: auto-migrate from visible export dirs to hidden .promptuary/exports
const legacyExportDirs = ["Promptuary/exports", "MultiAIEdit/exports", ".multiaiedit/exports"];
if (legacyExportDirs.includes(this.settings.exportDir)) {
this.settings.exportDir = DEFAULT_SETTINGS.exportDir;
await this.saveSettings();
}
// Migration: sidecarDir .multiaiedit → .promptuary
if (this.settings.sidecarDir === ".multiaiedit/annotations") {
this.settings.sidecarDir = DEFAULT_SETTINGS.sidecarDir;
await this.saveSettings();
}
}
async saveSettings(): Promise<void> {
@ -1222,10 +1221,14 @@ export default class MultiAIEditPlugin extends Plugin {
includeReadingNotes: this.settings.includeReadingNotesInExport,
});
if (target) {
const file = this.app.vault.getAbstractFileByPath(target);
if (file instanceof TFile) {
await this.app.workspace.getLeaf(false).openFile(file);
new Notice(`导出成功,正在打开: ${target}`);
// Reveal the exported file in Finder / Explorer
const result = await revealInFileManager(this.app, target);
if (!result.success) {
new Notice(`打开文件夹失败: ${result.error ?? "未知错误"}`);
}
} else {
new Notice("导出失败:未生成文件");
}
}

View file

@ -1,5 +1,5 @@
import { App, Modal, Notice, PluginSettingTab, Setting, setIcon } from "obsidian";
import type MultiAIEditPlugin from "../main";
import type PromptuaryPlugin from "../main";
import { ViewMode } from "../annotation/AnnotationModel";
import { CommandRule, CommandRuleStore, PRESET_RULES } from "../agent/CommandRuleStore";
import { detectAgents, AgentInfo } from "../agent/AgentDetector";
@ -15,7 +15,7 @@ export interface APISettings {
maxTokens: number;
}
export interface MultiAIEditSettings {
export interface PromptuarySettings {
defaultMode: ViewMode;
contextSpan: number;
sidecarDir: string;
@ -36,11 +36,11 @@ export const DEFAULT_API_SETTINGS: APISettings = {
maxTokens: 4096,
};
export const DEFAULT_SETTINGS: MultiAIEditSettings = {
export const DEFAULT_SETTINGS: PromptuarySettings = {
defaultMode: "reading",
contextSpan: 50,
sidecarDir: ".multiaiedit/annotations",
exportDir: "MultiAIEdit/exports",
sidecarDir: ".promptuary/annotations",
exportDir: ".promptuary/exports",
includeReadingNotesInExport: false,
terminalApp: "Terminal",
customCommandRules: [],
@ -66,20 +66,20 @@ class AddRuleModal extends Modal {
onOpen(): void {
const { contentEl, modalEl } = this;
contentEl.empty();
contentEl.addClass("mae-add-rule-modal");
modalEl.addClass("mae-add-rule-modal");
contentEl.addClass("prm-add-rule-modal");
modalEl.addClass("prm-add-rule-modal");
// Header
const header = contentEl.createDiv({ cls: "mae-arm-header" });
const headerLeft = header.createDiv({ cls: "mae-arm-header-left" });
const icon = headerLeft.createDiv({ cls: "mae-arm-icon" });
const header = contentEl.createDiv({ cls: "prm-arm-header" });
const headerLeft = header.createDiv({ cls: "prm-arm-header-left" });
const icon = headerLeft.createDiv({ cls: "prm-arm-icon" });
setIcon(icon, "zap");
const titleWrap = headerLeft.createDiv({});
titleWrap.createDiv({ cls: "mae-arm-title", text: "添加命令规则" });
titleWrap.createDiv({ cls: "mae-arm-subtitle", text: "自定义 Agent CLI 命令模板" });
titleWrap.createDiv({ cls: "prm-arm-title", text: "添加命令规则" });
titleWrap.createDiv({ cls: "prm-arm-subtitle", text: "自定义 Agent CLI 命令模板" });
// Form
const form = contentEl.createDiv({ cls: "mae-arm-form" });
const form = contentEl.createDiv({ cls: "prm-arm-form" });
let newId = "";
let newLabel = "";
@ -121,9 +121,9 @@ class AddRuleModal extends Modal {
);
// Template variables reference
const varRef = form.createDiv({ cls: "mae-arm-var-ref" });
varRef.createDiv({ cls: "mae-arm-var-title", text: "可用模板变量" });
const varGrid = varRef.createDiv({ cls: "mae-arm-var-grid" });
const varRef = form.createDiv({ cls: "prm-arm-var-ref" });
varRef.createDiv({ cls: "prm-arm-var-title", text: "可用模板变量" });
const varGrid = varRef.createDiv({ cls: "prm-arm-var-grid" });
const vars = [
["{{vaultPath}}", "Vault 根目录"],
["{{instructionFile}}", "指令文件路径"],
@ -132,21 +132,21 @@ class AddRuleModal extends Modal {
["{{prompt}}", "内联 Prompt"],
];
for (const [v, desc] of vars) {
const item = varGrid.createDiv({ cls: "mae-arm-var-item" });
item.createDiv({ cls: "mae-arm-var-code", text: v });
item.createDiv({ cls: "mae-arm-var-desc", text: desc });
const item = varGrid.createDiv({ cls: "prm-arm-var-item" });
item.createDiv({ cls: "prm-arm-var-code", text: v });
item.createDiv({ cls: "prm-arm-var-desc", text: desc });
}
// Footer
const footer = contentEl.createDiv({ cls: "mae-arm-footer" });
const footer = contentEl.createDiv({ cls: "prm-arm-footer" });
const cancelBtn = footer.createEl("button", {
cls: "mae-arm-btn-cancel",
cls: "prm-arm-btn-cancel",
text: "取消",
});
cancelBtn.onclick = () => this.close();
const saveBtn = footer.createEl("button", {
cls: "mae-arm-btn-save",
cls: "prm-arm-btn-save",
text: "添加规则",
});
saveBtn.onclick = () => {
@ -193,7 +193,7 @@ class AddRuleModal extends Modal {
// ---------- Settings Tab ----------
export class SettingsTab extends PluginSettingTab {
constructor(app: App, private plugin: MultiAIEditPlugin) {
constructor(app: App, private plugin: PromptuaryPlugin) {
super(app, plugin);
}
@ -202,17 +202,17 @@ export class SettingsTab extends PluginSettingTab {
containerEl.empty();
// Page header
const pageHeader = containerEl.createDiv({ cls: "mae-settings-header" });
const pageHeaderLeft = pageHeader.createDiv({ cls: "mae-settings-header-left" });
const pageIcon = pageHeaderLeft.createDiv({ cls: "mae-settings-page-icon" });
const pageHeader = containerEl.createDiv({ cls: "prm-settings-header" });
const pageHeaderLeft = pageHeader.createDiv({ cls: "prm-settings-header-left" });
const pageIcon = pageHeaderLeft.createDiv({ cls: "prm-settings-page-icon" });
if (this.plugin.logoUrl) {
const img = pageIcon.createEl("img", { cls: "mae-logo-img" });
const img = pageIcon.createEl("img", { cls: "prm-logo-img" });
img.src = this.plugin.logoUrl;
img.alt = "MultiAIEdit";
img.alt = "Promptuary";
} else {
setIcon(pageIcon, "mae-highlighter");
setIcon(pageIcon, "prm-highlighter");
}
pageHeaderLeft.createDiv({ cls: "mae-settings-page-title", text: "MultiAIEdit" });
pageHeaderLeft.createDiv({ cls: "prm-settings-page-title", text: "Promptuary" });
// --- Section 1: Basic ---
this.renderSection(containerEl, {
@ -255,21 +255,21 @@ export class SettingsTab extends PluginSettingTab {
render: (el: HTMLElement) => void;
},
): void {
const section = containerEl.createDiv({ cls: "mae-settings-section" });
const section = containerEl.createDiv({ cls: "prm-settings-section" });
// Clickable header
const header = section.createDiv({ cls: "mae-settings-section-header" });
const headerLeft = header.createDiv({ cls: "mae-settings-section-left" });
const iconEl = headerLeft.createDiv({ cls: "mae-settings-section-icon" });
const header = section.createDiv({ cls: "prm-settings-section-header" });
const headerLeft = header.createDiv({ cls: "prm-settings-section-left" });
const iconEl = headerLeft.createDiv({ cls: "prm-settings-section-icon" });
setIcon(iconEl, opts.icon);
const textWrap = headerLeft.createDiv({ cls: "mae-settings-section-text" });
textWrap.createDiv({ cls: "mae-settings-section-title", text: opts.title });
textWrap.createDiv({ cls: "mae-settings-section-desc", text: opts.desc });
const chevron = header.createDiv({ cls: "mae-settings-section-chevron" });
const textWrap = headerLeft.createDiv({ cls: "prm-settings-section-text" });
textWrap.createDiv({ cls: "prm-settings-section-title", text: opts.title });
textWrap.createDiv({ cls: "prm-settings-section-desc", text: opts.desc });
const chevron = header.createDiv({ cls: "prm-settings-section-chevron" });
setIcon(chevron, "chevron-down");
// Collapsible body
const body = section.createDiv({ cls: "mae-settings-section-body" });
const body = section.createDiv({ cls: "prm-settings-section-body" });
if (opts.open) {
body.addClass("is-open");
chevron.addClass("is-open");
@ -331,7 +331,7 @@ export class SettingsTab extends PluginSettingTab {
new Setting(containerEl)
.setName("导出目录")
.setDesc("导出批阅文件的保存位置vault 相对路径)")
.setDesc("导出批阅文件的保存位置vault 相对路径)。默认隐藏目录,导出后自动在 Finder 中打开")
.addText((t) =>
t
.setValue(this.plugin.settings.exportDir)
@ -373,32 +373,32 @@ export class SettingsTab extends PluginSettingTab {
// --- Detected agents as cards ---
const agentResults: AgentInfo[] = detectAgents(PRESET_RULES);
const detectedWrap = containerEl.createDiv({ cls: "mae-agent-cards" });
const detectedWrap = containerEl.createDiv({ cls: "prm-agent-cards" });
for (const info of agentResults) {
const card = detectedWrap.createDiv({
cls: `mae-agent-card ${info.installed ? "installed" : "not-installed"}`,
cls: `prm-agent-card ${info.installed ? "installed" : "not-installed"}`,
});
// Avatar
const avatar = card.createDiv({ cls: "mae-agent-card-avatar" });
const avatar = card.createDiv({ cls: "prm-agent-card-avatar" });
avatar.setText(info.rule.label.charAt(0));
// Info
const infoEl = card.createDiv({ cls: "mae-agent-card-info" });
const nameRow = infoEl.createDiv({ cls: "mae-agent-card-name-row" });
nameRow.createDiv({ cls: "mae-agent-card-name", text: info.rule.label });
const infoEl = card.createDiv({ cls: "prm-agent-card-info" });
const nameRow = infoEl.createDiv({ cls: "prm-agent-card-name-row" });
nameRow.createDiv({ cls: "prm-agent-card-name", text: info.rule.label });
const badge = nameRow.createDiv({
cls: `mae-agent-card-badge ${info.installed ? "installed" : "missing"}`,
cls: `prm-agent-card-badge ${info.installed ? "installed" : "missing"}`,
text: info.installed ? "已安装" : "未安装",
});
if (info.rule.vendor) {
infoEl.createDiv({ cls: "mae-agent-card-vendor", text: info.rule.vendor });
infoEl.createDiv({ cls: "prm-agent-card-vendor", text: info.rule.vendor });
}
if (!info.installed) {
infoEl.createDiv({
cls: "mae-agent-card-hint",
cls: "prm-agent-card-hint",
text: `安装:${info.rule.installHint}`,
});
}
@ -422,10 +422,10 @@ export class SettingsTab extends PluginSettingTab {
const customRules = ruleStore.getCustomRules();
// Sub-section header
const ruleHeader = containerEl.createDiv({ cls: "mae-settings-sub-header" });
ruleHeader.createDiv({ cls: "mae-settings-sub-title", text: "自定义命令规则" });
const ruleHeader = containerEl.createDiv({ cls: "prm-settings-sub-header" });
ruleHeader.createDiv({ cls: "prm-settings-sub-title", text: "自定义命令规则" });
const addBtn = ruleHeader.createEl("button", {
cls: "mae-settings-sub-btn",
cls: "prm-settings-sub-btn",
text: "+ 添加",
});
addBtn.onclick = async () => {
@ -449,20 +449,20 @@ export class SettingsTab extends PluginSettingTab {
if (customRules.length === 0) {
containerEl.createDiv({
cls: "mae-settings-empty",
cls: "prm-settings-empty",
text: "暂无自定义规则,点击上方「+ 添加」按钮创建。",
});
} else {
const ruleCards = containerEl.createDiv({ cls: "mae-rule-cards" });
const ruleCards = containerEl.createDiv({ cls: "prm-rule-cards" });
for (const rule of customRules) {
this.renderCustomRuleCard(ruleCards, rule);
}
}
// Available variables help
const varDetails = containerEl.createEl("details", { cls: "mae-settings-var-details" });
const varDetails = containerEl.createEl("details", { cls: "prm-settings-var-details" });
varDetails.createEl("summary", { text: "可用模板变量参考" });
const varTable = varDetails.createEl("table", { cls: "mae-settings-var-table" });
const varTable = varDetails.createEl("table", { cls: "prm-settings-var-table" });
const vars = [
["{{vaultPath}}", "Vault 根目录绝对路径"],
["{{instructionFile}}", "生成的批注指令文件路径"],
@ -478,21 +478,21 @@ export class SettingsTab extends PluginSettingTab {
}
private renderCustomRuleCard(containerEl: HTMLElement, rule: CommandRule): void {
const card = containerEl.createDiv({ cls: "mae-rule-card" });
const card = containerEl.createDiv({ cls: "prm-rule-card" });
// Left: info
const info = card.createDiv({ cls: "mae-rule-card-info" });
info.createDiv({ cls: "mae-rule-card-label", text: rule.label });
const meta = info.createDiv({ cls: "mae-rule-card-meta" });
const info = card.createDiv({ cls: "prm-rule-card-info" });
info.createDiv({ cls: "prm-rule-card-label", text: rule.label });
const meta = info.createDiv({ cls: "prm-rule-card-meta" });
meta.createSpan({ text: `检测: ${rule.detectCmd}` });
// Template preview (truncate)
const tpl = rule.template.length > 60 ? rule.template.slice(0, 57) + "…" : rule.template;
const tplSpan = meta.createSpan({ cls: "mae-rule-card-template" });
const tplSpan = meta.createSpan({ cls: "prm-rule-card-template" });
tplSpan.setText(`模板: ${tpl}`);
// Right: delete button
const delBtn = card.createEl("button", {
cls: "mae-rule-card-del",
cls: "prm-rule-card-del",
text: "删除",
});
delBtn.onclick = async () => {
@ -541,7 +541,7 @@ export class SettingsTab extends PluginSettingTab {
await this.plugin.saveSettings();
});
t.inputEl.type = "password";
t.inputEl.addClass("mae-api-key-input");
t.inputEl.addClass("prm-api-key-input");
// Toggle visibility button
const inputParent = t.inputEl.parentElement;
@ -549,7 +549,7 @@ export class SettingsTab extends PluginSettingTab {
inputParent.style.position = "relative";
}
const toggleBtn = inputParent?.createEl("button", {
cls: "mae-api-key-toggle",
cls: "prm-api-key-toggle",
});
if (toggleBtn) {
setIcon(toggleBtn, "eye");

View file

@ -2,11 +2,11 @@ import { ItemView, Menu, MarkdownView, Notice, TFile, WorkspaceLeaf, setIcon } f
import { Annotation, ViewMode } from "../annotation/AnnotationModel";
import { AnnotationStore } from "../annotation/AnnotationStore";
import { locate, fuzzyLocate, computeLineHint, computeOccurrenceIndex } from "../annotation/AnnotationLocator";
import type MultiAIEditPlugin from "../main";
import type PromptuaryPlugin from "../main";
import { isMobile } from "../utils/platform";
import { EditorView } from "@codemirror/view";
export const SIDEBAR_VIEW_TYPE = "multiaiedit-sidebar";
export const SIDEBAR_VIEW_TYPE = "promptuary-sidebar";
export class SidebarView extends ItemView {
private mode: ViewMode = "reading";
@ -21,7 +21,7 @@ export class SidebarView extends ItemView {
private modifyDebounceTimer: number | null = null;
private lastReviewCount = 0;
constructor(leaf: WorkspaceLeaf, private plugin: MultiAIEditPlugin) {
constructor(leaf: WorkspaceLeaf, private plugin: PromptuaryPlugin) {
super(leaf);
}
@ -29,7 +29,7 @@ export class SidebarView extends ItemView {
return SIDEBAR_VIEW_TYPE;
}
getDisplayText(): string {
return "MultiAIEdit";
return "Promptuary";
}
getIcon(): string {
return "highlighter";
@ -37,13 +37,13 @@ export class SidebarView extends ItemView {
async onOpen(): Promise<void> {
this.contentEl.empty();
this.container = this.contentEl.createDiv({ cls: "multiaiedit-sidebar" });
this.container = this.contentEl.createDiv({ cls: "promptuary-sidebar" });
this.mode = this.plugin.settings.defaultMode;
// Three-section layout: Header → Scrollable content → Bottom action bar
this.headerEl = this.container.createDiv({ cls: "mae-sidebar-header" });
this.contentEl2 = this.container.createDiv({ cls: "mae-sidebar-content" });
this.actionBarEl = this.container.createDiv({ cls: "mae-sidebar-action-bar" });
this.headerEl = this.container.createDiv({ cls: "prm-sidebar-header" });
this.contentEl2 = this.container.createDiv({ cls: "prm-sidebar-content" });
this.actionBarEl = this.container.createDiv({ cls: "prm-sidebar-action-bar" });
this.registerEvent(
this.app.workspace.on("active-leaf-change", () => this.onActiveLeafChange()),
@ -298,39 +298,39 @@ export class SidebarView extends ItemView {
/** Render empty state: illustration + hint text */
private renderEmptyState(parent: HTMLElement, text: string): void {
const wrap = parent.createDiv({ cls: "mae-empty-state" });
const wrap = parent.createDiv({ cls: "prm-empty-state" });
if (this.plugin.emptyStateUrl) {
const img = wrap.createEl("img", { cls: "mae-empty-state-img" });
const img = wrap.createEl("img", { cls: "prm-empty-state-img" });
img.src = this.plugin.emptyStateUrl;
img.alt = "";
}
wrap.createDiv({ cls: "mae-empty-state-text", text });
wrap.createDiv({ cls: "prm-empty-state-text", text });
}
/** Render sidebar header: icon + title + settings */
private renderHeader(parent: HTMLElement): void {
const row = parent.createDiv({ cls: "mae-header-row" });
const left = row.createDiv({ cls: "mae-header-left" });
const iconWrap = left.createDiv({ cls: "mae-header-icon" });
const row = parent.createDiv({ cls: "prm-header-row" });
const left = row.createDiv({ cls: "prm-header-left" });
const iconWrap = left.createDiv({ cls: "prm-header-icon" });
if (this.plugin.logoUrl) {
const img = iconWrap.createEl("img", { cls: "mae-logo-img" });
const img = iconWrap.createEl("img", { cls: "prm-logo-img" });
img.src = this.plugin.logoUrl;
img.alt = "MultiAIEdit";
img.alt = "Promptuary";
} else {
setIcon(iconWrap, "mae-highlighter");
setIcon(iconWrap, "prm-highlighter");
}
left.createSpan({ cls: "mae-header-title", text: "MultiAIEdit" });
left.createSpan({ cls: "prm-header-title", text: "Promptuary" });
const settingsBtn = row.createEl("button", { cls: "mae-header-settings" });
const settingsBtn = row.createEl("button", { cls: "prm-header-settings" });
setIcon(settingsBtn, "settings");
settingsBtn.onclick = () => {
// Open plugin settings
(this.app as any).setting?.open();
(this.app as any).setting?.openTabById?.("multiaiedit");
(this.app as any).setting?.openTabById?.("promptuary");
};
// Mode capsule (inside header, below title row)
const capsule = parent.createDiv({ cls: "mae-mode-capsule" });
const capsule = parent.createDiv({ cls: "prm-mode-capsule" });
const modes: Array<[ViewMode, string]> = [
["reading", "阅读"],
["reviewing", "批阅"],
@ -351,7 +351,7 @@ export class SidebarView extends ItemView {
// Row 1: Agent 批阅(紫色 CTA 主按钮,桌面端)
if (!isMobile()) {
const execBtn = parent.createEl("button", { cls: "mae-action-execute" });
const execBtn = parent.createEl("button", { cls: "prm-action-execute" });
setIcon(execBtn, "sparkles");
execBtn.title = "选择 Agent CLI 执行批阅修改";
const isAgentExecuting = isExecuting && executing.type === "agent";
@ -361,7 +361,7 @@ export class SidebarView extends ItemView {
execBtn.createSpan({ text: "Agent 批阅" });
}
execBtn.disabled = !hasReviews || isExecuting;
if (isAgentExecuting) execBtn.addClass("mae-executing");
if (isAgentExecuting) execBtn.addClass("prm-executing");
execBtn.onclick = async () => {
if (!hasReviews || isExecuting) return;
if (!hasReviews) { new Notice("当前文件没有批阅意见"); return; }
@ -377,23 +377,23 @@ export class SidebarView extends ItemView {
}
// Row 2: API批阅 + 复制Prompt + 查看批注文件
const row2 = parent.createDiv({ cls: "mae-action-row" });
const row2 = parent.createDiv({ cls: "prm-action-row" });
// API 批阅
const hasApiKey = !!this.plugin.settings.apiSettings?.apiKey;
const isApiExecuting = isExecuting && executing.type === "api";
const apiBtn = row2.createEl("button", { cls: "mae-action-btn" });
const apiBtn = row2.createEl("button", { cls: "prm-action-btn" });
setIcon(apiBtn, "zap");
if (isApiExecuting) {
apiBtn.createSpan({ cls: "mae-action-short", text: "API" });
apiBtn.createSpan({ cls: "mae-action-long", text: " 批阅中…" });
apiBtn.createSpan({ cls: "prm-action-short", text: "API" });
apiBtn.createSpan({ cls: "prm-action-long", text: " 批阅中…" });
} else {
apiBtn.createSpan({ cls: "mae-action-short", text: "API" });
apiBtn.createSpan({ cls: "mae-action-long", text: " 批阅" });
apiBtn.createSpan({ cls: "prm-action-short", text: "API" });
apiBtn.createSpan({ cls: "prm-action-long", text: " 批阅" });
}
apiBtn.title = hasApiKey ? "通过 API 直调执行批阅修改" : "请先在设置中配置 API Key";
apiBtn.disabled = !hasApiKey || isExecuting;
if (isApiExecuting) apiBtn.addClass("mae-executing");
if (isApiExecuting) apiBtn.addClass("prm-executing");
apiBtn.onclick = async () => {
if (!hasApiKey || isExecuting) return;
this.plugin.executionState = { type: "api" };
@ -407,10 +407,10 @@ export class SidebarView extends ItemView {
};
// 复制 Prompt
const copyBtn = row2.createEl("button", { cls: "mae-action-btn" });
const copyBtn = row2.createEl("button", { cls: "prm-action-btn" });
setIcon(copyBtn, "clipboard-copy");
copyBtn.createSpan({ cls: "mae-action-short", text: "Prompt" });
copyBtn.createSpan({ cls: "mae-action-long", text: " 复制" });
copyBtn.createSpan({ cls: "prm-action-short", text: "Prompt" });
copyBtn.createSpan({ cls: "prm-action-long", text: " 复制" });
copyBtn.title = "复制批阅 Prompt 到剪贴板";
copyBtn.disabled = !hasReviews || isExecuting;
copyBtn.onclick = () => {
@ -419,7 +419,7 @@ export class SidebarView extends ItemView {
};
// … 按钮(下拉菜单:导出批注文件)
const moreBtn = row2.createEl("button", { cls: "mae-action-btn mae-more-btn" });
const moreBtn = row2.createEl("button", { cls: "prm-action-btn prm-more-btn" });
setIcon(moreBtn, "more-horizontal");
moreBtn.title = "导出批注文件";
moreBtn.disabled = !hasReviews || isExecuting;
@ -439,12 +439,12 @@ export class SidebarView extends ItemView {
};
// Status hint
const status = parent.createDiv({ cls: "mae-action-status" });
const status = parent.createDiv({ cls: "prm-action-status" });
if (isExecuting) {
status.createSpan({ cls: "mae-status-dot mae-status-dot-executing" });
status.createSpan({ cls: "prm-status-dot prm-status-dot-executing" });
status.createSpan({ text: executing.type === "agent" ? "Agent 批阅中…" : "API 批阅中…" });
} else {
status.createSpan({ cls: "mae-status-dot" });
status.createSpan({ cls: "prm-status-dot" });
status.createSpan({ text: `${reviewCount} 条批阅待执行` });
}
}
@ -459,7 +459,7 @@ export class SidebarView extends ItemView {
/** Render banner inside a given parent element */
private renderBannerIn(parent: HTMLElement, affected: { fuzzy: number; drifted: number }): void {
const banner = parent.createDiv({ cls: "mae-banner" });
const banner = parent.createDiv({ cls: "prm-banner" });
const parts: string[] = [];
if (affected.drifted > 0) parts.push(`${affected.drifted} 条已漂移`);
if (affected.fuzzy > 0) parts.push(`${affected.fuzzy} 条位置存在歧义`);
@ -504,7 +504,7 @@ export class SidebarView extends ItemView {
private renderCard(parent: HTMLElement, ann: Annotation, docText: string | null): void {
const r = docText ? locate(docText, ann) : null;
const card = parent.createDiv({ cls: "mae-card" });
const card = parent.createDiv({ cls: "prm-card" });
// 卡片左侧色条 class
const colorClass = colorClassFor(ann);
@ -517,17 +517,17 @@ export class SidebarView extends ItemView {
}
// ── meta 行:类型 chip + 漂移标记 + 删除按钮(所有类型统一) ──
const meta = card.createDiv({ cls: "mae-card-meta" });
const meta = card.createDiv({ cls: "prm-card-meta" });
const tagClass = tagColorClassFor(ann);
meta.createSpan({ cls: `mae-tag ${tagClass}`, text: tagLabelFor(ann) });
meta.createSpan({ cls: `prm-tag ${tagClass}`, text: tagLabelFor(ann) });
// 漂移状态
if (r?.status === "fuzzy") { const s = meta.createSpan({ cls: "mae-card-status-inline" }); setIcon(s, "alert-triangle"); s.createSpan({ text: " 位置歧义" }); }
if (r?.status === "auto-healed") { const s = meta.createSpan({ cls: "mae-card-status-inline" }); setIcon(s, "wrench"); s.createSpan({ text: " 已自动修复" }); }
if (r?.status === "drifted") { const s = meta.createSpan({ cls: "mae-card-status-inline" }); setIcon(s, "alert-triangle"); s.createSpan({ text: " 已漂移" }); }
if (r?.status === "fuzzy") { const s = meta.createSpan({ cls: "prm-card-status-inline" }); setIcon(s, "alert-triangle"); s.createSpan({ text: " 位置歧义" }); }
if (r?.status === "auto-healed") { const s = meta.createSpan({ cls: "prm-card-status-inline" }); setIcon(s, "wrench"); s.createSpan({ text: " 已自动修复" }); }
if (r?.status === "drifted") { const s = meta.createSpan({ cls: "prm-card-status-inline" }); setIcon(s, "alert-triangle"); s.createSpan({ text: " 已漂移" }); }
// 删除按钮(右推)
const spacer = meta.createDiv({ cls: "mae-card-meta-spacer" });
const spacer = meta.createDiv({ cls: "prm-card-meta-spacer" });
void spacer;
const delBtn = meta.createEl("button", { cls: "mae-card-del-btn", text: "删除" });
const delBtn = meta.createEl("button", { cls: "prm-card-del-btn", text: "删除" });
delBtn.onclick = (e) => {
e.stopPropagation();
this.plugin.deleteAnnotation(ann);
@ -537,32 +537,32 @@ export class SidebarView extends ItemView {
if (ann.type === "review") {
// Quote + review text + sourceno icon badge
const quote = card.createDiv({
cls: "mae-card-quote mae-quote-orange" + (ann.strike ? " strike" : ""),
cls: "prm-card-quote prm-quote-orange" + (ann.strike ? " strike" : ""),
});
quote.setText(ann.selectedText);
if (ann.reviewText) {
const review = card.createDiv({ cls: "mae-card-text mae-text-purple" });
const review = card.createDiv({ cls: "prm-card-text prm-text-purple" });
review.setText(ann.reviewText);
}
const fileName = (this.currentFilePath ?? "").split("/").pop() ?? "";
const source = card.createDiv({ cls: "mae-card-source" });
const source = card.createDiv({ cls: "prm-card-source" });
source.createSpan({ text: fileName });
source.createSpan({ text: ` · L.${ann.lineHint}` });
} else {
// highlight / note
const quote = card.createDiv({
cls: "mae-card-quote" + (ann.strike ? " strike" : ""),
cls: "prm-card-quote" + (ann.strike ? " strike" : ""),
});
quote.setText(ann.selectedText);
if (ann.type === "note" && ann.noteText) {
card.createDiv({ cls: "mae-card-text", text: ann.noteText });
card.createDiv({ cls: "prm-card-text", text: ann.noteText });
}
const fileName = (this.currentFilePath ?? "").split("/").pop() ?? "";
const source = card.createDiv({ cls: "mae-card-source" });
const source = card.createDiv({ cls: "prm-card-source" });
source.createSpan({ text: fileName });
source.createSpan({ text: ` · L.${ann.lineHint}` });
}
@ -606,7 +606,7 @@ function emptyText(mode: ViewMode): string {
return "这个文档还没有任何标注。";
}
/** 卡片左侧色条的 class与 .mae-card.color-* 配套)。 */
/** 卡片左侧色条的 class与 .prm-card.color-* 配套)。 */
function colorClassFor(ann: Annotation): string | null {
if (ann.type === "highlight") {
return `color-${ann.highlightColor ?? "yellow"}`;

5
src/svg.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
/** Allow importing .svg files as raw strings via esbuild text loader */
declare module "*.svg" {
const content: string;
export default content;
}

149
src/utils/FolderOpener.ts Normal file
View file

@ -0,0 +1,149 @@
/**
* FolderOpener /
*
* 使 child_process TerminalLauncher
* require('electron').shell
*
*/
import { App, Notice, FileSystemAdapter } from "obsidian";
import { isMobile } from "./platform";
// ---------- helpers ----------
/** 获取 vault 内相对路径对应的绝对路径 */
export function getAbsolutePath(app: App, relativePath: string): string | null {
const adapter = app.vault.adapter as FileSystemAdapter;
if (typeof adapter.getFullPath === "function") {
return adapter.getFullPath(relativePath);
}
// Fallback: basePath + relative
const basePath = (adapter as unknown as { basePath?: string }).basePath;
if (basePath) {
return `${basePath}/${relativePath}`;
}
return null;
}
/** 确保目录存在,返回其相对路径 */
export async function ensureDir(app: App, relDir: string): Promise<string> {
if (!(await app.vault.adapter.exists(relDir))) {
await app.vault.adapter.mkdir(relDir);
}
return relDir;
}
// ---------- public API ----------
export interface OpenResult {
success: boolean;
path: string;
error?: string;
}
/**
*
*
* macOS: `open -R` shell.showItemInFolder
* Windows: `explorer /select,`
* Linux: `xdg-open`
*/
export async function revealInFileManager(
app: App,
relPath: string,
): Promise<OpenResult> {
if (isMobile()) {
new Notice("移动端不支持打开本地文件夹");
return { success: false, path: "", error: "mobile" };
}
const absPath = getAbsolutePath(app, relPath);
if (!absPath) {
new Notice("无法解析文件绝对路径");
return { success: false, path: relPath, error: "no-abs-path" };
}
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { exec } = require("child_process") as {
exec: (cmd: string, cb: (err: Error | null) => void) => void;
};
let cmd: string;
if (process.platform === "darwin") {
// macOS: open -R reveals & selects the file in Finder
cmd = `open -R "${absPath}"`;
} else if (process.platform === "win32") {
// Windows: explorer /select opens Explorer and selects the file
cmd = `explorer /select,"${absPath}"`;
} else {
// Linux: xdg-open the parent directory
const parentDir = absPath.substring(0, absPath.lastIndexOf("/"));
cmd = `xdg-open "${parentDir}"`;
}
await new Promise<void>((resolve, reject) => {
exec(cmd, (err) => {
if (err) reject(err);
else resolve();
});
});
return { success: true, path: absPath };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(`打开文件夹失败: ${msg}`);
return { success: false, path: absPath, error: msg };
}
}
/**
*
*
* macOS: `open`
* Windows: `explorer`
* Linux: `xdg-open`
*/
export async function openFolderInSystem(
app: App,
relDir: string,
opts?: { ensureExists?: boolean },
): Promise<OpenResult> {
if (isMobile()) {
new Notice("移动端不支持打开本地文件夹");
return { success: false, path: "", error: "mobile" };
}
if (opts?.ensureExists) {
await ensureDir(app, relDir);
}
const absPath = getAbsolutePath(app, relDir);
if (!absPath) {
new Notice("无法解析文件夹绝对路径");
return { success: false, path: relDir, error: "no-abs-path" };
}
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { exec } = require("child_process") as {
exec: (cmd: string, cb: (err: Error | null) => void) => void;
};
const cmd = process.platform === "win32"
? `explorer "${absPath}"`
: process.platform === "darwin"
? `open "${absPath}"`
: `xdg-open "${absPath}"`;
await new Promise<void>((resolve, reject) => {
exec(cmd, (err) => {
if (err) reject(err);
else resolve();
});
});
return { success: true, path: absPath };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(`打开文件夹失败: ${msg}`);
return { success: false, path: absPath, error: msg };
}
}

View file

@ -1,5 +1,5 @@
/**
* Icon registry for MultiAIEdit plugin.
* Icon registry for Promptuary plugin.
*
* Uses Obsidian's built-in `addIcon` / `setIcon` with Lucide icons where possible,
* and registers custom SVG icons for cases where Lucide doesn't have an exact match.
@ -18,10 +18,10 @@ const SVG_DIYA = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
/** Register all custom icons. Call once in plugin onload(). */
export function registerIcons(): void {
addIcon("mae-highlighter", SVG_HIGHLIGHTER);
addIcon("mae-check-check", SVG_CHECK_CHECK);
addIcon("mae-diff", SVG_DIFF);
addIcon("mae-diya", SVG_DIYA);
addIcon("prm-highlighter", SVG_HIGHLIGHTER);
addIcon("prm-check-check", SVG_CHECK_CHECK);
addIcon("prm-diff", SVG_DIFF);
addIcon("prm-diya", SVG_DIYA);
}
/** Set a Lucide or custom icon on an element. Wrapper for convenience. */
@ -38,7 +38,7 @@ export function createIconSpan(
iconName: string,
cls?: string,
): HTMLElement {
const span = parent.createSpan({ cls: `mae-icon ${cls ?? ""}`.trim() });
const span = parent.createSpan({ cls: `prm-icon ${cls ?? ""}`.trim() });
setIcon(span, iconName);
return span;
}

View file

@ -1,24 +1,65 @@
/**
* Encode a vault relative path into a sidecar filename.
* Uses URL-safe base64 of the UTF-8 path to avoid collision:
* a/b__c.md YS9iX19j (unique)
* a__b/c.md YV9fYi9j (unique)
* Map a vault-relative file path to a human-readable sidecar path.
*
* This replaces the previous `__`-substitution scheme which caused
* collisions when file names themselves contained `__`.
* The sidecar mirrors the original vault path structure:
* /.md <rootDir>//.md.json
* notes/todo.md <rootDir>/notes/todo.md.json
*
* This replaces the previous base64url encoding scheme which produced
* unreadable filenames. The path itself is the unique key, so collisions
* are impossible.
*/
export function encodeSidecarName(filePath: string): string {
const noExt = filePath.replace(/\.md$/i, "");
const bytes = new TextEncoder().encode(noExt);
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, ""); // base64url (no padding)
export function sidecarPath(rootDir: string, filePath: string): string {
return `${rootDir}/${filePath}.json`;
}
export function sidecarPath(rootDir: string, filePath: string): string {
const name = encodeSidecarName(filePath);
return `${rootDir}/${name}.json`;
/**
* Ensure every segment of a vault path exists under `rootDir`.
* Obsidian's `adapter.mkdir` only creates a single level, so we
* walk each segment and create as needed.
*/
export async function ensureDir(
adapter: { exists(path: string): Promise<boolean>; mkdir(path: string): Promise<void> },
dir: string,
): Promise<void> {
const parts = dir.split("/");
let current = "";
for (const part of parts) {
if (!part) continue;
current = current ? `${current}/${part}` : part;
if (!(await adapter.exists(current))) {
await adapter.mkdir(current);
}
}
}
/**
* Remove empty ancestor directories under `rootDir` after a file
* is moved or deleted. Stops at `rootDir` itself.
*/
export async function removeEmptyAncestors(
adapter: { exists(path: string): Promise<boolean>; rmdir(path: string, recursive?: boolean): Promise<void> },
rootDir: string,
fileDir: string,
): Promise<void> {
let dir = fileDir;
while (dir && dir !== rootDir && dir.length > rootDir.length) {
if (!(await adapter.exists(dir))) break;
try {
const children = await (adapter as unknown as {
list(path: string): Promise<{ files: string[]; folders: string[] }>;
}).list(dir);
if (children.files.length === 0 && children.folders.length === 0) {
await adapter.rmdir(dir);
} else {
break;
}
} catch {
break;
}
// Move up one level
const idx = dir.lastIndexOf("/");
if (idx < 0) break;
dir = dir.slice(0, idx);
}
}

1286
styles.css

File diff suppressed because it is too large Load diff