mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-03 02:11:07 +05:30
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6747b12f0 | ||
|
|
7a641b2618 | ||
|
|
edefcb8409 | ||
|
|
5ee986fab8 | ||
|
|
c1573dba08 | ||
|
|
9576abc979 | ||
|
|
caa3f3d35a | ||
|
|
2a6f0fc302 | ||
|
|
4da5b7b9c4 | ||
|
|
1ba42592d7 | ||
|
|
bc6f1a7d41 | ||
|
|
539502d7ff | ||
|
|
b5771dd914 | ||
|
|
a816b6ec32 | ||
|
|
54c9484cb5 | ||
|
|
36e26318cd | ||
|
|
8abd580295 | ||
|
|
80294c9876 | ||
|
|
148b549c83 | ||
|
|
a3b4065984 |
@@ -19,17 +19,17 @@ jobs:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install rpmbuild
|
||||
run: sudo apt-get update && sudo apt-get install -y rpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Download external tools (pandoc)
|
||||
run: node scripts/download-tools.js
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Build Linux packages
|
||||
run: npm run build:linux
|
||||
run: npm run build:linux-ci -- --publish=never
|
||||
|
||||
- name: Upload Linux artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -56,14 +56,19 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Download external tools (pandoc)
|
||||
run: node scripts/download-tools.js
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Decode certificate (if available)
|
||||
if: ${{ secrets.CSC_LINK_BASE64 != '' }}
|
||||
if: ${{ env.CSC_LINK_BASE64 != '' }}
|
||||
shell: pwsh
|
||||
env:
|
||||
CSC_LINK_BASE64: ${{ secrets.CSC_LINK_BASE64 }}
|
||||
run: |
|
||||
$bytes = [Convert]::FromBase64String("${{ secrets.CSC_LINK_BASE64 }}")
|
||||
$bytes = [Convert]::FromBase64String("$env:CSC_LINK_BASE64")
|
||||
[IO.File]::WriteAllBytes("${{ github.workspace }}\code-signing-cert.pfx", $bytes)
|
||||
echo "CERT_AVAILABLE=true" >> $env:GITHUB_ENV
|
||||
|
||||
@@ -72,13 +77,13 @@ jobs:
|
||||
env:
|
||||
CSC_LINK: code-signing-cert.pfx
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
run: npm run build:win-signed
|
||||
run: npm run build:win-signed -- --publish=never
|
||||
|
||||
- name: Build Windows packages (unsigned)
|
||||
if: ${{ env.CERT_AVAILABLE != 'true' }}
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: false
|
||||
run: npm run build:win-unsigned
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
run: npm run build:win-unsigned -- --publish=never
|
||||
|
||||
- name: Upload Windows artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -86,24 +91,26 @@ jobs:
|
||||
name: windows-artifacts
|
||||
path: |
|
||||
dist/*.exe
|
||||
dist/*-win.zip
|
||||
dist/*.zip
|
||||
retention-days: 5
|
||||
|
||||
release:
|
||||
needs: [build-linux, build-windows]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download Linux artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: linux-artifacts
|
||||
path: dist
|
||||
|
||||
- name: Download Windows artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: windows-artifacts
|
||||
path: dist
|
||||
|
||||
+7
-1
@@ -16,7 +16,10 @@ out/
|
||||
.cache/
|
||||
.npm/
|
||||
.electron/
|
||||
package-lock.json
|
||||
# package-lock.json is intentionally tracked for reproducible CI builds
|
||||
|
||||
# Downloaded tool binaries (fetched at build time via scripts/download-tools.js)
|
||||
bin/
|
||||
|
||||
# Code signing certificates — never commit private keys
|
||||
*.pfx
|
||||
@@ -40,3 +43,6 @@ pdf\ modal.png
|
||||
CLAUDE.md
|
||||
agents.md
|
||||
coverage/
|
||||
|
||||
# Superpowers brainstorm artifacts
|
||||
.superpowers/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,426 @@
|
||||
# MarkdownConverter v5.0 — Platform Design
|
||||
|
||||
**Date:** 2026-04-14
|
||||
**Status:** Approved
|
||||
**Author:** Amit Haridas
|
||||
|
||||
## Overview
|
||||
|
||||
Transform MarkdownConverter from a monolithic editor into an extensible platform with a plugin system and three feature packs, shipped together as v5.0.
|
||||
|
||||
## Subsystems
|
||||
|
||||
1. **Plugin System** — Lightweight plugin registry with extension points (sidebar, commands, settings, status bar, export hooks, event bus)
|
||||
2. **Writing Studio Plugin** — Manuscript manager, goal tracking, writing sprints, snapshots, smart proofreading
|
||||
3. **AI Assistant Plugin** — Multi-provider AI writing assistant (Ollama, LMStudio, GGUF direct with GPU, Anthropic, OpenAI)
|
||||
4. **Collaboration Plugin** — Git-based async collaboration, comments/annotations, review requests
|
||||
|
||||
## Core Principle
|
||||
|
||||
**Existing functionality is never replaced or broken.** The plugin system is additive. All existing keyboard shortcuts, features, and UI remain untouched. Plugin shortcuts use `Ctrl+Alt+` namespace.
|
||||
|
||||
---
|
||||
|
||||
## 1. Plugin System
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
plugins/
|
||||
plugin-registry.js # Load, register, lifecycle
|
||||
plugin-api.js # Base class plugins extend
|
||||
plugin-loader.js # Discovers and validates manifests
|
||||
built-in/
|
||||
writing-studio/
|
||||
manifest.json
|
||||
index.js
|
||||
panels/
|
||||
components/
|
||||
ai-assistant/
|
||||
manifest.json
|
||||
index.js
|
||||
providers/
|
||||
collaboration/
|
||||
manifest.json
|
||||
index.js
|
||||
```
|
||||
|
||||
### Manifest Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "writing-studio",
|
||||
"name": "Writing Studio",
|
||||
"version": "1.0.0",
|
||||
"description": "Manuscript management, goal tracking, writing sprints",
|
||||
"icon": "pen-tool",
|
||||
"extensionPoints": {
|
||||
"sidebar": { "panel": "panels/manuscript-panel.js", "order": 30 },
|
||||
"settings": { "section": "settings/index.js" },
|
||||
"statusBar": { "indicators": ["sprint-timer", "word-goal"] },
|
||||
"commands": [
|
||||
{ "id": "start-sprint", "label": "Start Writing Sprint", "shortcut": "Ctrl+Alt+S" },
|
||||
{ "id": "take-snapshot", "label": "Take Snapshot", "shortcut": "Ctrl+Alt+N" }
|
||||
],
|
||||
"exportHooks": {
|
||||
"preExport": "hooks/pre-export.js",
|
||||
"postExport": "hooks/post-export.js"
|
||||
}
|
||||
},
|
||||
"settings": [
|
||||
{ "key": "dailyGoal", "type": "number", "default": 1000, "label": "Daily word goal" },
|
||||
{ "key": "sprintDuration", "type": "number", "default": 25, "label": "Sprint duration (min)" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Plugin Lifecycle
|
||||
|
||||
1. PluginLoader discovers manifests in `built-in/` + user plugins directory
|
||||
2. PluginRegistry validates manifests
|
||||
3. Each plugin calls `Plugin.init(context)` receiving scoped API context
|
||||
4. Extension points registered (sidebar panels, commands, status bar items)
|
||||
5. Plugins activate lazily — sidebar panel loads JS when user clicks tab
|
||||
|
||||
### Plugin Context API
|
||||
|
||||
Each plugin's `init()` receives:
|
||||
|
||||
```javascript
|
||||
{
|
||||
sidebar: {
|
||||
registerPanel(id, { icon, title, component })
|
||||
},
|
||||
commands: {
|
||||
register(id, label, handler, shortcut?)
|
||||
},
|
||||
statusBar: {
|
||||
registerIndicator(id, { position, render })
|
||||
},
|
||||
settings: {
|
||||
get(key), // plugin-scoped
|
||||
set(key, value), // auto-persisted via electron-store
|
||||
onChanged(key, callback)
|
||||
},
|
||||
editor: {
|
||||
getContent(), // current document
|
||||
getSelection(), // selected text
|
||||
insertAtCursor(text), // requires opt-in
|
||||
onContentChanged(callback)
|
||||
},
|
||||
events: {
|
||||
on(event, handler),
|
||||
emit(event, data)
|
||||
},
|
||||
exports: {
|
||||
registerPreHook(handler),
|
||||
registerPostHook(handler)
|
||||
},
|
||||
ipc: {
|
||||
invoke(channel, ...args),
|
||||
on(channel, handler)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Event Bus Events
|
||||
|
||||
Each event has a versioned payload schema. Breaking changes increment the version suffix.
|
||||
|
||||
```
|
||||
document:opened → { filePath: string, tabId: string }
|
||||
document:saved → { filePath: string, tabId: string }
|
||||
document:changed → { tabId: string, content: string, wordCount: number }
|
||||
editor:selection-changed → { tabId: string, text: string, from: {line,ch}, to: {line,ch} }
|
||||
tab:switched → { tabId: string, filePath: string }
|
||||
tab:closed → { tabId: string, filePath: string }
|
||||
export:started → { format: string, filePath: string }
|
||||
export:completed → { format: string, filePath: string, outputPath: string }
|
||||
export:failed → { format: string, error: string }
|
||||
plugin:loaded → { pluginId: string, version: string }
|
||||
plugin:activated → { pluginId: string }
|
||||
plugin:deactivated → { pluginId: string }
|
||||
app:ready → {}
|
||||
app:before-quit → {}
|
||||
```
|
||||
|
||||
### Design Rules
|
||||
|
||||
- Built-in plugins use the same API as future third-party plugins
|
||||
- Lazy activation — sidebar panels don't load until clicked
|
||||
- Scoped settings: `plugins.<id>.<key>` in electron-store
|
||||
- Plugin commands globally unique — registry rejects duplicate command IDs at load time
|
||||
- **Plugin sandboxing**: each plugin handler is wrapped in try/catch. For CPU-intensive operations (AI inference, diff computation), plugins must delegate to main process via IPC. Handlers that block the renderer for >5s trigger a warning notification. Memory-hungry operations (GGUF inference) run in isolated child processes.
|
||||
- **Cross-plugin graceful degradation**: plugins check `context.events.hasHandler('ai:analyze')` before emitting cross-plugin requests. If no handler (AI plugin disabled), show a "this feature requires the AI plugin" prompt instead of failing silently. All cross-plugin calls have a 30s timeout with default fallback behavior.
|
||||
|
||||
---
|
||||
|
||||
## 2. Writing Studio Plugin
|
||||
|
||||
### 2A. Manuscript / Project Manager
|
||||
|
||||
Folder-based project structure:
|
||||
|
||||
```
|
||||
~/Manuscripts/
|
||||
my-novel/
|
||||
.project.json # { title, targets, metadata }
|
||||
01-chapter-one.md
|
||||
02-chapter-two.md
|
||||
characters/
|
||||
protagonist.md
|
||||
research/
|
||||
world-building.md
|
||||
.snapshots/
|
||||
2026-04-14T10-30.json
|
||||
```
|
||||
|
||||
`.project.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "My Novel",
|
||||
"type": "manuscript",
|
||||
"target": { "words": 80000, "deadline": "2026-09-01" },
|
||||
"chapters": [
|
||||
{ "file": "01-chapter-one.md", "title": "The Beginning", "status": "draft" }
|
||||
],
|
||||
"metadata": { "author": "", "genre": "", "synopsis": "" }
|
||||
}
|
||||
```
|
||||
|
||||
Sidebar panel shows project tree with drag-to-reorder, word counts per chapter, target progress bar. "Compile manuscript" exports all chapters as a single document.
|
||||
|
||||
### 2B. Goal Tracking & Writing Sprints
|
||||
|
||||
- **Status bar**: daily progress bar + sprint timer
|
||||
- **Writing sprint**: configurable duration (15/25/30/45/60 min), word count delta, WPM at end
|
||||
- **Goal tracking**: daily/weekly word goals, streak tracking, 30-day bar chart
|
||||
- **Enhanced analytics**: session tracking, readability scores, productive time-of-day heatmap
|
||||
- Data stored in `plugins.writing-studio.history` as date-keyed map
|
||||
|
||||
### 2C. Snapshot & Versioning
|
||||
|
||||
- `Ctrl+Alt+N` or toolbar button saves snapshot
|
||||
- Stored as JSON: `{ timestamp, content, wordCount, cursorPos, label }`
|
||||
- Snapshot panel in sidebar: Restore, Diff (side-by-side), auto-snapshot interval
|
||||
- Snapshots in `.snapshots/` inside project folder, or app data if no project
|
||||
|
||||
### 2D. Smart Proofreading
|
||||
|
||||
Delegates to AI plugin via event bus. Writing Studio provides:
|
||||
- Right-click context menu: "Check grammar", "Suggest alternatives", "Analyze readability"
|
||||
- Inline wavy underline decorations for issues
|
||||
- Proofread panel: issues categorized by type with Accept/Dismiss
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Shortcut | Action |
|
||||
|---------|----------|--------|
|
||||
| `start-sprint` | `Ctrl+Alt+S` | Start writing sprint |
|
||||
| `stop-sprint` | `Ctrl+Alt+Shift+S` | Stop sprint |
|
||||
| `take-snapshot` | `Ctrl+Alt+N` | Save snapshot |
|
||||
| `restore-last-snapshot` | `Ctrl+Alt+Z` | Restore latest snapshot |
|
||||
| `new-project` | — | Create manuscript project |
|
||||
| `compile-manuscript` | `Ctrl+Alt+E` | Export all chapters |
|
||||
| `proofread-document` | `Ctrl+Alt+G` | AI proofread |
|
||||
|
||||
---
|
||||
|
||||
## 3. AI Assistant Plugin
|
||||
|
||||
### Provider Architecture
|
||||
|
||||
```
|
||||
AI Plugin
|
||||
├── Provider Interface
|
||||
│ ├── complete(prompt, options) → string
|
||||
│ ├── stream(prompt, options) → AsyncIterable
|
||||
│ └── analyze(text, type) → AnalysisResult
|
||||
│
|
||||
├── Providers
|
||||
│ ├── OllamaProvider — localhost:11434
|
||||
│ ├── LMStudioProvider — localhost:1234/v1
|
||||
│ ├── GGUFProvider — direct llama.cpp with GPU support
|
||||
│ ├── AnthropicProvider — Claude API
|
||||
│ └── OpenAIProvider — GPT API
|
||||
│
|
||||
└── Features
|
||||
├── Grammar/style check
|
||||
├── Inline auto-complete
|
||||
├── AI chat panel (sidebar)
|
||||
├── Document analysis
|
||||
└── Smart commands (command palette)
|
||||
```
|
||||
|
||||
### Provider Details
|
||||
|
||||
**Ollama:** `GET /api/tags` for models, `POST /api/generate` and `POST /api/chat` for inference.
|
||||
|
||||
**LMStudio:** OpenAI-compatible API at `localhost:1234/v1`. `GET /v1/models`, standard chat completion format, SSE streaming.
|
||||
|
||||
**GGUF Direct (with GPU):**
|
||||
- Ships bundled llama.cpp binaries per platform (CUDA, Vulkan, Metal, CPU variants)
|
||||
- Auto-detects GPU: CUDA (nvidia-smi), Vulkan driver, Metal (macOS)
|
||||
- GPU layer offloading: configurable, auto-suggests based on VRAM vs model size
|
||||
- Settings: GPU backend selection, layer count, context length, thread count
|
||||
- "Keep model loaded" option for faster repeated requests
|
||||
- WASM fallback for sandboxed environments (CPU-only)
|
||||
- External binary path for advanced users with custom builds
|
||||
- **Process isolation**: llama.cpp runs as a spawned child process (not in main process). If it crashes, detected via exit handler, auto-restarted with notification. GPU memory freed on crash. App remains stable.
|
||||
- Process management: spawn in server mode on localhost ephemeral port, clean up on app quit or model unload
|
||||
|
||||
**Cloud (Anthropic/OpenAI):**
|
||||
- API key stored encrypted via electron safeStorage
|
||||
- Token usage tracking with estimated cost
|
||||
- Rate limit awareness with request queueing and backoff
|
||||
|
||||
### IPC Design
|
||||
|
||||
All provider HTTP requests go through main process:
|
||||
- No CORS issues
|
||||
- API keys never in renderer
|
||||
- Main process enforces rate limiting
|
||||
- GGUF inference in isolated child process
|
||||
|
||||
**Request/response lifecycle:**
|
||||
```
|
||||
Renderer → ipc.invoke('ai:complete') → Main → HTTP to provider → result → Renderer
|
||||
```
|
||||
|
||||
**Streaming lifecycle with error handling:**
|
||||
```
|
||||
Renderer → ipc.invoke('ai:stream', { requestId, prompt })
|
||||
← Main assigns requestId, returns { requestId }
|
||||
← ipc.on('ai:chunk', { requestId, text }) — repeated
|
||||
← ipc.on('ai:done', { requestId }) — success
|
||||
← ipc.on('ai:error', { requestId, error }) — failure
|
||||
|
||||
// Cancellation
|
||||
Renderer → ipc.invoke('ai:cancel', { requestId })
|
||||
← Main aborts HTTP request, emits 'ai:done'
|
||||
|
||||
// Orphan cleanup: if renderer disconnects (crash/close),
|
||||
// main process detects via 'render-view-deleted' and aborts all active streams.
|
||||
// Heartbeat: if no chunk received in 30s, main emits 'ai:error' with timeout.
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
1. **Inline suggestions**: ghost text after configurable delay, Tab to accept, Esc to dismiss
|
||||
2. **AI chat panel**: sidebar conversation, "Insert" / "Replace selection" buttons
|
||||
3. **Document analysis**: grammar, style, tone, with accept/reject per suggestion
|
||||
4. **Smart commands**: summarize, generate outline, find inconsistencies, translate, explain code
|
||||
|
||||
### Privacy
|
||||
|
||||
- Local-first: default provider is Ollama
|
||||
- No telemetry: requests go direct to provider
|
||||
- Content gating: exclude file types from AI
|
||||
- Status bar shows "AI: processing..." with cancel option
|
||||
- Cloud usage stats in settings (tokens, cost)
|
||||
|
||||
### Cross-Plugin Integration
|
||||
|
||||
```javascript
|
||||
// Writing Studio calls AI Plugin
|
||||
context.events.emit('ai:analyze', { text, type: 'grammar', callback });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Collaboration Plugin
|
||||
|
||||
### 4A. Enhanced Git Panel
|
||||
|
||||
Upgrades to existing git panel:
|
||||
- Remote management (add/remove remotes, push/pull)
|
||||
- Branch list and switching
|
||||
- Commit history with diff viewer (side-by-side or unified)
|
||||
- Conflict resolution UI (accept-ours/accept-theirs/per-edit)
|
||||
|
||||
### 4B. Shared Repository Workflow
|
||||
|
||||
1. Writer A creates project + initializes git + pushes to shared repo
|
||||
2. Writer B clones repo from within MarkdownConverter
|
||||
3. Both write on their own branches
|
||||
4. Writer A creates review request (simplified PR)
|
||||
|
||||
Review request: changed files, word count diff, commit messages. Reviewer can approve, request changes, leave inline comments. Reviews are git branches + comments as git notes.
|
||||
|
||||
### 4C. Comments & Annotations
|
||||
|
||||
Inline comments stored as JSON in `.comments/` directory (git-tracked):
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"file": "03-chapter-three.md",
|
||||
"anchor": {
|
||||
"contextBefore": "The hero looked at the horizon and said,",
|
||||
"selectedText": "I will not go quietly into that dark night",
|
||||
"contextAfter": "He turned to face the army alone."
|
||||
},
|
||||
"line": 142,
|
||||
"text": "This dialogue feels unnatural",
|
||||
"author": "amit",
|
||||
"timestamp": "2026-04-14T14:30:00Z",
|
||||
"replies": [],
|
||||
"resolved": false
|
||||
}
|
||||
```
|
||||
|
||||
- **Anchor-based positioning**: comments store `contextBefore`, `selectedText`, and `contextAfter` (not absolute byte offsets). On file change, re-anchor by searching for the context text. If context no longer matches, mark comment as "detached" and show a warning. Falls back to `line` number as rough position.
|
||||
- Highlighted text in editor with tooltip on hover
|
||||
- Comment panel in sidebar: all unresolved comments across files
|
||||
- Resolution workflow: add → address → reply → resolve
|
||||
- Resolved comments dim but stay visible
|
||||
|
||||
### 4D. Change Notifications
|
||||
|
||||
- Status bar indicator: `↓ 3 new commits`
|
||||
- Click to see changes, one-click pull
|
||||
- Conflicts trigger resolution UI
|
||||
- Push button only when local commits ahead of remote
|
||||
|
||||
### 4E. Offline-First
|
||||
|
||||
All writing happens locally. Git is the sync mechanism. No internet required for writing, commenting, snapshots, or sprints. Push/pull on user action or auto-sync setting.
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Shortcut | Action |
|
||||
|---------|----------|--------|
|
||||
| `collab:commit` | `Ctrl+Shift+G` | Commit with message |
|
||||
| `collab:push` | — | Push current branch |
|
||||
| `collab:pull` | — | Pull from remote |
|
||||
| `collab:add-comment` | `Ctrl+Alt+C` | Comment on selection |
|
||||
| `collab:next-comment` | `F8` | Next unresolved comment |
|
||||
| `collab:prev-comment` | `Shift+F8` | Previous comment |
|
||||
| `collab:create-review` | — | Create review request |
|
||||
|
||||
### Cross-Plugin Integration
|
||||
|
||||
```javascript
|
||||
context.events.emit('snapshot:created', { file, snapshotId });
|
||||
context.events.on('project:chapter-opened', (chapter) => { /* load comments */ });
|
||||
context.events.on('comment:added', (comment) => { /* AI could suggest fix */ });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bundle Size Impact
|
||||
|
||||
- llama.cpp binaries: ~15MB per GPU variant. Only target platform shipped. GPU variants (CUDA/Vulkan/Metal) downloaded on demand if user enables GGUF direct loading — not bundled by default. Only CPU fallback bundled (~15MB).
|
||||
- Plugin system core: ~30KB
|
||||
- Each built-in plugin: ~50-100KB
|
||||
- Diff library (jsdiff): ~15KB
|
||||
- Total estimated increase: ~20-30MB (core), additional ~30-50MB per GPU variant (lazy download)
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Plugin system: unit tests for registry, loader, context API mocking
|
||||
- Each plugin: isolated unit tests, integration tests via plugin context
|
||||
- AI provider tests: mock HTTP responses, test streaming parsing
|
||||
- Git tests: use test repository fixture
|
||||
- E2E: verify plugin loading doesn't break existing features
|
||||
Generated
+13895
File diff suppressed because it is too large
Load Diff
+17
-14
@@ -19,9 +19,11 @@
|
||||
"create-cert": "powershell -ExecutionPolicy Bypass -File scripts/create-selfsigned-cert.ps1",
|
||||
"build:mac": "electron-builder --mac",
|
||||
"build:linux": "electron-builder --linux",
|
||||
"build:linux-ci": "electron-builder --linux deb AppImage",
|
||||
"build:local": "electron-builder --linux --win",
|
||||
"dist": "electron-builder --publish=never",
|
||||
"dist:all": "electron-builder -mwl",
|
||||
"download-tools": "node scripts/download-tools.js",
|
||||
"generate-icons": "node scripts/generate-icons.js"
|
||||
},
|
||||
"keywords": [
|
||||
@@ -74,6 +76,7 @@
|
||||
"docx4js": "^3.3.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"electron-store": "^10.1.0",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"html2pdf.js": "^0.14.0",
|
||||
"marked": "^17.0.3",
|
||||
@@ -109,6 +112,10 @@
|
||||
"node_modules/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"asarUnpack": [
|
||||
"node_modules/ffmpeg-static/**"
|
||||
],
|
||||
"extraFiles": [],
|
||||
"fileAssociations": [
|
||||
{
|
||||
"ext": "md",
|
||||
@@ -160,7 +167,11 @@
|
||||
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
||||
"requestedExecutionLevel": "asInvoker",
|
||||
"legalTrademarks": "Copyright (C) 2024-2025 ConcreteInfo",
|
||||
"verifyUpdateCodeSignature": false
|
||||
"verifyUpdateCodeSignature": false,
|
||||
"signAndEditExecutable": false,
|
||||
"extraFiles": [
|
||||
{ "from": "bin/win32/pandoc.exe", "to": "bin/pandoc.exe" }
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
@@ -182,29 +193,21 @@
|
||||
"target": [
|
||||
"deb",
|
||||
"AppImage",
|
||||
"snap",
|
||||
"rpm"
|
||||
"snap"
|
||||
],
|
||||
"category": "Utility",
|
||||
"maintainer": "ConcreteInfo <amit.wh@gmail.com>"
|
||||
"maintainer": "ConcreteInfo <amit.wh@gmail.com>",
|
||||
"extraFiles": [
|
||||
{ "from": "bin/linux/pandoc", "to": "bin/pandoc" }
|
||||
]
|
||||
},
|
||||
"deb": {
|
||||
"depends": [
|
||||
"pandoc",
|
||||
"ffmpeg",
|
||||
"imagemagick",
|
||||
"libreoffice-common"
|
||||
],
|
||||
"description": "Professional Markdown editor and universal file converter",
|
||||
"maintainer": "ConcreteInfo <amit.wh@gmail.com>"
|
||||
},
|
||||
"rpm": {
|
||||
"depends": [
|
||||
"pandoc",
|
||||
"ffmpeg",
|
||||
"ImageMagick",
|
||||
"libreoffice-core"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Downloads pandoc binary for the current build platform.
|
||||
* Run automatically via `npm run download-tools` before building.
|
||||
* Skips download if binary already exists (idempotent).
|
||||
*/
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const PANDOC_VERSION = '3.9.0.2';
|
||||
|
||||
const PANDOC_CONFIG = {
|
||||
linux: {
|
||||
url: `https://github.com/jgm/pandoc/releases/download/${PANDOC_VERSION}/pandoc-${PANDOC_VERSION}-linux-amd64.tar.gz`,
|
||||
archiveExt: '.tar.gz',
|
||||
destFile: 'pandoc',
|
||||
extract(archivePath, destDir) {
|
||||
const tmpDir = path.join(os.tmpdir(), `pandoc-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
execSync(`tar -xzf "${archivePath}" -C "${tmpDir}" pandoc-${PANDOC_VERSION}/bin/pandoc`);
|
||||
const src = path.join(tmpDir, `pandoc-${PANDOC_VERSION}`, 'bin', 'pandoc');
|
||||
fs.copyFileSync(src, path.join(destDir, 'pandoc'));
|
||||
fs.chmodSync(path.join(destDir, 'pandoc'), 0o755);
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
},
|
||||
},
|
||||
win32: {
|
||||
url: `https://github.com/jgm/pandoc/releases/download/${PANDOC_VERSION}/pandoc-${PANDOC_VERSION}-windows-x86_64.zip`,
|
||||
archiveExt: '.zip',
|
||||
destFile: 'pandoc.exe',
|
||||
extract(archivePath, destDir) {
|
||||
const tmpDir = path.join(os.tmpdir(), `pandoc-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
execSync(
|
||||
`powershell -Command "Expand-Archive -Force '${archivePath}' '${tmpDir}'"`,
|
||||
);
|
||||
const src = path.join(tmpDir, `pandoc-${PANDOC_VERSION}`, 'pandoc.exe');
|
||||
fs.copyFileSync(src, path.join(destDir, 'pandoc.exe'));
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
},
|
||||
},
|
||||
darwin: {
|
||||
url: `https://github.com/jgm/pandoc/releases/download/${PANDOC_VERSION}/pandoc-${PANDOC_VERSION}-x86_64-macOS.zip`,
|
||||
archiveExt: '.zip',
|
||||
destFile: 'pandoc',
|
||||
extract(archivePath, destDir) {
|
||||
const tmpDir = path.join(os.tmpdir(), `pandoc-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
execSync(`unzip -o "${archivePath}" -d "${tmpDir}"`);
|
||||
const src = path.join(tmpDir, `pandoc-${PANDOC_VERSION}`, 'bin', 'pandoc');
|
||||
fs.copyFileSync(src, path.join(destDir, 'pandoc'));
|
||||
fs.chmodSync(path.join(destDir, 'pandoc'), 0o755);
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function download(url, destPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = fs.createWriteStream(destPath);
|
||||
let received = 0;
|
||||
let total = 0;
|
||||
let lastPct = -1;
|
||||
|
||||
function get(redirectUrl) {
|
||||
const client = redirectUrl.startsWith('https://') ? https : http;
|
||||
client
|
||||
.get(redirectUrl, (res) => {
|
||||
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||
get(res.headers.location);
|
||||
return;
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${res.statusCode} for ${redirectUrl}`));
|
||||
return;
|
||||
}
|
||||
total = parseInt(res.headers['content-length'] || '0', 10);
|
||||
res.on('data', (chunk) => {
|
||||
received += chunk.length;
|
||||
if (total > 0) {
|
||||
const pct = Math.floor((received / total) * 100);
|
||||
if (pct !== lastPct && pct % 10 === 0) {
|
||||
process.stdout.write(` ${pct}%\r`);
|
||||
lastPct = pct;
|
||||
}
|
||||
}
|
||||
});
|
||||
res.pipe(file);
|
||||
file.on('finish', () => {
|
||||
file.close();
|
||||
process.stdout.write(' 100%\n');
|
||||
resolve();
|
||||
});
|
||||
})
|
||||
.on('error', (err) => {
|
||||
fs.unlink(destPath, () => {});
|
||||
reject(err);
|
||||
});
|
||||
}
|
||||
get(url);
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadPandoc() {
|
||||
const platform = process.platform;
|
||||
const config = PANDOC_CONFIG[platform];
|
||||
|
||||
if (!config) {
|
||||
console.log(`[download-tools] No pandoc config for platform "${platform}" — skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const destDir = path.join(__dirname, '..', 'bin', platform);
|
||||
const destFile = path.join(destDir, config.destFile);
|
||||
|
||||
if (fs.existsSync(destFile)) {
|
||||
console.log(`[download-tools] pandoc already present at ${destFile} — skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
|
||||
const tmpArchive = path.join(os.tmpdir(), `pandoc-download${config.archiveExt}`);
|
||||
|
||||
console.log(`[download-tools] Downloading pandoc ${PANDOC_VERSION} for ${platform}...`);
|
||||
await download(config.url, tmpArchive);
|
||||
|
||||
console.log(`[download-tools] Extracting to ${destDir}...`);
|
||||
config.extract(tmpArchive, destDir);
|
||||
|
||||
try {
|
||||
fs.unlinkSync(tmpArchive);
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
console.log(`[download-tools] pandoc ready: ${destFile}`);
|
||||
}
|
||||
|
||||
downloadPandoc().catch((err) => {
|
||||
console.error('[download-tools] FAILED:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
+58
-508
@@ -2,8 +2,9 @@ const { app, BrowserWindow, Menu, dialog, ipcMain, shell } = require('electron')
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { exec, execFile, spawn } = require('child_process');
|
||||
const { PDFDocument, rgb, degrees, StandardFonts } = require('pdf-lib');
|
||||
const WordTemplateExporter = require('./wordTemplateExporter');
|
||||
const PDFOperations = require('./main/PDFOperations');
|
||||
const GitOperations = require('./main/GitOperations');
|
||||
|
||||
// Add MiKTeX to PATH for LaTeX support
|
||||
if (process.platform === 'win32') {
|
||||
@@ -14,13 +15,39 @@ if (process.platform === 'win32') {
|
||||
}
|
||||
}
|
||||
|
||||
// Get the system Pandoc path
|
||||
// Returns path to pandoc: bundled binary when packaged, dev bin or system fallback otherwise.
|
||||
function getPandocPath() {
|
||||
// Pandoc is expected to be in the system's PATH.
|
||||
// The command will be executed directly. Quoting is handled by exec.
|
||||
if (app.isPackaged) {
|
||||
const ext = process.platform === 'win32' ? '.exe' : '';
|
||||
return path.join(process.resourcesPath, 'bin', `pandoc${ext}`);
|
||||
}
|
||||
// Development: prefer locally-downloaded binary in bin/<platform>/
|
||||
const devBin = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'bin',
|
||||
process.platform,
|
||||
process.platform === 'win32' ? 'pandoc.exe' : 'pandoc',
|
||||
);
|
||||
if (fs.existsSync(devBin)) return devBin;
|
||||
return 'pandoc';
|
||||
}
|
||||
|
||||
// Returns path to ffmpeg: asar-unpacked bundled binary when packaged, system fallback otherwise.
|
||||
function getFFmpegPath() {
|
||||
try {
|
||||
let ffmpegPath = require('ffmpeg-static');
|
||||
if (app.isPackaged) {
|
||||
// ffmpeg-static is in asarUnpack — rewrite the path to the unpacked location
|
||||
ffmpegPath = ffmpegPath.replace('app.asar' + path.sep, 'app.asar.unpacked' + path.sep);
|
||||
}
|
||||
if (fs.existsSync(ffmpegPath)) return ffmpegPath;
|
||||
} catch (_) {
|
||||
/* ffmpeg-static not available */
|
||||
}
|
||||
return process.platform === 'win32' ? 'ffmpeg' : 'ffmpeg';
|
||||
}
|
||||
|
||||
// Check if Pandoc is available
|
||||
function checkPandocAvailable() {
|
||||
return new Promise((resolve) => {
|
||||
@@ -283,6 +310,14 @@ const store = {
|
||||
}
|
||||
};
|
||||
|
||||
// Plugin settings IPC handlers
|
||||
ipcMain.handle('plugin-settings:get', (_event, key) => {
|
||||
return store.get(key);
|
||||
});
|
||||
ipcMain.handle('plugin-settings:set', (_event, { key, value }) => {
|
||||
store.set(key, value);
|
||||
});
|
||||
|
||||
let mainWindow;
|
||||
let currentFile = null; // This will now represent the active tab's file
|
||||
let pandocAvailable = null; // Cache pandoc availability check
|
||||
@@ -1833,9 +1868,12 @@ function checkConverterAvailable(tool) {
|
||||
case 'imagemagick':
|
||||
toolName = isWin ? 'magick' : 'convert';
|
||||
break;
|
||||
case 'ffmpeg':
|
||||
toolName = 'ffmpeg';
|
||||
break;
|
||||
case 'ffmpeg': {
|
||||
// ffmpeg-static is always bundled — check it directly
|
||||
const ffmpegBin = getFFmpegPath();
|
||||
resolve(fs.existsSync(ffmpegBin) || ffmpegBin === 'ffmpeg');
|
||||
return;
|
||||
}
|
||||
default:
|
||||
resolve(false);
|
||||
return;
|
||||
@@ -2062,8 +2100,8 @@ function convertWithImageMagick(inputFile, outputPath) {
|
||||
// FFmpeg conversion - returns {command, args} for execFile
|
||||
function convertWithFFmpeg(inputFile, outputPath) {
|
||||
return {
|
||||
command: 'ffmpeg',
|
||||
args: ['-i', inputFile, outputPath, '-y']
|
||||
command: getFFmpegPath(),
|
||||
args: ['-i', inputFile, outputPath, '-y'],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3719,433 +3757,9 @@ function openFileFromPath(filePath) {
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// PDF EDITOR OPERATIONS (using pdf-lib)
|
||||
// PDF OPERATIONS — delegates to main/PDFOperations.js
|
||||
// ========================================
|
||||
|
||||
// Helper function to parse page ranges (e.g., "1-5, 7, 9-12")
|
||||
function parsePageRanges(rangeString, totalPages) {
|
||||
const pages = [];
|
||||
const ranges = rangeString.split(',').map(r => r.trim());
|
||||
|
||||
for (const range of ranges) {
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||||
for (let i = start; i <= end && i <= totalPages; i++) {
|
||||
if (i > 0 && !pages.includes(i - 1)) { // Convert to 0-indexed
|
||||
pages.push(i - 1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const page = parseInt(range);
|
||||
if (page > 0 && page <= totalPages && !pages.includes(page - 1)) {
|
||||
pages.push(page - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pages.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
// Helper function to convert hex color to RGB
|
||||
function hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16) / 255,
|
||||
g: parseInt(result[2], 16) / 255,
|
||||
b: parseInt(result[3], 16) / 255
|
||||
} : { r: 0, g: 0, b: 0 };
|
||||
}
|
||||
|
||||
// PDF Merge Operation
|
||||
async function pdfMerge(data) {
|
||||
try {
|
||||
const mergedPdf = await PDFDocument.create();
|
||||
|
||||
for (const filePath of data.inputFiles) {
|
||||
const pdfBytes = fs.readFileSync(filePath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
|
||||
copiedPages.forEach(page => mergedPdf.addPage(page));
|
||||
}
|
||||
|
||||
const pdfBytes = await mergedPdf.save();
|
||||
fs.writeFileSync(data.outputPath, pdfBytes);
|
||||
|
||||
return { success: true, message: `Successfully merged ${data.inputFiles.length} PDFs` };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// PDF Split Operation
|
||||
async function pdfSplit(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const totalPages = pdf.getPageCount();
|
||||
|
||||
let splits = [];
|
||||
|
||||
if (data.splitMode === 'pages') {
|
||||
// Split by page ranges
|
||||
const ranges = data.pageRanges.split(',').map(r => r.trim());
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
const range = ranges[i];
|
||||
let pages = [];
|
||||
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||||
for (let p = start; p <= end && p <= totalPages; p++) {
|
||||
pages.push(p - 1);
|
||||
}
|
||||
} else {
|
||||
const page = parseInt(range);
|
||||
if (page > 0 && page <= totalPages) {
|
||||
pages.push(page - 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (pages.length > 0) {
|
||||
splits.push({ pages, name: `part_${i + 1}` });
|
||||
}
|
||||
}
|
||||
} else if (data.splitMode === 'interval') {
|
||||
// Split every N pages
|
||||
const interval = data.interval;
|
||||
for (let i = 0; i < totalPages; i += interval) {
|
||||
const pages = [];
|
||||
for (let j = i; j < i + interval && j < totalPages; j++) {
|
||||
pages.push(j);
|
||||
}
|
||||
splits.push({ pages, name: `part_${Math.floor(i / interval) + 1}` });
|
||||
}
|
||||
} else if (data.splitMode === 'size') {
|
||||
// Split by size (approximate - we'll split evenly)
|
||||
// This is complex, so we'll do a simple even split for now
|
||||
const maxSize = data.maxSize * 1024 * 1024; // Convert MB to bytes
|
||||
// For simplicity, split into fixed page chunks
|
||||
const chunkSize = Math.max(1, Math.floor(totalPages / 5)); // Split into ~5 parts
|
||||
for (let i = 0; i < totalPages; i += chunkSize) {
|
||||
const pages = [];
|
||||
for (let j = i; j < i + chunkSize && j < totalPages; j++) {
|
||||
pages.push(j);
|
||||
}
|
||||
splits.push({ pages, name: `part_${Math.floor(i / chunkSize) + 1}` });
|
||||
}
|
||||
}
|
||||
|
||||
// Create split PDFs
|
||||
const baseName = path.basename(data.inputPath, '.pdf');
|
||||
for (const split of splits) {
|
||||
const newPdf = await PDFDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(pdf, split.pages);
|
||||
copiedPages.forEach(page => newPdf.addPage(page));
|
||||
|
||||
const outputPath = path.join(data.outputFolder, `${baseName}_${split.name}.pdf`);
|
||||
const newPdfBytes = await newPdf.save();
|
||||
fs.writeFileSync(outputPath, newPdfBytes);
|
||||
}
|
||||
|
||||
return { success: true, message: `Successfully split PDF into ${splits.length} files` };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// PDF Compress Operation
|
||||
async function pdfCompress(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
|
||||
// pdf-lib doesn't have built-in compression, but we can save with default compression
|
||||
// For actual compression, we would need additional libraries like Ghostscript
|
||||
// For now, we'll save with standard options which provides some compression
|
||||
const compressedPdfBytes = await pdf.save({
|
||||
useObjectStreams: true,
|
||||
addDefaultPage: false,
|
||||
objectsPerTick: 50
|
||||
});
|
||||
|
||||
fs.writeFileSync(data.outputPath, compressedPdfBytes);
|
||||
|
||||
const originalSize = fs.statSync(data.inputPath).size;
|
||||
const compressedSize = fs.statSync(data.outputPath).size;
|
||||
const savings = ((originalSize - compressedSize) / originalSize * 100).toFixed(1);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `PDF compressed. Size reduced by ${savings}% (${(originalSize / 1024).toFixed(1)}KB → ${(compressedSize / 1024).toFixed(1)}KB)`
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// PDF Rotate Pages Operation
|
||||
async function pdfRotate(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const totalPages = pdf.getPageCount();
|
||||
|
||||
let pagesToRotate = [];
|
||||
if (data.pages && data.pages.trim()) {
|
||||
pagesToRotate = parsePageRanges(data.pages, totalPages);
|
||||
} else {
|
||||
// Rotate all pages
|
||||
pagesToRotate = Array.from({ length: totalPages }, (_, i) => i);
|
||||
}
|
||||
|
||||
pagesToRotate.forEach(pageIndex => {
|
||||
const page = pdf.getPage(pageIndex);
|
||||
page.setRotation(degrees(data.angle));
|
||||
});
|
||||
|
||||
const rotatedPdfBytes = await pdf.save();
|
||||
fs.writeFileSync(data.outputPath, rotatedPdfBytes);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully rotated ${pagesToRotate.length} page(s) by ${data.angle}°`
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// PDF Delete Pages Operation
|
||||
async function pdfDeletePages(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const totalPages = pdf.getPageCount();
|
||||
|
||||
const pagesToDelete = parsePageRanges(data.pages, totalPages);
|
||||
|
||||
// Remove pages in reverse order to maintain indices
|
||||
pagesToDelete.sort((a, b) => b - a).forEach(pageIndex => {
|
||||
pdf.removePage(pageIndex);
|
||||
});
|
||||
|
||||
const newPdfBytes = await pdf.save();
|
||||
fs.writeFileSync(data.outputPath, newPdfBytes);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully deleted ${pagesToDelete.length} page(s). New PDF has ${totalPages - pagesToDelete.length} pages`
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// PDF Reorder Pages Operation
|
||||
async function pdfReorder(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const totalPages = pdf.getPageCount();
|
||||
|
||||
// Parse new page order
|
||||
const newOrder = data.newOrder.split(',').map(n => parseInt(n.trim()) - 1); // Convert to 0-indexed
|
||||
|
||||
// Validate new order
|
||||
if (newOrder.length !== totalPages) {
|
||||
return { success: false, error: `New order must include all ${totalPages} pages` };
|
||||
}
|
||||
|
||||
const newPdf = await PDFDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(pdf, newOrder);
|
||||
copiedPages.forEach(page => newPdf.addPage(page));
|
||||
|
||||
const reorderedPdfBytes = await newPdf.save();
|
||||
fs.writeFileSync(data.outputPath, reorderedPdfBytes);
|
||||
|
||||
return { success: true, message: 'Successfully reordered PDF pages' };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// PDF Watermark Operation
|
||||
async function pdfWatermark(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const totalPages = pdf.getPageCount();
|
||||
|
||||
// Determine which pages to watermark
|
||||
let pagesToWatermark = [];
|
||||
if (data.pages === 'all') {
|
||||
pagesToWatermark = Array.from({ length: totalPages }, (_, i) => i);
|
||||
} else if (data.pages === 'custom' && data.customPages) {
|
||||
pagesToWatermark = parsePageRanges(data.customPages, totalPages);
|
||||
}
|
||||
|
||||
const font = await pdf.embedFont(StandardFonts.Helvetica);
|
||||
const color = hexToRgb(data.color);
|
||||
|
||||
for (const pageIndex of pagesToWatermark) {
|
||||
const page = pdf.getPage(pageIndex);
|
||||
const { width, height } = page.getSize();
|
||||
|
||||
let x, y, rotation = 0;
|
||||
|
||||
// Calculate position based on selected position
|
||||
switch (data.position) {
|
||||
case 'center':
|
||||
x = width / 2;
|
||||
y = height / 2;
|
||||
break;
|
||||
case 'diagonal':
|
||||
x = width / 2;
|
||||
y = height / 2;
|
||||
rotation = 45;
|
||||
break;
|
||||
case 'top-left':
|
||||
x = 50;
|
||||
y = height - 50;
|
||||
break;
|
||||
case 'top-center':
|
||||
x = width / 2;
|
||||
y = height - 50;
|
||||
break;
|
||||
case 'top-right':
|
||||
x = width - 50;
|
||||
y = height - 50;
|
||||
break;
|
||||
case 'bottom-left':
|
||||
x = 50;
|
||||
y = 50;
|
||||
break;
|
||||
case 'bottom-center':
|
||||
x = width / 2;
|
||||
y = 50;
|
||||
break;
|
||||
case 'bottom-right':
|
||||
x = width - 50;
|
||||
y = 50;
|
||||
break;
|
||||
default:
|
||||
x = width / 2;
|
||||
y = height / 2;
|
||||
}
|
||||
|
||||
page.drawText(data.text, {
|
||||
x,
|
||||
y,
|
||||
size: data.fontSize,
|
||||
font,
|
||||
color: rgb(color.r, color.g, color.b),
|
||||
opacity: data.opacity,
|
||||
rotate: degrees(rotation)
|
||||
});
|
||||
}
|
||||
|
||||
const watermarkedPdfBytes = await pdf.save();
|
||||
fs.writeFileSync(data.outputPath, watermarkedPdfBytes);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully added watermark to ${pagesToWatermark.length} page(s)`
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// PDF Encrypt (Password Protection) Operation
|
||||
async function pdfEncrypt(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
|
||||
// pdf-lib has limited encryption support in v1.17.1
|
||||
// We'll save with password (basic encryption)
|
||||
const encryptedPdfBytes = await pdf.save({
|
||||
userPassword: data.userPassword,
|
||||
ownerPassword: data.ownerPassword || data.userPassword,
|
||||
permissions: {
|
||||
printing: data.permissions.printing ? 'highResolution' : 'lowResolution',
|
||||
modifying: data.permissions.modifying,
|
||||
copying: data.permissions.copying,
|
||||
annotating: data.permissions.annotating,
|
||||
fillingForms: data.permissions.fillingForms,
|
||||
contentAccessibility: data.permissions.contentAccessibility,
|
||||
documentAssembly: data.permissions.documentAssembly
|
||||
}
|
||||
});
|
||||
|
||||
fs.writeFileSync(data.outputPath, encryptedPdfBytes);
|
||||
|
||||
return { success: true, message: 'Successfully added password protection to PDF' };
|
||||
} catch (error) {
|
||||
// If pdf-lib doesn't support encryption in this version, provide a helpful error
|
||||
if (error.message.includes('encrypt') || error.message.includes('password')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'PDF encryption requires pdf-lib with encryption support. This feature may not be available in the current version.'
|
||||
};
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// PDF Decrypt (Remove Password) Operation
|
||||
async function pdfDecrypt(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes, { password: data.password });
|
||||
|
||||
// Save without password
|
||||
const decryptedPdfBytes = await pdf.save();
|
||||
fs.writeFileSync(data.outputPath, decryptedPdfBytes);
|
||||
|
||||
return { success: true, message: 'Successfully removed password protection from PDF' };
|
||||
} catch (error) {
|
||||
if (error.message.includes('password') || error.message.includes('encrypted')) {
|
||||
return { success: false, error: 'Incorrect password or PDF is not encrypted' };
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// PDF Permissions Operation
|
||||
async function pdfSetPermissions(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const loadOptions = data.currentPassword ? { password: data.currentPassword } : {};
|
||||
const pdf = await PDFDocument.load(pdfBytes, loadOptions);
|
||||
|
||||
const newPdfBytes = await pdf.save({
|
||||
ownerPassword: data.ownerPassword,
|
||||
permissions: {
|
||||
printing: data.permissions.printing ? 'highResolution' : 'lowResolution',
|
||||
modifying: data.permissions.modifying,
|
||||
copying: data.permissions.copying,
|
||||
annotating: data.permissions.annotating,
|
||||
fillingForms: data.permissions.fillingForms,
|
||||
contentAccessibility: data.permissions.contentAccessibility,
|
||||
documentAssembly: data.permissions.documentAssembly
|
||||
}
|
||||
});
|
||||
|
||||
fs.writeFileSync(data.outputPath, newPdfBytes);
|
||||
|
||||
return { success: true, message: 'Successfully updated PDF permissions' };
|
||||
} catch (error) {
|
||||
if (error.message.includes('encrypt') || error.message.includes('permission')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'PDF permissions require pdf-lib with encryption support. This feature may not be available in the current version.'
|
||||
};
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// IPC Handler for PDF Operations
|
||||
ipcMain.on('process-pdf-operation', async (event, data) => {
|
||||
try {
|
||||
mainWindow.webContents.send('pdf-operation-progress', {
|
||||
@@ -4153,43 +3767,7 @@ ipcMain.on('process-pdf-operation', async (event, data) => {
|
||||
progress: 10
|
||||
});
|
||||
|
||||
let result;
|
||||
|
||||
switch (data.operation) {
|
||||
case 'merge':
|
||||
result = await pdfMerge(data);
|
||||
break;
|
||||
case 'split':
|
||||
result = await pdfSplit(data);
|
||||
break;
|
||||
case 'compress':
|
||||
result = await pdfCompress(data);
|
||||
break;
|
||||
case 'rotate':
|
||||
result = await pdfRotate(data);
|
||||
break;
|
||||
case 'delete':
|
||||
result = await pdfDeletePages(data);
|
||||
break;
|
||||
case 'reorder':
|
||||
result = await pdfReorder(data);
|
||||
break;
|
||||
case 'watermark':
|
||||
result = await pdfWatermark(data);
|
||||
break;
|
||||
case 'encrypt':
|
||||
result = await pdfEncrypt(data);
|
||||
break;
|
||||
case 'decrypt':
|
||||
result = await pdfDecrypt(data);
|
||||
break;
|
||||
case 'permissions':
|
||||
result = await pdfSetPermissions(data);
|
||||
break;
|
||||
default:
|
||||
result = { success: false, error: `Unknown operation: ${data.operation}` };
|
||||
}
|
||||
|
||||
const result = await PDFOperations.executeOperation(data.operation, data);
|
||||
mainWindow.webContents.send('pdf-operation-complete', result);
|
||||
} catch (error) {
|
||||
mainWindow.webContents.send('pdf-operation-complete', {
|
||||
@@ -4199,12 +3777,9 @@ ipcMain.on('process-pdf-operation', async (event, data) => {
|
||||
}
|
||||
});
|
||||
|
||||
// IPC Handler for getting PDF page count
|
||||
ipcMain.on('get-pdf-page-count', async (event, filePath) => {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(filePath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const count = pdf.getPageCount();
|
||||
const count = await PDFOperations.getPageCount(filePath);
|
||||
event.reply('pdf-page-count', { count });
|
||||
} catch (error) {
|
||||
event.reply('pdf-page-count', { error: error.message });
|
||||
@@ -4512,49 +4087,24 @@ ipcMain.on('open-file-path', (event, filePath) => {
|
||||
// ============================================
|
||||
// Git IPC Handlers
|
||||
// ============================================
|
||||
const simpleGit = require('simple-git');
|
||||
|
||||
function getGitRepo() {
|
||||
// Use the directory of the current file, or the app's working directory
|
||||
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
|
||||
return simpleGit(dir);
|
||||
}
|
||||
|
||||
ipcMain.handle('git-status', async () => {
|
||||
try {
|
||||
const git = getGitRepo();
|
||||
return await git.status();
|
||||
} catch (err) {
|
||||
return { error: 'Not a git repository' };
|
||||
}
|
||||
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
|
||||
return GitOperations.getStatus(dir);
|
||||
});
|
||||
|
||||
ipcMain.handle('git-stage', async (event, { files }) => {
|
||||
try {
|
||||
const git = getGitRepo();
|
||||
await git.add(files);
|
||||
return await git.status();
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
}
|
||||
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
|
||||
return GitOperations.stage(dir, files);
|
||||
});
|
||||
|
||||
ipcMain.handle('git-commit', async (event, { message }) => {
|
||||
try {
|
||||
const git = getGitRepo();
|
||||
return await git.commit(message);
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
}
|
||||
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
|
||||
return GitOperations.commit(dir, message);
|
||||
});
|
||||
|
||||
ipcMain.handle('git-log', async () => {
|
||||
try {
|
||||
const git = getGitRepo();
|
||||
return await git.log({ maxCount: 20 });
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
}
|
||||
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
|
||||
return GitOperations.log(dir);
|
||||
});
|
||||
|
||||
// ============================================
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
const simpleGit = require('simple-git');
|
||||
|
||||
function getGitInstance(dir) {
|
||||
return simpleGit(dir);
|
||||
}
|
||||
|
||||
async function getStatus(dir) {
|
||||
try {
|
||||
const git = getGitInstance(dir);
|
||||
return await git.status();
|
||||
} catch (err) {
|
||||
return { error: 'Not a git repository' };
|
||||
}
|
||||
}
|
||||
|
||||
async function stage(dir, files) {
|
||||
try {
|
||||
const git = getGitInstance(dir);
|
||||
await git.add(files);
|
||||
return await git.status();
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function commit(dir, message) {
|
||||
try {
|
||||
const git = getGitInstance(dir);
|
||||
return await git.commit(message);
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function log(dir, maxCount = 20) {
|
||||
try {
|
||||
const git = getGitInstance(dir);
|
||||
return await git.log({ maxCount });
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getStatus, stage, commit, log };
|
||||
@@ -0,0 +1,433 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { PDFDocument, rgb, degrees, StandardFonts } = require('pdf-lib');
|
||||
|
||||
function parsePageRanges(rangeString, totalPages) {
|
||||
const pages = [];
|
||||
const ranges = rangeString.split(',').map(r => r.trim());
|
||||
|
||||
for (const range of ranges) {
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||||
for (let i = start; i <= end && i <= totalPages; i++) {
|
||||
if (i > 0 && !pages.includes(i - 1)) {
|
||||
pages.push(i - 1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const page = parseInt(range);
|
||||
if (page > 0 && page <= totalPages && !pages.includes(page - 1)) {
|
||||
pages.push(page - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pages.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16) / 255,
|
||||
g: parseInt(result[2], 16) / 255,
|
||||
b: parseInt(result[3], 16) / 255
|
||||
} : { r: 0, g: 0, b: 0 };
|
||||
}
|
||||
|
||||
async function pdfMerge(data) {
|
||||
try {
|
||||
const mergedPdf = await PDFDocument.create();
|
||||
|
||||
for (const filePath of data.inputFiles) {
|
||||
const pdfBytes = fs.readFileSync(filePath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
|
||||
copiedPages.forEach(page => mergedPdf.addPage(page));
|
||||
}
|
||||
|
||||
const pdfBytes = await mergedPdf.save();
|
||||
fs.writeFileSync(data.outputPath, pdfBytes);
|
||||
|
||||
return { success: true, message: `Successfully merged ${data.inputFiles.length} PDFs` };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function pdfSplit(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const totalPages = pdf.getPageCount();
|
||||
|
||||
let splits = [];
|
||||
|
||||
if (data.splitMode === 'pages') {
|
||||
const ranges = data.pageRanges.split(',').map(r => r.trim());
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
const range = ranges[i];
|
||||
let pages = [];
|
||||
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||||
for (let p = start; p <= end && p <= totalPages; p++) {
|
||||
pages.push(p - 1);
|
||||
}
|
||||
} else {
|
||||
const page = parseInt(range);
|
||||
if (page > 0 && page <= totalPages) {
|
||||
pages.push(page - 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (pages.length > 0) {
|
||||
splits.push({ pages, name: `part_${i + 1}` });
|
||||
}
|
||||
}
|
||||
} else if (data.splitMode === 'interval') {
|
||||
const interval = data.interval;
|
||||
for (let i = 0; i < totalPages; i += interval) {
|
||||
const pages = [];
|
||||
for (let j = i; j < i + interval && j < totalPages; j++) {
|
||||
pages.push(j);
|
||||
}
|
||||
splits.push({ pages, name: `part_${Math.floor(i / interval) + 1}` });
|
||||
}
|
||||
} else if (data.splitMode === 'size') {
|
||||
const chunkSize = Math.max(1, Math.floor(totalPages / 5));
|
||||
for (let i = 0; i < totalPages; i += chunkSize) {
|
||||
const pages = [];
|
||||
for (let j = i; j < i + chunkSize && j < totalPages; j++) {
|
||||
pages.push(j);
|
||||
}
|
||||
splits.push({ pages, name: `part_${Math.floor(i / chunkSize) + 1}` });
|
||||
}
|
||||
}
|
||||
|
||||
const baseName = path.basename(data.inputPath, '.pdf');
|
||||
for (const split of splits) {
|
||||
const newPdf = await PDFDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(pdf, split.pages);
|
||||
copiedPages.forEach(page => newPdf.addPage(page));
|
||||
|
||||
const outputPath = path.join(data.outputFolder, `${baseName}_${split.name}.pdf`);
|
||||
const newPdfBytes = await newPdf.save();
|
||||
fs.writeFileSync(outputPath, newPdfBytes);
|
||||
}
|
||||
|
||||
return { success: true, message: `Successfully split PDF into ${splits.length} files` };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function pdfCompress(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
|
||||
const compressedPdfBytes = await pdf.save({
|
||||
useObjectStreams: true,
|
||||
addDefaultPage: false,
|
||||
objectsPerTick: 50
|
||||
});
|
||||
|
||||
fs.writeFileSync(data.outputPath, compressedPdfBytes);
|
||||
|
||||
const originalSize = fs.statSync(data.inputPath).size;
|
||||
const compressedSize = fs.statSync(data.outputPath).size;
|
||||
const savings = ((originalSize - compressedSize) / originalSize * 100).toFixed(1);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `PDF compressed. Size reduced by ${savings}% (${(originalSize / 1024).toFixed(1)}KB → ${(compressedSize / 1024).toFixed(1)}KB)`
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function pdfRotate(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const totalPages = pdf.getPageCount();
|
||||
|
||||
let pagesToRotate = [];
|
||||
if (data.pages && data.pages.trim()) {
|
||||
pagesToRotate = parsePageRanges(data.pages, totalPages);
|
||||
} else {
|
||||
pagesToRotate = Array.from({ length: totalPages }, (_, i) => i);
|
||||
}
|
||||
|
||||
pagesToRotate.forEach(pageIndex => {
|
||||
const page = pdf.getPage(pageIndex);
|
||||
page.setRotation(degrees(data.angle));
|
||||
});
|
||||
|
||||
const rotatedPdfBytes = await pdf.save();
|
||||
fs.writeFileSync(data.outputPath, rotatedPdfBytes);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully rotated ${pagesToRotate.length} page(s) by ${data.angle}\u00B0`
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function pdfDeletePages(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const totalPages = pdf.getPageCount();
|
||||
|
||||
const pagesToDelete = parsePageRanges(data.pages, totalPages);
|
||||
|
||||
pagesToDelete.sort((a, b) => b - a).forEach(pageIndex => {
|
||||
pdf.removePage(pageIndex);
|
||||
});
|
||||
|
||||
const newPdfBytes = await pdf.save();
|
||||
fs.writeFileSync(data.outputPath, newPdfBytes);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully deleted ${pagesToDelete.length} page(s). New PDF has ${totalPages - pagesToDelete.length} pages`
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function pdfReorder(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const totalPages = pdf.getPageCount();
|
||||
|
||||
const newOrder = data.newOrder.split(',').map(n => parseInt(n.trim()) - 1);
|
||||
|
||||
if (newOrder.length !== totalPages) {
|
||||
return { success: false, error: `New order must include all ${totalPages} pages` };
|
||||
}
|
||||
|
||||
const newPdf = await PDFDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(pdf, newOrder);
|
||||
copiedPages.forEach(page => newPdf.addPage(page));
|
||||
|
||||
const reorderedPdfBytes = await newPdf.save();
|
||||
fs.writeFileSync(data.outputPath, reorderedPdfBytes);
|
||||
|
||||
return { success: true, message: 'Successfully reordered PDF pages' };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function pdfWatermark(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const totalPages = pdf.getPageCount();
|
||||
|
||||
let pagesToWatermark = [];
|
||||
if (data.pages === 'all') {
|
||||
pagesToWatermark = Array.from({ length: totalPages }, (_, i) => i);
|
||||
} else if (data.pages === 'custom' && data.customPages) {
|
||||
pagesToWatermark = parsePageRanges(data.customPages, totalPages);
|
||||
}
|
||||
|
||||
const font = await pdf.embedFont(StandardFonts.Helvetica);
|
||||
const color = hexToRgb(data.color);
|
||||
|
||||
for (const pageIndex of pagesToWatermark) {
|
||||
const page = pdf.getPage(pageIndex);
|
||||
const { width, height } = page.getSize();
|
||||
|
||||
let x, y, rotation = 0;
|
||||
|
||||
switch (data.position) {
|
||||
case 'center':
|
||||
x = width / 2;
|
||||
y = height / 2;
|
||||
break;
|
||||
case 'diagonal':
|
||||
x = width / 2;
|
||||
y = height / 2;
|
||||
rotation = 45;
|
||||
break;
|
||||
case 'top-left':
|
||||
x = 50;
|
||||
y = height - 50;
|
||||
break;
|
||||
case 'top-center':
|
||||
x = width / 2;
|
||||
y = height - 50;
|
||||
break;
|
||||
case 'top-right':
|
||||
x = width - 50;
|
||||
y = height - 50;
|
||||
break;
|
||||
case 'bottom-left':
|
||||
x = 50;
|
||||
y = 50;
|
||||
break;
|
||||
case 'bottom-center':
|
||||
x = width / 2;
|
||||
y = 50;
|
||||
break;
|
||||
case 'bottom-right':
|
||||
x = width - 50;
|
||||
y = 50;
|
||||
break;
|
||||
default:
|
||||
x = width / 2;
|
||||
y = height / 2;
|
||||
}
|
||||
|
||||
page.drawText(data.text, {
|
||||
x,
|
||||
y,
|
||||
size: data.fontSize,
|
||||
font,
|
||||
color: rgb(color.r, color.g, color.b),
|
||||
opacity: data.opacity,
|
||||
rotate: degrees(rotation)
|
||||
});
|
||||
}
|
||||
|
||||
const watermarkedPdfBytes = await pdf.save();
|
||||
fs.writeFileSync(data.outputPath, watermarkedPdfBytes);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully added watermark to ${pagesToWatermark.length} page(s)`
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function pdfEncrypt(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
|
||||
const encryptedPdfBytes = await pdf.save({
|
||||
userPassword: data.userPassword,
|
||||
ownerPassword: data.ownerPassword || data.userPassword,
|
||||
permissions: {
|
||||
printing: data.permissions.printing ? 'highResolution' : 'lowResolution',
|
||||
modifying: data.permissions.modifying,
|
||||
copying: data.permissions.copying,
|
||||
annotating: data.permissions.annotating,
|
||||
fillingForms: data.permissions.fillingForms,
|
||||
contentAccessibility: data.permissions.contentAccessibility,
|
||||
documentAssembly: data.permissions.documentAssembly
|
||||
}
|
||||
});
|
||||
|
||||
fs.writeFileSync(data.outputPath, encryptedPdfBytes);
|
||||
|
||||
return { success: true, message: 'Successfully added password protection to PDF' };
|
||||
} catch (error) {
|
||||
if (error.message.includes('encrypt') || error.message.includes('password')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'PDF encryption requires pdf-lib with encryption support. This feature may not be available in the current version.'
|
||||
};
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function pdfDecrypt(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes, { password: data.password });
|
||||
|
||||
const decryptedPdfBytes = await pdf.save();
|
||||
fs.writeFileSync(data.outputPath, decryptedPdfBytes);
|
||||
|
||||
return { success: true, message: 'Successfully removed password protection from PDF' };
|
||||
} catch (error) {
|
||||
if (error.message.includes('password') || error.message.includes('encrypted')) {
|
||||
return { success: false, error: 'Incorrect password or PDF is not encrypted' };
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function pdfSetPermissions(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const loadOptions = data.currentPassword ? { password: data.currentPassword } : {};
|
||||
const pdf = await PDFDocument.load(pdfBytes, loadOptions);
|
||||
|
||||
const newPdfBytes = await pdf.save({
|
||||
ownerPassword: data.ownerPassword,
|
||||
permissions: {
|
||||
printing: data.permissions.printing ? 'highResolution' : 'lowResolution',
|
||||
modifying: data.permissions.modifying,
|
||||
copying: data.permissions.copying,
|
||||
annotating: data.permissions.annotating,
|
||||
fillingForms: data.permissions.fillingForms,
|
||||
contentAccessibility: data.permissions.contentAccessibility,
|
||||
documentAssembly: data.permissions.documentAssembly
|
||||
}
|
||||
});
|
||||
|
||||
fs.writeFileSync(data.outputPath, newPdfBytes);
|
||||
|
||||
return { success: true, message: 'Successfully updated PDF permissions' };
|
||||
} catch (error) {
|
||||
if (error.message.includes('encrypt') || error.message.includes('permission')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'PDF permissions require pdf-lib with encryption support. This feature may not be available in the current version.'
|
||||
};
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
function executeOperation(operation, data) {
|
||||
switch (operation) {
|
||||
case 'merge': return pdfMerge(data);
|
||||
case 'split': return pdfSplit(data);
|
||||
case 'compress': return pdfCompress(data);
|
||||
case 'rotate': return pdfRotate(data);
|
||||
case 'delete': return pdfDeletePages(data);
|
||||
case 'reorder': return pdfReorder(data);
|
||||
case 'watermark': return pdfWatermark(data);
|
||||
case 'encrypt': return pdfEncrypt(data);
|
||||
case 'decrypt': return pdfDecrypt(data);
|
||||
case 'permissions': return pdfSetPermissions(data);
|
||||
default: return Promise.resolve({ success: false, error: `Unknown operation: ${operation}` });
|
||||
}
|
||||
}
|
||||
|
||||
async function getPageCount(filePath) {
|
||||
const pdfBytes = fs.readFileSync(filePath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
return pdf.getPageCount();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parsePageRanges,
|
||||
hexToRgb,
|
||||
pdfMerge,
|
||||
pdfSplit,
|
||||
pdfCompress,
|
||||
pdfRotate,
|
||||
pdfDeletePages,
|
||||
pdfReorder,
|
||||
pdfWatermark,
|
||||
pdfEncrypt,
|
||||
pdfDecrypt,
|
||||
pdfSetPermissions,
|
||||
executeOperation,
|
||||
getPageCount
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
const { PluginAPI } = require('../../plugin-api');
|
||||
|
||||
class SamplePlugin extends PluginAPI {
|
||||
init(context) {
|
||||
this.context = context;
|
||||
context.commands.register('hello', 'Sample: Hello World', () => {
|
||||
console.log('[SamplePlugin] Hello from the plugin system!');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Plugin: SamplePlugin };
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"id": "_sample",
|
||||
"name": "Sample Plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "Demonstrates the plugin system. Safe to delete.",
|
||||
"icon": "puzzle",
|
||||
"extensionPoints": {
|
||||
"commands": [
|
||||
{ "id": "hello", "label": "Sample: Hello World", "shortcut": "" }
|
||||
]
|
||||
},
|
||||
"settings": []
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
const HISTORY_KEY = 'plugins.writing-studio.history';
|
||||
|
||||
class GoalTracker {
|
||||
/**
|
||||
* @param {object} store - { get(key), set(key, value) } settings backend
|
||||
*/
|
||||
constructor(store) {
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
_getHistory() {
|
||||
const raw = this.store.get(HISTORY_KEY);
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
}
|
||||
|
||||
_setHistory(history) {
|
||||
this.store.set(HISTORY_KEY, JSON.stringify(history));
|
||||
}
|
||||
|
||||
_setHistoryDay(dateStr, data) {
|
||||
const history = this._getHistory();
|
||||
history[dateStr] = data;
|
||||
this._setHistory(history);
|
||||
}
|
||||
|
||||
addWords(count) {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const history = this._getHistory();
|
||||
if (!history[today]) {
|
||||
history[today] = { words: 0, sessions: 0 };
|
||||
}
|
||||
history[today].words += count;
|
||||
history[today].sessions += 1;
|
||||
this._setHistory(history);
|
||||
}
|
||||
|
||||
getDailyProgress(goal) {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const history = this._getHistory();
|
||||
const written = history[today]?.words || 0;
|
||||
return { written, goal, pct: goal > 0 ? Math.min(100, Math.round((written / goal) * 100)) : 0 };
|
||||
}
|
||||
|
||||
getStreak(goal) {
|
||||
const history = this._getHistory();
|
||||
let streak = 0;
|
||||
const d = new Date();
|
||||
for (let i = 0; i < 365; i++) {
|
||||
const key = d.toISOString().split('T')[0];
|
||||
const day = history[key];
|
||||
if (day && day.words >= goal) {
|
||||
streak++;
|
||||
d.setDate(d.getDate() - 1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return streak;
|
||||
}
|
||||
|
||||
getLast30Days() {
|
||||
const history = this._getHistory();
|
||||
const days = [];
|
||||
const d = new Date();
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const key = d.toISOString().split('T')[0];
|
||||
const day = history[key];
|
||||
days.push({ date: key, words: day?.words || 0 });
|
||||
d.setDate(d.getDate() - 1);
|
||||
}
|
||||
return days.reverse();
|
||||
}
|
||||
|
||||
getWeeklyTotal() {
|
||||
const history = this._getHistory();
|
||||
let total = 0;
|
||||
const d = new Date();
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const key = d.toISOString().split('T')[0];
|
||||
if (history[key]) total += history[key].words || 0;
|
||||
d.setDate(d.getDate() - 1);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { GoalTracker };
|
||||
@@ -0,0 +1,102 @@
|
||||
const { PluginAPI } = require('../../../plugins/plugin-api');
|
||||
const { SprintEngine } = require('./sprint-engine');
|
||||
const { GoalTracker } = require('./goal-tracker');
|
||||
const { SnapshotManager } = require('./snapshot-manager');
|
||||
const { ProjectManager } = require('./project-manager');
|
||||
|
||||
class WritingStudioPlugin extends PluginAPI {
|
||||
init(context) {
|
||||
this.context = context;
|
||||
|
||||
this.sprintEngine = new SprintEngine({
|
||||
onEvent: (name, data) => context.events.emit(name, data)
|
||||
});
|
||||
this.goalTracker = new GoalTracker(context.settings);
|
||||
this.snapshotManager = new SnapshotManager(context.settings);
|
||||
this.projectManager = new ProjectManager({
|
||||
readFile: (p) => context.ipc.invoke('read-file', p),
|
||||
writeFile: (p, c) => context.ipc.invoke('write-file', p, c),
|
||||
fileExists: (p) => context.ipc.invoke('path-exists', p),
|
||||
listDir: (p) => context.ipc.invoke('list-directory', p)
|
||||
});
|
||||
|
||||
this._engines = {
|
||||
sprint: this.sprintEngine,
|
||||
goals: this.goalTracker,
|
||||
snapshots: this.snapshotManager,
|
||||
projects: this.projectManager
|
||||
};
|
||||
|
||||
this._registerCommands(context);
|
||||
this._registerStatusBar(context);
|
||||
}
|
||||
|
||||
_registerCommands(context) {
|
||||
const { sprintEngine, snapshotManager, goalTracker } = this;
|
||||
|
||||
context.commands.register('start-sprint', 'Studio: Start Sprint', () => {
|
||||
const duration = context.settings.get('sprintDuration') || 25;
|
||||
const content = context.editor.getContent() || '';
|
||||
const words = content.split(/\s+/).filter(Boolean).length;
|
||||
sprintEngine.start(duration, words);
|
||||
}, 'Ctrl+Alt+S');
|
||||
|
||||
context.commands.register('stop-sprint', 'Studio: Stop Sprint', () => {
|
||||
if (!sprintEngine.isActive()) return;
|
||||
const content = context.editor.getContent() || '';
|
||||
const words = content.split(/\s+/).filter(Boolean).length;
|
||||
const result = sprintEngine.stop(words);
|
||||
goalTracker.addWords(result.wordDelta);
|
||||
context.events.emit('sprint:stopped', result);
|
||||
}, 'Ctrl+Alt+Shift+S');
|
||||
|
||||
context.commands.register('take-snapshot', 'Studio: Take Snapshot', () => {
|
||||
const content = context.editor.getContent() || '';
|
||||
snapshotManager.create(content, 'manual');
|
||||
context.events.emit('snapshot:created', {});
|
||||
}, 'Ctrl+Alt+N');
|
||||
|
||||
context.commands.register('restore-last-snapshot', 'Studio: Restore Last Snapshot', () => {
|
||||
const snaps = snapshotManager.list();
|
||||
if (snaps.length === 0) return;
|
||||
const content = snapshotManager.restore(snaps[0].id);
|
||||
context.editor.insertAtCursor(content);
|
||||
}, 'Ctrl+Alt+Z');
|
||||
|
||||
context.commands.register('new-project', 'Studio: New Project', () => {
|
||||
context.events.emit('studio:new-project', {});
|
||||
});
|
||||
|
||||
context.commands.register('compile-manuscript', 'Studio: Compile Manuscript', () => {
|
||||
context.events.emit('studio:compile', {});
|
||||
}, 'Ctrl+Alt+E');
|
||||
|
||||
context.commands.register('proofread-document', 'Studio: Proofread Document', () => {
|
||||
if (context.events.hasHandler('ai:analyze')) {
|
||||
const content = context.editor.getContent() || '';
|
||||
context.events.emit('ai:analyze', { text: content, type: 'grammar' });
|
||||
}
|
||||
}, 'Ctrl+Alt+G');
|
||||
}
|
||||
|
||||
_registerStatusBar(context) {
|
||||
context.statusBar.registerIndicator('word-goal', {
|
||||
text: '0/1000',
|
||||
tooltip: 'Daily word goal progress'
|
||||
});
|
||||
context.statusBar.registerIndicator('sprint-timer', {
|
||||
text: '',
|
||||
tooltip: 'Writing sprint timer'
|
||||
});
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
if (this._sprintInterval) clearInterval(this._sprintInterval);
|
||||
}
|
||||
|
||||
getEngines() {
|
||||
return this._engines;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Plugin: WritingStudioPlugin };
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"id": "writing-studio",
|
||||
"name": "Writing Studio",
|
||||
"version": "1.0.0",
|
||||
"description": "Manuscript management, writing sprints, goal tracking, snapshots, and smart proofreading",
|
||||
"icon": "pen-tool",
|
||||
"extensionPoints": {
|
||||
"sidebar": [
|
||||
{ "id": "manuscript", "title": "Manuscript", "order": 30 },
|
||||
{ "id": "goals", "title": "Goals", "order": 31 },
|
||||
{ "id": "snapshots", "title": "Snapshots", "order": 32 },
|
||||
{ "id": "proofread", "title": "Proofread", "order": 33 }
|
||||
],
|
||||
"commands": [
|
||||
{ "id": "start-sprint", "label": "Studio: Start Sprint", "shortcut": "Ctrl+Alt+S" },
|
||||
{ "id": "stop-sprint", "label": "Studio: Stop Sprint", "shortcut": "Ctrl+Alt+Shift+S" },
|
||||
{ "id": "take-snapshot", "label": "Studio: Take Snapshot", "shortcut": "Ctrl+Alt+N" },
|
||||
{ "id": "restore-last-snapshot", "label": "Studio: Restore Last Snapshot", "shortcut": "Ctrl+Alt+Z" },
|
||||
{ "id": "new-project", "label": "Studio: New Project", "shortcut": "" },
|
||||
{ "id": "compile-manuscript", "label": "Studio: Compile Manuscript", "shortcut": "Ctrl+Alt+E" },
|
||||
{ "id": "proofread-document", "label": "Studio: Proofread Document", "shortcut": "Ctrl+Alt+G" }
|
||||
],
|
||||
"statusBar": { "indicators": ["sprint-timer", "word-goal"] }
|
||||
},
|
||||
"settings": [
|
||||
{ "key": "dailyGoal", "type": "number", "default": 1000, "label": "Daily word goal" },
|
||||
{ "key": "sprintDuration", "type": "number", "default": 25, "label": "Sprint duration (min)" },
|
||||
{ "key": "autoSnapshotInterval", "type": "number", "default": 0, "label": "Auto-snapshot interval (min, 0=off)" },
|
||||
{ "key": "maxSnapshots", "type": "number", "default": 50, "label": "Max snapshots to keep" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
function renderGoalsPanel(container, { engines, settings }) {
|
||||
const dailyGoal = settings.get('dailyGoal') || 1000;
|
||||
const progress = engines.goals.getDailyProgress(dailyGoal);
|
||||
const streak = engines.goals.getStreak(dailyGoal);
|
||||
const weekly = engines.goals.getWeeklyTotal();
|
||||
const last30 = engines.goals.getLast30Days();
|
||||
|
||||
container.replaceChildren();
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'ws-panel';
|
||||
|
||||
// Daily progress section
|
||||
const section1 = document.createElement('div');
|
||||
section1.className = 'ws-section';
|
||||
const heading1 = document.createElement('h3');
|
||||
heading1.className = 'ws-heading';
|
||||
heading1.textContent = 'Daily Progress';
|
||||
section1.appendChild(heading1);
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'ws-progress-bar';
|
||||
const fill = document.createElement('div');
|
||||
fill.className = 'ws-progress-fill';
|
||||
fill.style.width = progress.pct + '%';
|
||||
bar.appendChild(fill);
|
||||
section1.appendChild(bar);
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'ws-stat-row';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = progress.written.toLocaleString() + ' / ' + dailyGoal.toLocaleString() + ' words';
|
||||
const pct = document.createElement('span');
|
||||
pct.className = 'ws-pct';
|
||||
pct.textContent = progress.pct + '%';
|
||||
row.appendChild(label);
|
||||
row.appendChild(pct);
|
||||
section1.appendChild(row);
|
||||
panel.appendChild(section1);
|
||||
|
||||
// Stats cards
|
||||
const section2 = document.createElement('div');
|
||||
section2.className = 'ws-section';
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'ws-stat-grid';
|
||||
|
||||
const streakCard = document.createElement('div');
|
||||
streakCard.className = 'ws-stat-card';
|
||||
const streakVal = document.createElement('span');
|
||||
streakVal.className = 'ws-stat-value';
|
||||
streakVal.textContent = String(streak);
|
||||
const streakLbl = document.createElement('span');
|
||||
streakLbl.className = 'ws-stat-label';
|
||||
streakLbl.textContent = 'Day Streak';
|
||||
streakCard.appendChild(streakVal);
|
||||
streakCard.appendChild(streakLbl);
|
||||
|
||||
const weekCard = document.createElement('div');
|
||||
weekCard.className = 'ws-stat-card';
|
||||
const weekVal = document.createElement('span');
|
||||
weekVal.className = 'ws-stat-value';
|
||||
weekVal.textContent = weekly.toLocaleString();
|
||||
const weekLbl = document.createElement('span');
|
||||
weekLbl.className = 'ws-stat-label';
|
||||
weekLbl.textContent = 'This Week';
|
||||
weekCard.appendChild(weekVal);
|
||||
weekCard.appendChild(weekLbl);
|
||||
|
||||
grid.appendChild(streakCard);
|
||||
grid.appendChild(weekCard);
|
||||
section2.appendChild(grid);
|
||||
panel.appendChild(section2);
|
||||
|
||||
// 30-day chart
|
||||
const section3 = document.createElement('div');
|
||||
section3.className = 'ws-section';
|
||||
const heading3 = document.createElement('h3');
|
||||
heading3.className = 'ws-heading';
|
||||
heading3.textContent = 'Last 30 Days';
|
||||
section3.appendChild(heading3);
|
||||
|
||||
const chart = document.createElement('div');
|
||||
chart.className = 'ws-chart';
|
||||
const maxWords = Math.max(...last30.map(d => d.words), 1);
|
||||
for (const day of last30) {
|
||||
const barEl = document.createElement('div');
|
||||
const height = Math.max(2, (day.words / maxWords) * 60);
|
||||
barEl.className = 'ws-bar' + (day.words >= dailyGoal ? ' ws-bar-met' : '');
|
||||
barEl.style.height = height + 'px';
|
||||
barEl.title = day.date + ': ' + day.words + ' words';
|
||||
chart.appendChild(barEl);
|
||||
}
|
||||
section3.appendChild(chart);
|
||||
panel.appendChild(section3);
|
||||
|
||||
container.appendChild(panel);
|
||||
}
|
||||
|
||||
module.exports = { renderGoalsPanel };
|
||||
@@ -0,0 +1,124 @@
|
||||
function renderManuscriptPanel(container, { engines, editor, settings }) {
|
||||
const projectDir = settings.get('projectDir');
|
||||
|
||||
container.replaceChildren();
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'ws-panel';
|
||||
|
||||
if (!projectDir) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'ws-empty';
|
||||
const p = document.createElement('p');
|
||||
p.textContent = 'No manuscript project open';
|
||||
empty.appendChild(p);
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'ws-btn ws-btn-primary';
|
||||
btn.id = 'ws-new-project';
|
||||
btn.textContent = 'New Project';
|
||||
btn.addEventListener('click', () => {
|
||||
const name = prompt('Project name:');
|
||||
if (!name) return;
|
||||
settings.set('projectDir', name);
|
||||
renderManuscriptPanel(container, { engines, editor, settings });
|
||||
});
|
||||
empty.appendChild(btn);
|
||||
panel.appendChild(empty);
|
||||
container.appendChild(panel);
|
||||
return;
|
||||
}
|
||||
|
||||
const project = engines.projects.loadProject(projectDir);
|
||||
if (!project) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'ws-empty';
|
||||
const p = document.createElement('p');
|
||||
p.textContent = 'Project not found at ' + projectDir;
|
||||
empty.appendChild(p);
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'ws-btn';
|
||||
btn.id = 'ws-clear-project';
|
||||
btn.textContent = 'Clear Project';
|
||||
btn.addEventListener('click', () => {
|
||||
settings.set('projectDir', null);
|
||||
renderManuscriptPanel(container, { engines, editor, settings });
|
||||
});
|
||||
empty.appendChild(btn);
|
||||
panel.appendChild(empty);
|
||||
container.appendChild(panel);
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = engines.projects.getStats(projectDir);
|
||||
|
||||
// Project title + progress
|
||||
const section1 = document.createElement('div');
|
||||
section1.className = 'ws-section';
|
||||
const heading = document.createElement('h3');
|
||||
heading.className = 'ws-heading';
|
||||
heading.textContent = project.title;
|
||||
section1.appendChild(heading);
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'ws-progress-bar';
|
||||
const fill = document.createElement('div');
|
||||
fill.className = 'ws-progress-fill';
|
||||
fill.style.width = stats.pctComplete + '%';
|
||||
bar.appendChild(fill);
|
||||
section1.appendChild(bar);
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'ws-stat-row';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = stats.totalWords.toLocaleString() + ' / ' + stats.targetWords.toLocaleString() + ' words';
|
||||
const pct = document.createElement('span');
|
||||
pct.className = 'ws-pct';
|
||||
pct.textContent = stats.pctComplete + '%';
|
||||
row.appendChild(label);
|
||||
row.appendChild(pct);
|
||||
section1.appendChild(row);
|
||||
panel.appendChild(section1);
|
||||
|
||||
// Chapters list
|
||||
const section2 = document.createElement('div');
|
||||
section2.className = 'ws-section';
|
||||
const heading2 = document.createElement('h3');
|
||||
heading2.className = 'ws-heading';
|
||||
heading2.textContent = 'Chapters (' + project.chapters.length + ')';
|
||||
section2.appendChild(heading2);
|
||||
|
||||
const chList = document.createElement('div');
|
||||
chList.className = 'ws-chapter-list';
|
||||
for (const ch of project.chapters) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'ws-chapter-item';
|
||||
const title = document.createElement('span');
|
||||
title.className = 'ws-chapter-title';
|
||||
title.textContent = ch.title || ch.file;
|
||||
const status = document.createElement('span');
|
||||
status.className = 'ws-chapter-status ws-status-' + (ch.status || 'draft');
|
||||
status.textContent = ch.status || 'draft';
|
||||
item.appendChild(title);
|
||||
item.appendChild(status);
|
||||
chList.appendChild(item);
|
||||
}
|
||||
section2.appendChild(chList);
|
||||
panel.appendChild(section2);
|
||||
|
||||
// Compile button
|
||||
const section3 = document.createElement('div');
|
||||
section3.className = 'ws-section';
|
||||
const compileBtn = document.createElement('button');
|
||||
compileBtn.className = 'ws-btn ws-btn-primary';
|
||||
compileBtn.id = 'ws-compile';
|
||||
compileBtn.textContent = 'Compile Manuscript';
|
||||
compileBtn.addEventListener('click', () => {
|
||||
const compiled = engines.projects.compileManuscript(projectDir);
|
||||
editor.insertAtCursor(compiled);
|
||||
});
|
||||
section3.appendChild(compileBtn);
|
||||
panel.appendChild(section3);
|
||||
|
||||
container.appendChild(panel);
|
||||
}
|
||||
|
||||
module.exports = { renderManuscriptPanel };
|
||||
@@ -0,0 +1,97 @@
|
||||
function renderProofreadPanel(container, { events, editor }) {
|
||||
const hasAI = events.hasHandler('ai:analyze');
|
||||
|
||||
container.replaceChildren();
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'ws-panel';
|
||||
|
||||
const section = document.createElement('div');
|
||||
section.className = 'ws-section';
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'ws-btn ws-btn-primary';
|
||||
btn.id = 'ws-proofread';
|
||||
btn.textContent = hasAI ? 'Check Document' : 'AI Plugin Required';
|
||||
btn.disabled = !hasAI;
|
||||
section.appendChild(btn);
|
||||
|
||||
if (!hasAI) {
|
||||
const note = document.createElement('p');
|
||||
note.className = 'ws-muted';
|
||||
note.textContent = 'Install the AI Assistant plugin to enable proofreading.';
|
||||
section.appendChild(note);
|
||||
}
|
||||
panel.appendChild(section);
|
||||
|
||||
const issuesList = document.createElement('div');
|
||||
issuesList.className = 'ws-issues-list';
|
||||
issuesList.id = 'ws-issues';
|
||||
panel.appendChild(issuesList);
|
||||
|
||||
container.appendChild(panel);
|
||||
|
||||
if (!hasAI) return;
|
||||
|
||||
container.querySelector('#ws-proofread').addEventListener('click', () => {
|
||||
const content = editor.getContent() || '';
|
||||
events.emit('ai:analyze', {
|
||||
text: content,
|
||||
type: 'grammar',
|
||||
callback: (result) => {
|
||||
if (result && result.issues) {
|
||||
renderIssues(issuesList, result.issues);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderIssues(container, issues) {
|
||||
container.replaceChildren();
|
||||
if (!issues || issues.length === 0) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'ws-muted';
|
||||
p.textContent = 'No issues found.';
|
||||
container.appendChild(p);
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < issues.length; i++) {
|
||||
const issue = issues[i];
|
||||
const item = document.createElement('div');
|
||||
item.className = 'ws-issue-item';
|
||||
|
||||
const type = document.createElement('div');
|
||||
type.className = 'ws-issue-type';
|
||||
type.textContent = (issue.type || 'grammar').toUpperCase();
|
||||
item.appendChild(type);
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.className = 'ws-issue-text';
|
||||
text.textContent = issue.message || issue.text || '';
|
||||
item.appendChild(text);
|
||||
|
||||
if (issue.suggestion) {
|
||||
const sug = document.createElement('div');
|
||||
sug.className = 'ws-issue-suggestion';
|
||||
sug.textContent = 'Suggestion: ' + issue.suggestion;
|
||||
item.appendChild(sug);
|
||||
}
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'ws-issue-actions';
|
||||
for (const [action, label] of [['accept', 'Accept'], ['dismiss', 'Dismiss']]) {
|
||||
const actionBtn = document.createElement('button');
|
||||
actionBtn.className = 'ws-btn ws-btn-sm';
|
||||
actionBtn.textContent = label;
|
||||
actionBtn.addEventListener('click', () => {
|
||||
item.remove();
|
||||
});
|
||||
actions.appendChild(actionBtn);
|
||||
}
|
||||
item.appendChild(actions);
|
||||
container.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { renderProofreadPanel };
|
||||
@@ -0,0 +1,81 @@
|
||||
function renderSnapshotsPanel(container, { engines, editor }) {
|
||||
const snapshots = engines.snapshots.list();
|
||||
|
||||
container.replaceChildren();
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'ws-panel';
|
||||
|
||||
// Header with take snapshot button
|
||||
const section = document.createElement('div');
|
||||
section.className = 'ws-section';
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'ws-btn ws-btn-primary';
|
||||
btn.id = 'ws-take-snapshot';
|
||||
btn.textContent = 'Take Snapshot';
|
||||
section.appendChild(btn);
|
||||
const count = document.createElement('span');
|
||||
count.className = 'ws-muted';
|
||||
count.textContent = snapshots.length + ' snapshots';
|
||||
section.appendChild(count);
|
||||
panel.appendChild(section);
|
||||
|
||||
// Snapshot list
|
||||
const list = document.createElement('div');
|
||||
list.className = 'ws-snapshot-list';
|
||||
for (const s of snapshots) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'ws-snapshot-item';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'ws-snapshot-header';
|
||||
const sLabel = document.createElement('span');
|
||||
sLabel.className = 'ws-snapshot-label';
|
||||
sLabel.textContent = s.label;
|
||||
const sWords = document.createElement('span');
|
||||
sWords.textContent = s.wordCount + ' words';
|
||||
header.appendChild(sLabel);
|
||||
header.appendChild(sWords);
|
||||
item.appendChild(header);
|
||||
|
||||
const time = document.createElement('div');
|
||||
time.className = 'ws-snapshot-time';
|
||||
time.textContent = new Date(s.timestamp).toLocaleString();
|
||||
item.appendChild(time);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'ws-snapshot-actions';
|
||||
for (const [action, text, cls] of [['restore', 'Restore', ''], ['diff', 'Diff', ''], ['delete', 'Delete', 'ws-btn-danger']]) {
|
||||
const actionBtn = document.createElement('button');
|
||||
actionBtn.className = 'ws-btn ws-btn-sm' + (cls ? ' ' + cls : '');
|
||||
actionBtn.textContent = text;
|
||||
actionBtn.addEventListener('click', () => {
|
||||
if (action === 'restore') {
|
||||
const content = engines.snapshots.restore(s.id);
|
||||
editor.insertAtCursor(content);
|
||||
} else if (action === 'delete') {
|
||||
engines.snapshots.delete(s.id);
|
||||
renderSnapshotsPanel(container, { engines, editor });
|
||||
} else if (action === 'diff') {
|
||||
const current = editor.getContent() || '';
|
||||
const result = engines.snapshots.diff(s.id, current);
|
||||
alert('+' + result.added + ' lines added, -' + result.removed + ' lines removed');
|
||||
}
|
||||
});
|
||||
actions.appendChild(actionBtn);
|
||||
}
|
||||
item.appendChild(actions);
|
||||
list.appendChild(item);
|
||||
}
|
||||
panel.appendChild(list);
|
||||
container.appendChild(panel);
|
||||
|
||||
// Take snapshot button handler
|
||||
container.querySelector('#ws-take-snapshot').addEventListener('click', () => {
|
||||
const content = editor.getContent() || '';
|
||||
engines.snapshots.create(content, 'manual');
|
||||
renderSnapshotsPanel(container, { engines, editor });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { renderSnapshotsPanel };
|
||||
@@ -0,0 +1,74 @@
|
||||
class ProjectManager {
|
||||
/**
|
||||
* @param {object} fs - { readFile(path), writeFile(path, content), fileExists(path), listDir(path) }
|
||||
*/
|
||||
constructor(fs) {
|
||||
this.fs = fs;
|
||||
}
|
||||
|
||||
createProject(dir, opts) {
|
||||
const project = {
|
||||
title: opts.title,
|
||||
type: opts.type || 'manuscript',
|
||||
target: { words: opts.targetWords || 0, deadline: opts.deadline || null },
|
||||
chapters: [],
|
||||
metadata: opts.metadata || {}
|
||||
};
|
||||
this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
|
||||
return project;
|
||||
}
|
||||
|
||||
loadProject(dir) {
|
||||
const raw = this.fs.readFile(dir + '/.project.json');
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
_saveProject(dir, project) {
|
||||
this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
|
||||
}
|
||||
|
||||
addChapter(dir, chapter) {
|
||||
const project = this.loadProject(dir);
|
||||
if (!project) throw new Error('Project not found');
|
||||
project.chapters.push(chapter);
|
||||
this._saveProject(dir, project);
|
||||
}
|
||||
|
||||
updateChapter(dir, index, updates) {
|
||||
const project = this.loadProject(dir);
|
||||
if (!project) throw new Error('Project not found');
|
||||
Object.assign(project.chapters[index], updates);
|
||||
this._saveProject(dir, project);
|
||||
}
|
||||
|
||||
compileManuscript(dir) {
|
||||
const project = this.loadProject(dir);
|
||||
if (!project) throw new Error('Project not found');
|
||||
const parts = [];
|
||||
for (const ch of project.chapters) {
|
||||
const content = this.fs.readFile(dir + '/' + ch.file);
|
||||
if (content) parts.push(content);
|
||||
}
|
||||
return parts.join('\n\n---\n\n');
|
||||
}
|
||||
|
||||
getStats(dir) {
|
||||
const project = this.loadProject(dir);
|
||||
if (!project) throw new Error('Project not found');
|
||||
let totalWords = 0;
|
||||
for (const ch of project.chapters) {
|
||||
const content = this.fs.readFile(dir + '/' + ch.file);
|
||||
if (content) totalWords += content.split(/\s+/).filter(Boolean).length;
|
||||
}
|
||||
const target = project.target.words || 0;
|
||||
return {
|
||||
totalWords,
|
||||
chapterCount: project.chapters.length,
|
||||
targetWords: target,
|
||||
pctComplete: target > 0 ? Math.min(100, Math.round((totalWords / target) * 100)) : 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ProjectManager };
|
||||
@@ -0,0 +1,73 @@
|
||||
class SnapshotManager {
|
||||
/**
|
||||
* @param {object} store - { get(key), set(key, value) }
|
||||
* @param {string} storeKey - settings key for snapshots
|
||||
*/
|
||||
constructor(store, storeKey = 'plugins.writing-studio.snapshots') {
|
||||
this.store = store;
|
||||
this.storeKey = storeKey;
|
||||
}
|
||||
|
||||
_getAll() {
|
||||
const raw = this.store.get(this.storeKey);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
}
|
||||
|
||||
_saveAll(snaps) {
|
||||
this.store.set(this.storeKey, JSON.stringify(snaps));
|
||||
}
|
||||
|
||||
create(content, label = 'manual') {
|
||||
const snaps = this._getAll();
|
||||
const snap = {
|
||||
id: 'snap-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8),
|
||||
timestamp: new Date().toISOString(),
|
||||
content,
|
||||
wordCount: content.split(/\s+/).filter(Boolean).length,
|
||||
label
|
||||
};
|
||||
snaps.unshift(snap);
|
||||
this._saveAll(snaps);
|
||||
return snap;
|
||||
}
|
||||
|
||||
list() {
|
||||
return this._getAll();
|
||||
}
|
||||
|
||||
getById(id) {
|
||||
return this._getAll().find(s => s.id === id) || null;
|
||||
}
|
||||
|
||||
restore(id) {
|
||||
const snap = this.getById(id);
|
||||
if (!snap) throw new Error('Snapshot not found');
|
||||
return snap.content;
|
||||
}
|
||||
|
||||
delete(id) {
|
||||
const snaps = this._getAll().filter(s => s.id !== id);
|
||||
this._saveAll(snaps);
|
||||
}
|
||||
|
||||
diff(id, currentContent) {
|
||||
const snap = this.getById(id);
|
||||
if (!snap) throw new Error('Snapshot not found');
|
||||
const oldLines = snap.content.split('\n');
|
||||
const newLines = currentContent.split('\n');
|
||||
const oldSet = new Set(oldLines);
|
||||
const newSet = new Set(newLines);
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const line of newLines) { if (!oldSet.has(line)) added++; }
|
||||
for (const line of oldLines) { if (!newSet.has(line)) removed++; }
|
||||
return { added, removed };
|
||||
}
|
||||
|
||||
prune(keepCount) {
|
||||
const snaps = this._getAll();
|
||||
this._saveAll(snaps.slice(0, keepCount));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SnapshotManager };
|
||||
@@ -0,0 +1,51 @@
|
||||
class SprintEngine {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {function} opts.onEvent - callback(event_name, data)
|
||||
*/
|
||||
constructor(opts = {}) {
|
||||
this.onEvent = opts.onEvent || (() => {});
|
||||
this._active = false;
|
||||
this._startTime = null;
|
||||
this._duration = 0;
|
||||
this._initialWords = 0;
|
||||
}
|
||||
|
||||
start(durationMinutes, currentWordCount) {
|
||||
if (this._active) throw new Error('Sprint already active');
|
||||
this._active = true;
|
||||
this._startTime = Date.now();
|
||||
this._duration = durationMinutes * 60 * 1000;
|
||||
this._initialWords = currentWordCount;
|
||||
}
|
||||
|
||||
stop(currentWordCount) {
|
||||
if (!this._active) throw new Error('No active sprint');
|
||||
const elapsed = Date.now() - this._startTime;
|
||||
const wordDelta = Math.max(0, currentWordCount - this._initialWords);
|
||||
const elapsedMinutes = elapsed / 60000;
|
||||
const wpm = elapsedMinutes > 0 ? Math.round(wordDelta / elapsedMinutes) : 0;
|
||||
this._active = false;
|
||||
this._startTime = null;
|
||||
return { wordDelta, elapsed, wpm };
|
||||
}
|
||||
|
||||
tick(elapsedMs) {
|
||||
if (!this._active) return;
|
||||
const remaining = Math.max(0, this._duration - elapsedMs);
|
||||
this.onEvent('sprint:tick', { remaining, elapsed: elapsedMs });
|
||||
if (remaining <= 0) {
|
||||
this._active = false;
|
||||
this.onEvent('sprint:complete', { expired: true });
|
||||
}
|
||||
}
|
||||
|
||||
isActive() { return this._active; }
|
||||
|
||||
getRemaining() {
|
||||
if (!this._active) return 0;
|
||||
return Math.max(0, this._duration - (Date.now() - this._startTime));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SprintEngine };
|
||||
@@ -0,0 +1,43 @@
|
||||
class EventBus {
|
||||
constructor() {
|
||||
this.listeners = new Map();
|
||||
}
|
||||
|
||||
on(event, handler) {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, []);
|
||||
}
|
||||
this.listeners.get(event).push(handler);
|
||||
}
|
||||
|
||||
off(event, handler) {
|
||||
if (!handler) {
|
||||
this.listeners.delete(event);
|
||||
return;
|
||||
}
|
||||
const handlers = this.listeners.get(event);
|
||||
if (handlers) {
|
||||
const idx = handlers.indexOf(handler);
|
||||
if (idx !== -1) handlers.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
emit(event, payload) {
|
||||
const handlers = this.listeners.get(event);
|
||||
if (!handlers) return;
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
handler(payload);
|
||||
} catch (err) {
|
||||
console.error(`[EventBus] Error in handler for "${event}":`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasHandler(event) {
|
||||
const handlers = this.listeners.get(event);
|
||||
return handlers !== undefined && handlers !== null && handlers.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { EventBus };
|
||||
@@ -0,0 +1,23 @@
|
||||
class PluginAPI {
|
||||
/**
|
||||
* Called when the plugin is discovered and loaded.
|
||||
* Receives a scoped context object with APIs.
|
||||
* @param {object} context - Plugin context (sidebar, commands, settings, etc.)
|
||||
*/
|
||||
init(context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
/** Called when the plugin is activated (e.g., sidebar panel clicked). */
|
||||
activate() {}
|
||||
|
||||
/** Called when the plugin is deactivated. */
|
||||
deactivate() {}
|
||||
|
||||
/** Returns the parsed manifest.json for this plugin. */
|
||||
getManifest() {
|
||||
return this._manifest || null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginAPI };
|
||||
@@ -0,0 +1,70 @@
|
||||
class PluginContext {
|
||||
/**
|
||||
* @param {object} deps - Injected dependencies
|
||||
* @param {string} deps.pluginId - Plugin unique ID (for namespacing)
|
||||
* @param {object} deps.sidebar - SidebarManager.registerPanel
|
||||
* @param {object} deps.commands - CommandPalette.register
|
||||
* @param {object} deps.statusBar - StatusBar.registerIndicator
|
||||
* @param {object} deps.eventBus - EventBus instance
|
||||
* @param {object} deps.settings - { get, set, onChanged }
|
||||
* @param {object} deps.editor - { getContent, getSelection, insertAtCursor, onContentChanged }
|
||||
* @param {object} deps.ipc - { invoke, on }
|
||||
* @param {object} deps.exportHooks - { preHooks: [], postHooks: [] }
|
||||
*/
|
||||
constructor(deps) {
|
||||
const { pluginId, sidebar, commands, statusBar, eventBus, settings, editor, ipc, exportHooks } = deps;
|
||||
|
||||
this.sidebar = {
|
||||
registerPanel: (id, opts) => sidebar.registerPanel(`${pluginId}:${id}`, opts)
|
||||
};
|
||||
|
||||
this.commands = {
|
||||
register: (id, label, handler, shortcut) => {
|
||||
const safeHandler = (...args) => {
|
||||
try {
|
||||
handler(...args);
|
||||
} catch (err) {
|
||||
console.error(`[Plugin:${pluginId}] Command "${id}" error:`, err);
|
||||
}
|
||||
};
|
||||
commands.register(`${pluginId}:${id}`, label, safeHandler, shortcut);
|
||||
}
|
||||
};
|
||||
|
||||
this.statusBar = {
|
||||
registerIndicator: (id, opts) => statusBar.registerIndicator(`${pluginId}:${id}`, opts)
|
||||
};
|
||||
|
||||
this.settings = {
|
||||
get: (key) => settings.get(`plugins.${pluginId}.${key}`),
|
||||
set: (key, value) => settings.set(`plugins.${pluginId}.${key}`, value),
|
||||
onChanged: (key, cb) => settings.onChanged(`plugins.${pluginId}.${key}`, cb)
|
||||
};
|
||||
|
||||
this.editor = {
|
||||
getContent: () => editor.getContent(),
|
||||
getSelection: () => editor.getSelection(),
|
||||
insertAtCursor: (text) => editor.insertAtCursor(text),
|
||||
onContentChanged: (cb) => editor.onContentChanged(cb)
|
||||
};
|
||||
|
||||
this.events = {
|
||||
on: (event, handler) => eventBus.on(event, handler),
|
||||
off: (event, handler) => eventBus.off(event, handler),
|
||||
emit: (event, payload) => eventBus.emit(event, payload),
|
||||
hasHandler: (event) => eventBus.hasHandler(event)
|
||||
};
|
||||
|
||||
this.ipc = {
|
||||
invoke: (channel, ...args) => ipc.invoke(channel, ...args),
|
||||
on: (channel, handler) => ipc.on(channel, handler)
|
||||
};
|
||||
|
||||
this.exports = {
|
||||
registerPreHook: (handler) => { if (exportHooks) exportHooks.preHooks.push(handler); },
|
||||
registerPostHook: (handler) => { if (exportHooks) exportHooks.postHooks.push(handler); }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginContext };
|
||||
@@ -0,0 +1,75 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
class PluginLoader {
|
||||
/**
|
||||
* @param {string[]} searchDirs - Directories to scan for plugin folders
|
||||
*/
|
||||
constructor(searchDirs = []) {
|
||||
this.searchDirs = searchDirs;
|
||||
this.loadedIds = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover plugins by scanning searchDirs for manifest.json files.
|
||||
* Returns array of { id, name, version, description, manifest, PluginClass, dir }
|
||||
*/
|
||||
discoverPlugins() {
|
||||
const plugins = [];
|
||||
for (const dir of this.searchDirs) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const pluginDir = path.join(dir, entry.name);
|
||||
const manifestPath = path.join(pluginDir, 'manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) continue;
|
||||
try {
|
||||
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
||||
const manifest = JSON.parse(raw);
|
||||
this.validateManifest(manifest);
|
||||
let PluginClass = null;
|
||||
const indexPath = path.join(pluginDir, 'index.js');
|
||||
if (fs.existsSync(indexPath)) {
|
||||
try {
|
||||
const loaded = require(indexPath);
|
||||
PluginClass = loaded.Plugin || loaded.default || null;
|
||||
} catch (err) {
|
||||
console.error(`[PluginLoader] Failed to load index.js for "${manifest.id}":`, err.message);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
plugins.push({
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
description: manifest.description,
|
||||
manifest,
|
||||
PluginClass,
|
||||
dir: pluginDir
|
||||
});
|
||||
this.loadedIds.add(manifest.id);
|
||||
} catch (err) {
|
||||
console.error(`[PluginLoader] Skipping plugin in ${pluginDir}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
return plugins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a manifest object. Throws on invalid.
|
||||
*/
|
||||
validateManifest(manifest) {
|
||||
if (!manifest.id) throw new Error('Manifest missing required field: id');
|
||||
if (!manifest.name) throw new Error('Manifest missing required field: name');
|
||||
if (!manifest.version) throw new Error('Manifest missing required field: version');
|
||||
if (!manifest.description) throw new Error('Manifest missing required field: description');
|
||||
if (this.loadedIds.has(manifest.id)) {
|
||||
throw new Error(`Duplicate plugin id: "${manifest.id}"`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginLoader };
|
||||
@@ -0,0 +1,80 @@
|
||||
const { PluginContext } = require('./plugin-context');
|
||||
const { PluginAPI } = require('./plugin-api');
|
||||
|
||||
class PluginRegistry {
|
||||
constructor(deps) {
|
||||
this.deps = deps;
|
||||
this.plugins = new Map();
|
||||
this.exportHooks = { preHooks: [], postHooks: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a discovered plugin. Creates instance, builds context, calls init.
|
||||
* If init throws, plugin is NOT registered.
|
||||
*/
|
||||
register(pluginInfo) {
|
||||
const { id, name, version, description, manifest, PluginClass, dir } = pluginInfo;
|
||||
|
||||
let instance;
|
||||
if (PluginClass) {
|
||||
instance = new PluginClass();
|
||||
} else {
|
||||
instance = new PluginAPI();
|
||||
}
|
||||
instance._manifest = manifest;
|
||||
|
||||
const context = new PluginContext({
|
||||
pluginId: id,
|
||||
sidebar: this.deps.sidebar,
|
||||
commands: this.deps.commands,
|
||||
statusBar: this.deps.statusBar,
|
||||
eventBus: this.deps.eventBus,
|
||||
settings: this.deps.settings,
|
||||
editor: this.deps.editor,
|
||||
ipc: this.deps.ipc,
|
||||
exportHooks: this.exportHooks
|
||||
});
|
||||
|
||||
try {
|
||||
instance.init(context);
|
||||
} catch (err) {
|
||||
console.error(`[PluginRegistry] Plugin "${id}" init failed:`, err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
this.plugins.set(id, { id, name, version, description, manifest, instance, dir, context });
|
||||
console.log(`[PluginRegistry] Registered plugin: ${name} v${version}`);
|
||||
}
|
||||
|
||||
getPlugin(id) {
|
||||
return this.plugins.get(id);
|
||||
}
|
||||
|
||||
getAll() {
|
||||
return Array.from(this.plugins.values());
|
||||
}
|
||||
|
||||
activate(id) {
|
||||
const plugin = this.plugins.get(id);
|
||||
if (plugin?.instance) {
|
||||
try {
|
||||
plugin.instance.activate();
|
||||
} catch (err) {
|
||||
console.error(`[PluginRegistry] Plugin "${id}" activate error:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deactivate(id) {
|
||||
const plugin = this.plugins.get(id);
|
||||
if (plugin?.instance) {
|
||||
try {
|
||||
plugin.instance.deactivate();
|
||||
} catch (err) {
|
||||
console.error(`[PluginRegistry] Plugin "${id}" deactivate error:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginRegistry };
|
||||
@@ -0,0 +1,23 @@
|
||||
class SettingsStore {
|
||||
/**
|
||||
* @param {object} backend - { get(key), set(key, value) } backed by main process store
|
||||
*/
|
||||
constructor(backend) {
|
||||
this.backend = backend;
|
||||
}
|
||||
|
||||
get(key) {
|
||||
return this.backend.get(key);
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
this.backend.set(key, value);
|
||||
}
|
||||
|
||||
onChanged(key, callback) {
|
||||
// Deferred: plugins read settings on init/activate for MVP.
|
||||
// Full change notification requires IPC watcher in main process.
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SettingsStore };
|
||||
+5
-1
@@ -135,7 +135,11 @@ const ALLOWED_SEND_CHANNELS = [
|
||||
'export',
|
||||
|
||||
// Git diff
|
||||
'git-diff'
|
||||
'git-diff',
|
||||
|
||||
// Plugin settings
|
||||
'plugin-settings:get',
|
||||
'plugin-settings:set'
|
||||
];
|
||||
|
||||
const ALLOWED_RECEIVE_CHANNELS = [
|
||||
|
||||
@@ -130,6 +130,9 @@ function plantumlEncode(text) {
|
||||
return '~h' + hex;
|
||||
}
|
||||
|
||||
// Module-level reference set by DOMContentLoaded after sidebar registration
|
||||
let outlinePanelContainer = null;
|
||||
|
||||
// Tab Management
|
||||
class TabManager {
|
||||
constructor() {
|
||||
@@ -1545,6 +1548,58 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Plugin System ---
|
||||
const { PluginLoader } = require('./plugins/plugin-loader');
|
||||
const { PluginRegistry } = require('./plugins/plugin-registry');
|
||||
const { EventBus } = require('./plugins/event-bus');
|
||||
const { SettingsStore } = require('./plugins/settings-store');
|
||||
const pluginPath = require('path');
|
||||
|
||||
const pluginEventBus = new EventBus();
|
||||
const pluginSettings = new SettingsStore({
|
||||
get: (key) => window.electronAPI.invoke('plugin-settings:get', key),
|
||||
set: (key, value) => window.electronAPI.invoke('plugin-settings:set', { key, value })
|
||||
});
|
||||
|
||||
const statusBarRight = document.querySelector('.status-bar-right');
|
||||
const pluginRegistry = new PluginRegistry({
|
||||
sidebar: sidebarManager,
|
||||
commands: commandPalette,
|
||||
statusBar: {
|
||||
registerIndicator: (id, opts) => {
|
||||
const el = document.createElement('span');
|
||||
el.className = 'status-item';
|
||||
el.id = `plugin-status-${id}`;
|
||||
if (opts.tooltip) el.title = opts.tooltip;
|
||||
el.textContent = opts.text || '';
|
||||
const sep = document.createElement('span');
|
||||
sep.className = 'status-separator';
|
||||
sep.textContent = '|';
|
||||
if (statusBarRight) {
|
||||
statusBarRight.appendChild(sep);
|
||||
statusBarRight.appendChild(el);
|
||||
}
|
||||
}
|
||||
},
|
||||
eventBus: pluginEventBus,
|
||||
settings: pluginSettings,
|
||||
editor: {
|
||||
getContent: () => tabManager.getCurrentContent(),
|
||||
getSelection: () => tabManager.getSelection(),
|
||||
insertAtCursor: (text) => tabManager.insertAtCursor(text),
|
||||
onContentChanged: (cb) => pluginEventBus.on('document:changed', cb)
|
||||
},
|
||||
ipc: {
|
||||
invoke: (ch, data) => window.electronAPI.invoke(ch, data),
|
||||
on: (ch, cb) => window.electronAPI.on(ch, cb)
|
||||
}
|
||||
});
|
||||
|
||||
const builtInDir = pluginPath.join(__dirname, 'plugins', 'built-in');
|
||||
const loader = new PluginLoader([builtInDir]);
|
||||
const discovered = loader.discoverPlugins();
|
||||
discovered.forEach(p => pluginRegistry.register(p));
|
||||
|
||||
// Initialize Zen Mode
|
||||
const ZenModeClass = getZenMode();
|
||||
const zenMode = new ZenModeClass(tabManager);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
const { EventBus } = require('../src/plugins/event-bus');
|
||||
|
||||
describe('EventBus', () => {
|
||||
let bus;
|
||||
|
||||
beforeEach(() => {
|
||||
bus = new EventBus();
|
||||
});
|
||||
|
||||
test('on/emit — listener receives payload', () => {
|
||||
const received = [];
|
||||
bus.on('document:saved', (payload) => received.push(payload));
|
||||
bus.emit('document:saved', { filePath: '/test.md', tabId: 'tab1' });
|
||||
expect(received).toEqual([{ filePath: '/test.md', tabId: 'tab1' }]);
|
||||
});
|
||||
|
||||
test('on — ignores events with no listeners', () => {
|
||||
expect(() => bus.emit('unknown:event', {})).not.toThrow();
|
||||
});
|
||||
|
||||
test('off — removes specific listener', () => {
|
||||
const handler = jest.fn();
|
||||
bus.on('test:event', handler);
|
||||
bus.off('test:event', handler);
|
||||
bus.emit('test:event', {});
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('off — removes all listeners for event when no handler given', () => {
|
||||
const h1 = jest.fn();
|
||||
const h2 = jest.fn();
|
||||
bus.on('test:event', h1);
|
||||
bus.on('test:event', h2);
|
||||
bus.off('test:event');
|
||||
bus.emit('test:event', {});
|
||||
expect(h1).not.toHaveBeenCalled();
|
||||
expect(h2).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('hasHandler — returns true when listener exists', () => {
|
||||
bus.on('ai:analyze', () => {});
|
||||
expect(bus.hasHandler('ai:analyze')).toBe(true);
|
||||
});
|
||||
|
||||
test('hasHandler — returns false when no listener exists', () => {
|
||||
expect(bus.hasHandler('ai:analyze')).toBe(false);
|
||||
});
|
||||
|
||||
test('handler errors are caught and logged, not thrown', () => {
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
bus.on('bad:event', () => { throw new Error('boom'); });
|
||||
expect(() => bus.emit('bad:event', {})).not.toThrow();
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('multiple listeners all receive the event', () => {
|
||||
const results = [];
|
||||
bus.on('multi:event', () => results.push('a'));
|
||||
bus.on('multi:event', () => results.push('b'));
|
||||
bus.emit('multi:event', {});
|
||||
expect(results).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
const { GoalTracker } = require('../src/plugins/built-in/writing-studio/goal-tracker');
|
||||
|
||||
describe('GoalTracker', () => {
|
||||
let tracker;
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = {};
|
||||
tracker = new GoalTracker({
|
||||
get: (key) => store[key],
|
||||
set: (key, value) => { store[key] = value; }
|
||||
});
|
||||
});
|
||||
|
||||
test('addWords records words for today', () => {
|
||||
tracker.addWords(500);
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
expect(store['plugins.writing-studio.history']).toBeDefined();
|
||||
const history = JSON.parse(store['plugins.writing-studio.history']);
|
||||
expect(history[today].words).toBe(500);
|
||||
});
|
||||
|
||||
test('addWords accumulates across multiple calls', () => {
|
||||
tracker.addWords(300);
|
||||
tracker.addWords(200);
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const history = JSON.parse(store['plugins.writing-studio.history']);
|
||||
expect(history[today].words).toBe(500);
|
||||
});
|
||||
|
||||
test('getDailyProgress returns 0 when no history', () => {
|
||||
expect(tracker.getDailyProgress(1000)).toEqual({ written: 0, goal: 1000, pct: 0 });
|
||||
});
|
||||
|
||||
test('getDailyProgress returns percentage', () => {
|
||||
tracker.addWords(500);
|
||||
const progress = tracker.getDailyProgress(1000);
|
||||
expect(progress.written).toBe(500);
|
||||
expect(progress.pct).toBe(50);
|
||||
});
|
||||
|
||||
test('getStreak counts consecutive days meeting goal', () => {
|
||||
const today = new Date();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
const key = d.toISOString().split('T')[0];
|
||||
tracker._setHistoryDay(key, { words: 1200 });
|
||||
}
|
||||
const streak = tracker.getStreak(1000);
|
||||
expect(streak).toBe(3);
|
||||
});
|
||||
|
||||
test('getStreak breaks on missed day', () => {
|
||||
const today = new Date();
|
||||
tracker._setHistoryDay(today.toISOString().split('T')[0], { words: 1200 });
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
tracker._setHistoryDay(yesterday.toISOString().split('T')[0], { words: 500 });
|
||||
const dayBefore = new Date(today);
|
||||
dayBefore.setDate(dayBefore.getDate() - 2);
|
||||
tracker._setHistoryDay(dayBefore.toISOString().split('T')[0], { words: 1200 });
|
||||
expect(tracker.getStreak(1000)).toBe(1);
|
||||
});
|
||||
|
||||
test('getLast30Days returns array of 30 entries', () => {
|
||||
tracker.addWords(100);
|
||||
const days = tracker.getLast30Days();
|
||||
expect(days.length).toBe(30);
|
||||
expect(days[29].words).toBe(100);
|
||||
});
|
||||
|
||||
test('getWeeklyTotal sums last 7 days', () => {
|
||||
const today = new Date();
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
tracker._setHistoryDay(d.toISOString().split('T')[0], { words: 200 });
|
||||
}
|
||||
expect(tracker.getWeeklyTotal()).toBe(1400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
const { PluginAPI } = require('../src/plugins/plugin-api');
|
||||
|
||||
describe('PluginAPI', () => {
|
||||
test('has default no-op lifecycle methods', () => {
|
||||
const plugin = new PluginAPI();
|
||||
expect(() => plugin.init({})).not.toThrow();
|
||||
expect(() => plugin.activate()).not.toThrow();
|
||||
expect(() => plugin.deactivate()).not.toThrow();
|
||||
});
|
||||
|
||||
test('getManifest returns null by default', () => {
|
||||
const plugin = new PluginAPI();
|
||||
expect(plugin.getManifest()).toBeNull();
|
||||
});
|
||||
|
||||
test('subclass can override init', () => {
|
||||
let called = false;
|
||||
class MyPlugin extends PluginAPI {
|
||||
init(context) {
|
||||
called = true;
|
||||
this.context = context;
|
||||
}
|
||||
}
|
||||
const p = new MyPlugin();
|
||||
p.init({ foo: 'bar' });
|
||||
expect(called).toBe(true);
|
||||
expect(p.context.foo).toBe('bar');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
const { PluginContext } = require('../src/plugins/plugin-context');
|
||||
const { EventBus } = require('../src/plugins/event-bus');
|
||||
|
||||
describe('PluginContext', () => {
|
||||
let context;
|
||||
let mockDeps;
|
||||
|
||||
beforeEach(() => {
|
||||
mockDeps = {
|
||||
pluginId: 'test-plugin',
|
||||
sidebar: { registerPanel: jest.fn() },
|
||||
commands: { register: jest.fn() },
|
||||
statusBar: { registerIndicator: jest.fn() },
|
||||
eventBus: new EventBus(),
|
||||
settings: { get: jest.fn(), set: jest.fn(), onChanged: jest.fn() },
|
||||
editor: { getContent: jest.fn(), getSelection: jest.fn(), insertAtCursor: jest.fn(), onContentChanged: jest.fn() },
|
||||
ipc: { invoke: jest.fn(), on: jest.fn() },
|
||||
exportHooks: { preHooks: [], postHooks: [] }
|
||||
};
|
||||
context = new PluginContext(mockDeps);
|
||||
});
|
||||
|
||||
test('exposes sidebar.registerPanel with namespaced id', () => {
|
||||
const handler = jest.fn();
|
||||
context.sidebar.registerPanel('my-panel', { icon: 'test', title: 'Test', render: handler });
|
||||
expect(mockDeps.sidebar.registerPanel).toHaveBeenCalledWith('test-plugin:my-panel', { icon: 'test', title: 'Test', render: handler });
|
||||
});
|
||||
|
||||
test('exposes commands.register with crash-safe wrapper', () => {
|
||||
const badHandler = () => { throw new Error('boom'); };
|
||||
context.commands.register('bad-cmd', 'Bad', badHandler, 'Ctrl+Alt+T');
|
||||
const registeredHandler = mockDeps.commands.register.mock.calls[0][2];
|
||||
expect(() => registeredHandler()).not.toThrow();
|
||||
});
|
||||
|
||||
test('exposes settings.get/set scoped to plugin', () => {
|
||||
context.settings.get('myKey');
|
||||
context.settings.set('myKey', 'myValue');
|
||||
expect(mockDeps.settings.get).toHaveBeenCalledWith('plugins.test-plugin.myKey');
|
||||
expect(mockDeps.settings.set).toHaveBeenCalledWith('plugins.test-plugin.myKey', 'myValue');
|
||||
});
|
||||
|
||||
test('exposes editor methods', () => {
|
||||
context.editor.getContent();
|
||||
context.editor.getSelection();
|
||||
expect(mockDeps.editor.getContent).toHaveBeenCalled();
|
||||
expect(mockDeps.editor.getSelection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('exposes events via event bus', () => {
|
||||
const handler = jest.fn();
|
||||
context.events.on('test:event', handler);
|
||||
context.events.emit('test:event', { data: 1 });
|
||||
expect(handler).toHaveBeenCalledWith({ data: 1 });
|
||||
});
|
||||
|
||||
test('exposes events.hasHandler', () => {
|
||||
expect(context.events.hasHandler('no:such')).toBe(false);
|
||||
context.events.on('exists:event', () => {});
|
||||
expect(context.events.hasHandler('exists:event')).toBe(true);
|
||||
});
|
||||
|
||||
test('exposes ipc.invoke', () => {
|
||||
context.ipc.invoke('test:channel', { a: 1 });
|
||||
expect(mockDeps.ipc.invoke).toHaveBeenCalledWith('test:channel', { a: 1 });
|
||||
});
|
||||
|
||||
test('exposes exports.registerPreHook', () => {
|
||||
const handler = jest.fn();
|
||||
context.exports.registerPreHook(handler);
|
||||
expect(mockDeps.exportHooks.preHooks).toContain(handler);
|
||||
});
|
||||
|
||||
test('exposes exports.registerPostHook', () => {
|
||||
const handler = jest.fn();
|
||||
context.exports.registerPostHook(handler);
|
||||
expect(mockDeps.exportHooks.postHooks).toContain(handler);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { PluginLoader } = require('../src/plugins/plugin-loader');
|
||||
|
||||
describe('PluginLoader', () => {
|
||||
let tempDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plugin-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeManifest(dir, manifest) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||||
}
|
||||
|
||||
test('discoverPlugins — finds manifests in built-in directory', () => {
|
||||
const pluginDir = path.join(tempDir, 'my-plugin');
|
||||
writeManifest(pluginDir, {
|
||||
id: 'my-plugin',
|
||||
name: 'My Plugin',
|
||||
version: '1.0.0',
|
||||
description: 'Test'
|
||||
});
|
||||
const loader = new PluginLoader([tempDir]);
|
||||
const plugins = loader.discoverPlugins();
|
||||
expect(plugins).toHaveLength(1);
|
||||
expect(plugins[0].id).toBe('my-plugin');
|
||||
});
|
||||
|
||||
test('discoverPlugins — skips directories without manifest.json', () => {
|
||||
const emptyDir = path.join(tempDir, 'no-manifest');
|
||||
fs.mkdirSync(emptyDir, { recursive: true });
|
||||
const loader = new PluginLoader([emptyDir]);
|
||||
const plugins = loader.discoverPlugins();
|
||||
expect(plugins).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('validateManifest — accepts valid manifest', () => {
|
||||
const manifest = {
|
||||
id: 'test-plugin',
|
||||
name: 'Test Plugin',
|
||||
version: '1.0.0',
|
||||
description: 'A test plugin'
|
||||
};
|
||||
const loader = new PluginLoader([]);
|
||||
expect(loader.validateManifest(manifest)).toBe(true);
|
||||
});
|
||||
|
||||
test('validateManifest — rejects manifest missing id', () => {
|
||||
const manifest = { name: 'No ID', version: '1.0.0', description: 'x' };
|
||||
const loader = new PluginLoader([]);
|
||||
expect(() => loader.validateManifest(manifest)).toThrow(/id/);
|
||||
});
|
||||
|
||||
test('validateManifest — rejects manifest missing name', () => {
|
||||
const manifest = { id: 'test', version: '1.0.0', description: 'x' };
|
||||
const loader = new PluginLoader([]);
|
||||
expect(() => loader.validateManifest(manifest)).toThrow(/name/);
|
||||
});
|
||||
|
||||
test('validateManifest — rejects manifest with duplicate id', () => {
|
||||
const loader = new PluginLoader([]);
|
||||
loader.loadedIds = new Set(['existing-plugin']);
|
||||
expect(() => loader.validateManifest({ id: 'existing-plugin', name: 'Dup', version: '1.0.0', description: 'x' }))
|
||||
.toThrow(/duplicate/i);
|
||||
});
|
||||
|
||||
test('discoverPlugins — loads index.js Plugin export if present', () => {
|
||||
const pluginDir = path.join(tempDir, 'with-index');
|
||||
writeManifest(pluginDir, { id: 'with-index', name: 'With Index', version: '1.0.0', description: 'Test' });
|
||||
// Write a simple index.js with a Plugin export
|
||||
fs.writeFileSync(path.join(pluginDir, 'index.js'), `
|
||||
class SimplePlugin { init() {} }
|
||||
module.exports = { Plugin: SimplePlugin };
|
||||
`);
|
||||
const loader = new PluginLoader([tempDir]);
|
||||
const plugins = loader.discoverPlugins();
|
||||
expect(plugins).toHaveLength(1);
|
||||
expect(plugins[0].PluginClass).toBeDefined();
|
||||
expect(plugins[0].PluginClass.name).toBe('SimplePlugin');
|
||||
});
|
||||
|
||||
test('discoverPlugins — continues if one plugin fails to load', () => {
|
||||
const badDir = path.join(tempDir, 'bad');
|
||||
writeManifest(badDir, { id: 'bad', name: 'Bad', version: '1.0.0', description: 'Broken' });
|
||||
fs.writeFileSync(path.join(badDir, 'index.js'), 'throw new Error("broken");');
|
||||
|
||||
const goodDir = path.join(tempDir, 'good');
|
||||
writeManifest(goodDir, { id: 'good', name: 'Good', version: '1.0.0', description: 'Works' });
|
||||
|
||||
const loader = new PluginLoader([tempDir]);
|
||||
const plugins = loader.discoverPlugins();
|
||||
expect(plugins).toHaveLength(1);
|
||||
expect(plugins[0].id).toBe('good');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
const { PluginRegistry } = require('../src/plugins/plugin-registry');
|
||||
const { PluginAPI } = require('../src/plugins/plugin-api');
|
||||
const { EventBus } = require('../src/plugins/event-bus');
|
||||
|
||||
class TestPlugin extends PluginAPI {
|
||||
init(context) { this.initialized = true; this.ctx = context; }
|
||||
activate() { this.activated = true; }
|
||||
deactivate() { this.deactivated = true; }
|
||||
}
|
||||
|
||||
describe('PluginRegistry', () => {
|
||||
let registry;
|
||||
let mockDeps;
|
||||
|
||||
beforeEach(() => {
|
||||
mockDeps = {
|
||||
sidebar: { registerPanel: jest.fn() },
|
||||
commands: { register: jest.fn() },
|
||||
statusBar: { registerIndicator: jest.fn() },
|
||||
eventBus: new EventBus(),
|
||||
settings: { get: jest.fn(), set: jest.fn(), onChanged: jest.fn() },
|
||||
editor: { getContent: jest.fn(() => ''), getSelection: jest.fn(() => ''), insertAtCursor: jest.fn(), onContentChanged: jest.fn() },
|
||||
ipc: { invoke: jest.fn(), on: jest.fn() }
|
||||
};
|
||||
registry = new PluginRegistry(mockDeps);
|
||||
});
|
||||
|
||||
test('register — stores plugin and calls init', () => {
|
||||
registry.register({
|
||||
id: 'test', name: 'Test', version: '1.0.0', description: 'desc',
|
||||
manifest: {}, PluginClass: TestPlugin, dir: '/tmp/test'
|
||||
});
|
||||
const entry = registry.getPlugin('test');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.instance.initialized).toBe(true);
|
||||
});
|
||||
|
||||
test('register — works without PluginClass', () => {
|
||||
registry.register({
|
||||
id: 'manifest-only', name: 'Manifest Only', version: '1.0.0', description: 'desc',
|
||||
manifest: {}, PluginClass: null, dir: '/tmp/test'
|
||||
});
|
||||
expect(registry.getPlugin('manifest-only')).toBeDefined();
|
||||
});
|
||||
|
||||
test('register — init error does not crash, plugin not registered', () => {
|
||||
class BadPlugin extends PluginAPI {
|
||||
init() { throw new Error('init fail'); }
|
||||
}
|
||||
expect(() => {
|
||||
registry.register({
|
||||
id: 'bad', name: 'Bad', version: '1.0.0', description: 'desc',
|
||||
manifest: {}, PluginClass: BadPlugin, dir: '/tmp/test'
|
||||
});
|
||||
}).not.toThrow();
|
||||
expect(registry.getPlugin('bad')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getPlugin — returns undefined for unknown', () => {
|
||||
expect(registry.getPlugin('nope')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getAll — returns all registered plugins', () => {
|
||||
registry.register({ id: 'a', name: 'A', version: '1', description: '', manifest: {}, PluginClass: null, dir: '' });
|
||||
registry.register({ id: 'b', name: 'B', version: '1', description: '', manifest: {}, PluginClass: null, dir: '' });
|
||||
expect(registry.getAll()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('activate — calls activate on plugin instance', () => {
|
||||
registry.register({ id: 'test', name: 'Test', version: '1', description: '', manifest: {}, PluginClass: TestPlugin, dir: '' });
|
||||
registry.activate('test');
|
||||
expect(registry.getPlugin('test').instance.activated).toBe(true);
|
||||
});
|
||||
|
||||
test('deactivate — calls deactivate on plugin instance', () => {
|
||||
registry.register({ id: 'test', name: 'Test', version: '1', description: '', manifest: {}, PluginClass: TestPlugin, dir: '' });
|
||||
registry.deactivate('test');
|
||||
expect(registry.getPlugin('test').instance.deactivated).toBe(true);
|
||||
});
|
||||
|
||||
test('exportHooks are available and populated', () => {
|
||||
registry.register({ id: 'test', name: 'Test', version: '1', description: '', manifest: {}, PluginClass: TestPlugin, dir: '' });
|
||||
const handler = jest.fn();
|
||||
registry.getPlugin('test').instance.ctx.exports.registerPreHook(handler);
|
||||
expect(registry.exportHooks.preHooks).toContain(handler);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
const { ProjectManager } = require('../src/plugins/built-in/writing-studio/project-manager');
|
||||
|
||||
describe('ProjectManager', () => {
|
||||
let pm;
|
||||
let files;
|
||||
|
||||
beforeEach(() => {
|
||||
files = {};
|
||||
pm = new ProjectManager({
|
||||
readFile: (p) => files[p] || null,
|
||||
writeFile: (p, c) => { files[p] = c; },
|
||||
fileExists: (p) => p in files,
|
||||
listDir: (p) => Object.keys(files).filter(f => f.startsWith(p)).map(f => f.slice(p.length + 1))
|
||||
});
|
||||
});
|
||||
|
||||
test('createProject writes .project.json', () => {
|
||||
const project = pm.createProject('/manuscripts/novel', {
|
||||
title: 'My Novel', type: 'manuscript', targetWords: 80000
|
||||
});
|
||||
expect(project.title).toBe('My Novel');
|
||||
expect(files['/manuscripts/novel/.project.json']).toBeDefined();
|
||||
const parsed = JSON.parse(files['/manuscripts/novel/.project.json']);
|
||||
expect(parsed.target.words).toBe(80000);
|
||||
});
|
||||
|
||||
test('loadProject reads and returns project data', () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||
chapters: [], metadata: {}
|
||||
});
|
||||
const project = pm.loadProject('/manuscripts/novel');
|
||||
expect(project.title).toBe('Test');
|
||||
});
|
||||
|
||||
test('loadProject returns null if no project file', () => {
|
||||
expect(pm.loadProject('/nonexistent')).toBeNull();
|
||||
});
|
||||
|
||||
test('addChapter appends chapter and saves', () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||
chapters: [], metadata: {}
|
||||
});
|
||||
pm.addChapter('/manuscripts/novel', { file: '01-chapter.md', title: 'Chapter One', status: 'draft' });
|
||||
const parsed = JSON.parse(files['/manuscripts/novel/.project.json']);
|
||||
expect(parsed.chapters.length).toBe(1);
|
||||
expect(parsed.chapters[0].title).toBe('Chapter One');
|
||||
});
|
||||
|
||||
test('compileManuscript concatenates chapter files', () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||
chapters: [
|
||||
{ file: '01.md', title: 'One', status: 'draft' },
|
||||
{ file: '02.md', title: 'Two', status: 'draft' }
|
||||
], metadata: {}
|
||||
});
|
||||
files['/manuscripts/novel/01.md'] = 'First chapter content.';
|
||||
files['/manuscripts/novel/02.md'] = 'Second chapter content.';
|
||||
const result = pm.compileManuscript('/manuscripts/novel');
|
||||
expect(result).toBe('First chapter content.\n\n---\n\nSecond chapter content.');
|
||||
});
|
||||
|
||||
test('compileManuscript skips missing files', () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||
chapters: [
|
||||
{ file: '01.md', title: 'One', status: 'draft' },
|
||||
{ file: '02.md', title: 'Two', status: 'draft' }
|
||||
], metadata: {}
|
||||
});
|
||||
files['/manuscripts/novel/01.md'] = 'Only chapter one.';
|
||||
const result = pm.compileManuscript('/manuscripts/novel');
|
||||
expect(result).toBe('Only chapter one.');
|
||||
});
|
||||
|
||||
test('getStats returns total word count across chapters', () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||
chapters: [
|
||||
{ file: '01.md', title: 'One', status: 'draft' },
|
||||
{ file: '02.md', title: 'Two', status: 'draft' }
|
||||
], metadata: {}
|
||||
});
|
||||
files['/manuscripts/novel/01.md'] = 'word '.repeat(100).trim();
|
||||
files['/manuscripts/novel/02.md'] = 'more '.repeat(50).trim();
|
||||
const stats = pm.getStats('/manuscripts/novel');
|
||||
expect(stats.totalWords).toBeGreaterThan(0);
|
||||
expect(stats.chapterCount).toBe(2);
|
||||
expect(stats.targetWords).toBe(50000);
|
||||
expect(stats.pctComplete).toBeDefined();
|
||||
});
|
||||
|
||||
test('updateChapter modifies a chapter by index', () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||
chapters: [{ file: '01.md', title: 'Old Title', status: 'draft' }],
|
||||
metadata: {}
|
||||
});
|
||||
pm.updateChapter('/manuscripts/novel', 0, { title: 'New Title', status: 'revised' });
|
||||
const parsed = JSON.parse(files['/manuscripts/novel/.project.json']);
|
||||
expect(parsed.chapters[0].title).toBe('New Title');
|
||||
expect(parsed.chapters[0].status).toBe('revised');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
const { SettingsStore } = require('../src/plugins/settings-store');
|
||||
|
||||
describe('SettingsStore', () => {
|
||||
let store;
|
||||
let data;
|
||||
|
||||
beforeEach(() => {
|
||||
data = {};
|
||||
store = new SettingsStore({
|
||||
get: (key) => data[key],
|
||||
set: (key, value) => { data[key] = value; }
|
||||
});
|
||||
});
|
||||
|
||||
test('get returns value for full key', () => {
|
||||
data['plugins.my-plugin.myKey'] = 'myValue';
|
||||
expect(store.get('plugins.my-plugin.myKey')).toBe('myValue');
|
||||
});
|
||||
|
||||
test('get returns undefined for missing key', () => {
|
||||
expect(store.get('plugins.my-plugin.missing')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('set stores value', () => {
|
||||
store.set('plugins.my-plugin.myKey', 42);
|
||||
expect(data['plugins.my-plugin.myKey']).toBe(42);
|
||||
});
|
||||
|
||||
test('set overwrites existing value', () => {
|
||||
data['plugins.my-plugin.myKey'] = 'old';
|
||||
store.set('plugins.my-plugin.myKey', 'new');
|
||||
expect(data['plugins.my-plugin.myKey']).toBe('new');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
const { SnapshotManager } = require('../src/plugins/built-in/writing-studio/snapshot-manager');
|
||||
|
||||
describe('SnapshotManager', () => {
|
||||
let manager;
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = {};
|
||||
manager = new SnapshotManager({
|
||||
get: (key) => store[key],
|
||||
set: (key, value) => { store[key] = value; }
|
||||
});
|
||||
});
|
||||
|
||||
test('create stores snapshot with timestamp, content, wordCount', () => {
|
||||
const snap = manager.create('Hello world this is a test', 'auto');
|
||||
expect(snap).toHaveProperty('id');
|
||||
expect(snap.content).toBe('Hello world this is a test');
|
||||
expect(snap.wordCount).toBe(6);
|
||||
expect(snap.label).toBe('auto');
|
||||
});
|
||||
|
||||
test('list returns snapshots ordered newest first', () => {
|
||||
manager.create('first', 'auto');
|
||||
manager.create('second', 'auto');
|
||||
const list = manager.list();
|
||||
expect(list.length).toBe(2);
|
||||
expect(list[0].content).toBe('second');
|
||||
});
|
||||
|
||||
test('getById returns specific snapshot', () => {
|
||||
const snap = manager.create('find me', 'manual');
|
||||
const found = manager.getById(snap.id);
|
||||
expect(found.content).toBe('find me');
|
||||
});
|
||||
|
||||
test('getById returns null for missing id', () => {
|
||||
expect(manager.getById('nope')).toBeNull();
|
||||
});
|
||||
|
||||
test('restore returns content of snapshot', () => {
|
||||
const snap = manager.create('restore this', 'manual');
|
||||
expect(manager.restore(snap.id)).toBe('restore this');
|
||||
});
|
||||
|
||||
test('restore throws for missing snapshot', () => {
|
||||
expect(() => manager.restore('nope')).toThrow('Snapshot not found');
|
||||
});
|
||||
|
||||
test('delete removes a snapshot', () => {
|
||||
const snap = manager.create('delete me', 'auto');
|
||||
manager.delete(snap.id);
|
||||
expect(manager.getById(snap.id)).toBeNull();
|
||||
});
|
||||
|
||||
test('diff returns added/removed line counts', () => {
|
||||
const snap = manager.create('line one\nline two\nline three', 'auto');
|
||||
const result = manager.diff(snap.id, 'line one\nline modified\nline three\nline four');
|
||||
expect(result.added).toBe(2);
|
||||
expect(result.removed).toBe(1);
|
||||
});
|
||||
|
||||
test('diff throws for missing snapshot', () => {
|
||||
expect(() => manager.diff('nope', 'new content')).toThrow('Snapshot not found');
|
||||
});
|
||||
|
||||
test('prune keeps only the N most recent snapshots', () => {
|
||||
for (let i = 0; i < 10; i++) manager.create('snap ' + i, 'auto');
|
||||
manager.prune(5);
|
||||
expect(manager.list().length).toBe(5);
|
||||
expect(manager.list()[0].content).toBe('snap 9');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
const { SprintEngine } = require('../src/plugins/built-in/writing-studio/sprint-engine');
|
||||
|
||||
describe('SprintEngine', () => {
|
||||
let engine;
|
||||
let events;
|
||||
|
||||
beforeEach(() => {
|
||||
events = [];
|
||||
engine = new SprintEngine({
|
||||
onEvent: (name, data) => events.push({ name, data })
|
||||
});
|
||||
});
|
||||
|
||||
test('starts a sprint with duration and initial word count', () => {
|
||||
engine.start(25, 100);
|
||||
expect(engine.isActive()).toBe(true);
|
||||
expect(engine.getRemaining()).toBeLessThanOrEqual(25 * 60 * 1000);
|
||||
});
|
||||
|
||||
test('start throws if sprint already active', () => {
|
||||
engine.start(25, 100);
|
||||
expect(() => engine.start(10, 50)).toThrow('Sprint already active');
|
||||
});
|
||||
|
||||
test('stop calculates WPM from word delta', () => {
|
||||
engine.start(25, 100);
|
||||
const result = engine.stop(350); // 250 words added
|
||||
expect(result.wordDelta).toBe(250);
|
||||
expect(engine.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
test('stop throws if no active sprint', () => {
|
||||
expect(() => engine.stop(100)).toThrow('No active sprint');
|
||||
});
|
||||
|
||||
test('tick emits progress event with remaining time', () => {
|
||||
engine.start(25, 100);
|
||||
engine.tick(200);
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0].name).toBe('sprint:tick');
|
||||
expect(events[0].data.remaining).toBeDefined();
|
||||
expect(events[0].data.elapsed).toBe(200);
|
||||
});
|
||||
|
||||
test('tick auto-stops when time expires and emits sprint:complete', () => {
|
||||
engine.start(1, 100); // 1 minute
|
||||
engine.tick(60 * 1000 + 1); // just past
|
||||
expect(events.some(e => e.name === 'sprint:complete')).toBe(true);
|
||||
expect(engine.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
test('getRemaining returns 0 when no sprint active', () => {
|
||||
expect(engine.getRemaining()).toBe(0);
|
||||
});
|
||||
|
||||
test('getStats returns elapsed, WPM, wordDelta after stop', () => {
|
||||
engine.start(25, 100);
|
||||
const result = engine.stop(350);
|
||||
expect(result).toHaveProperty('wordDelta', 250);
|
||||
expect(result).toHaveProperty('elapsed');
|
||||
expect(result).toHaveProperty('wpm');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user