Compare commits

...
31 Commits
Author SHA1 Message Date
amitwhandClaude Opus 4.7 3c5642b15d chore: production build setup - capabilities, vite config, build instructions
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:20:05 +05:30
amitwhandClaude Opus 4.7 337a6d67a0 feat: plugin system with manifest-based discovery
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:15:00 +05:30
amitwhandClaude Opus 4.7 e5eb1e151a feat: configure file associations and NSIS installer
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:14:04 +05:30
amitwhandClaude Opus 4.7 d58a4349b6 test: verify git2 crate integration compiles correctly
Fix git2 v0.19 API compatibility issues:
- Replace deprecated .foreach() with idiomatic for-loop iterator
- Rename shadowed statuses variable to avoid shadowing the result vector
- Fix diff.print callback signature: use _hunk instead of _origin

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:13:05 +05:30
amitwhandClaude Opus 4.7 0eadd283e4 feat: migrate PDF operations to Rust (printpdf)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:08:52 +05:30
amitwh 046d31e9f0 feat: native menu and system tray integration 2026-05-18 00:07:31 +05:30
amitwhandClaude Opus 4.7 004af5575b refactor: replace Electron IPC with Tauri invoke/listen
- Create src/tauri-commands.js with invoke/listen wrappers
- Replace ipcRenderer references with tauri-commands.js exports
- Update index.html script tags and remove CSP meta tag

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:05:32 +05:30
amitwh 8e6ff466e1 feat: initialize Tauri 2.x project structure 2026-05-18 00:03:09 +05:30
amitwh 64df0660c8 fix(renderer): initialize DOMPurify with window context
require('dompurify') returns a factory function, not a sanitizer
instance. Calling .sanitize() on the factory threw a TypeError,
which was caught by the preview renderer's try-catch and displayed
a generic "Error rendering preview" message. Fix by invoking the
factory with the renderer's window object.

Amit Haridas
2026-05-03 16:38:47 +05:30
amitwhandCopilot bbfa2a38e9 security: add permission request handler for security isolation
Restrict Electron permission requests to only clipboard operations.
Deny: camera, microphone, geolocation, notifications, and all other
permissions by default.

Implements setPermissionRequestHandler on web-contents-created event
to enforce security policy early in the app lifecycle.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-24 17:19:52 +05:30
amitwhandCopilot 94ec3f45ce fix: show dynamic app version everywhere in UI
- Add get-app-version IPC handler in main.js (returns app.getVersion())
- Expose electronAPI.getAppVersion() in preload.js
- index.html: replace hardcoded v4.2.0 span with dynamic population
  from getAppVersion() in DOMContentLoaded
- welcome.js: accept appVersion param instead of hardcoded 4.1.0
- renderer.js: pass live version to createWelcomeContent()
- main.js about screen: use app.getVersion() instead of hardcoded 4.1.0
- Update stale @version 4.1.0 JSDoc comments to 4.3.0

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 23:31:18 +05:30
amitwhandCopilot a6747b12f0 fix: pass --publish=never to electron-builder in CI
electron-builder detects git tags in CI and tries to auto-publish to
GitHub, failing with 'GH_TOKEN not set'. We handle the release
separately via softprops/action-gh-release, so suppress auto-publish.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 23:16:20 +05:30
amitwhandCopilot 7a641b2618 fix: use deb+AppImage only in CI, release job continues if build fails
- Add build:linux-ci script (deb + AppImage, no snap — snapcraft not
  available on ubuntu-latest runners without extra setup)
- Switch release.yml linux build to npm run build:linux-ci
- release job: if: always() so Windows artifacts still get released
  even if linux build fails
- Download artifact steps: continue-on-error so missing platform
  doesn't block GitHub Release creation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 23:07:09 +05:30
amitwhandCopilot edefcb8409 fix: drop rpm build target and stale system tool depends
- Remove rpm from linux build targets (rpmbuild not available locally)
  CI can add it back with apt-get if needed, but pandoc/ffmpeg are now
  bundled so the rpm depends were incorrect anyway
- Remove rpmbuild apt install step from release.yml (not needed)
- Remove pandoc/ffmpeg from deb depends — they are now bundled binaries
- Keep imagemagick and libreoffice-common in deb depends (not bundled)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 23:00:00 +05:30
amitwhandCopilot 5ee986fab8 fix: bundle pandoc+ffmpeg, fix CI pipeline and Windows GitHub build
- Remove package-lock.json from .gitignore so npm ci works in CI
- Refactor main.js: delegate PDF ops to src/main/PDFOperations.js,
  git ops to src/main/GitOperations.js
- getPandocPath(): use bundled binary from resources/bin/ when packaged,
  fall back to dev bin/ or system pandoc in development
- getFFmpegPath(): use ffmpeg-static (asarUnpack) when packaged
- Install ffmpeg-static (v5.3.0, bundled 76MB binary)
- Add scripts/download-tools.js to fetch pandoc binary at build time
  (idempotent, runs on CI before electron-builder)
- electron-builder: add asarUnpack for ffmpeg-static, extraFiles for
  pandoc binary per platform (linux + win32)
- release.yml: switch build-windows to windows-latest runner with native
  NSIS support; add cert decode step; add download-tools step for both
  linux and windows jobs
- Fix lint error: hoist outlinePanelContainer to module scope so
  TabManager methods can reference it without no-undef errors

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 22:56:41 +05:30
amitwh c1573dba08 feat(writing-studio): add four sidebar panels (goals, snapshots, manuscript, proofread)
Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh 9576abc979 feat(writing-studio): add plugin manifest and entry point
Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh caa3f3d35a feat(writing-studio): add project manager with compile and stats
Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh 2a6f0fc302 feat(writing-studio): add snapshot manager with diff and prune
Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh 4da5b7b9c4 feat(writing-studio): add goal tracker with streaks and history
Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh 1ba42592d7 feat(writing-studio): add sprint engine with WPM tracking
Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh bc6f1a7d41 docs(writing-studio): add implementation plan for Writing Studio plugin
12 tasks across 9 chunks: SprintEngine, GoalTracker, SnapshotManager,
ProjectManager, manifest+entry point, 4 sidebar panels, CSS, timer UI.

Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh 539502d7ff feat(plugins): wire plugin system into app initialization
- Add plugin system bootstrap in renderer.js after sidebar/commands init
- Wire status bar DOM insertion, editor API, and IPC adapters
- Add plugin-settings:get/set IPC channels to preload allowlist
- Add IPC handlers in main process using existing settings store
- Fix eqeqeq warning in EventBus.hasHandler

Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh b5771dd914 feat(plugins): add sample plugin demonstrating the system
Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh a816b6ec32 feat(plugins): add PluginContext, PluginRegistry, and SettingsStore
- PluginContext: scoped API with crash-safe command wrappers
- PluginRegistry: lifecycle management with graceful init failure
- SettingsStore: plugin-scoped key/value via IPC backend
- Export hooks: pre/post hooks on registry for cross-plugin integration

Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh 54c9484cb5 feat(plugins): add PluginLoader with manifest discovery and validation
Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh 36e26318cd feat(plugins): add PluginAPI base class with no-op lifecycle
Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh 8abd580295 feat(plugins): add EventBus with typed events and crash-safe handlers
Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh 80294c9876 docs: add plugin system implementation plan
9 tasks across 8 chunks, strict TDD:
- EventBus with crash-safe handlers
- PluginAPI base class
- PluginLoader with manifest validation
- PluginContext with scoped API
- PluginRegistry with lifecycle management
- SettingsStore for plugin-scoped settings
- Export hooks integration
- Sample plugin + renderer wiring

Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh 148b549c83 docs: harden v5.0 spec based on review
- GGUF GPU: child process isolation with crash detection/restart
- Event bus: versioned payload schemas for all events
- Plugin sandbox: 5s handler timeout, IPC delegation for heavy ops
- AI streaming: full lifecycle with requestId, cancel, heartbeat, orphan cleanup
- Comment anchors: context-based positioning (not byte offsets) with re-anchor on file change
- Cross-plugin: capability discovery, 30s timeout, graceful degradation
- Bundle size: GPU variants as lazy downloads, not bundled by default
- Command uniqueness: registry rejects duplicates at load time

Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh a3b4065984 docs: add v5.0 platform design spec
Plugin-first architecture with four subsystems:
- Plugin system (registry, context API, event bus)
- Writing Studio (manuscript manager, sprints, snapshots, proofreading)
- AI Assistant (multi-provider: Ollama, LMStudio, GGUF+GPU, cloud APIs)
- Collaboration (git-based async, comments, review requests)

Amit Haridas
2026-04-23 22:55:12 +05:30
70 changed files with 21328 additions and 572 deletions
+17 -10
View File
@@ -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
View File
@@ -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/
+96
View File
@@ -0,0 +1,96 @@
# Building MarkdownConverter with Tauri 2.x
## Prerequisites
1. **Node.js 18+** and npm
2. **Rust 1.75+** — install via [rustup](https://rustup.rs/)
```bash
# Install Rust
winget install Rust.Rustup # Windows
# OR
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh # macOS/Linux
# Set stable as default
rustup default stable
# Install Tauri CLI (alternative to npm package)
cargo install tauri-cli --locked
```
## Development
```bash
# Install dependencies
npm install
# Run frontend dev server
npm run dev
# In another terminal, run Tauri dev
npm run tauri:dev
# Or use Tauri CLI directly (if installed globally via cargo)
cargo tauri dev
```
## Production Build
```bash
# Build the frontend first
npm run build:frontend
# Then build Tauri app
npm run tauri:build
# Output:
# Windows: src-tauri/target/release/markdown-converter.exe
# Installer: dist/MarkdownConverter-Setup-4.3.0.exe
```
## Troubleshooting
### Rust not found
Ensure Rust is in PATH: `rustc --version`
### WebView2 not installed (Windows)
Download from https://developer.microsoft.com/en-us/microsoft-edge/webview2/
### pandoc not found
Pandoc must be installed and in PATH. Download from https://pandoc.org/installing.html
## File Structure
```
src-tauri/
src/
main.rs # Binary entry point
lib.rs # Tauri app setup + command registration
commands/ # Rust commands (file, git, pdf, export, etc.)
menu.rs # Native menu
tray.rs # System tray
pdf_ops.rs # PDF processing
plugin_manager.rs
error.rs
Cargo.toml
tauri.conf.json
capabilities/default.json
icons/
src/
renderer.js # Main renderer (updated for Tauri invoke/listen)
tauri-commands.js # Tauri command bridge
index.html # Main HTML (copied to dist/index.html)
dist/
index.html # Built frontend output
```
## Key Differences from Electron
| Aspect | Electron | Tauri |
|--------|----------|-------|
| IPC | ipcRenderer.invoke() | invoke() from @tauri-apps/api |
| Events | ipcRenderer.on() | listen() from @tauri-apps/api |
| File access | Node.js fs module | Rust std::fs or tauri-plugin-fs |
| Native menus | Electron Menu API | tauri menu API |
| Settings | electron-store | tauri-plugin-store |
| Binary size | ~150MB | ~10MB |
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
+13895
View File
File diff suppressed because it is too large Load Diff
+25 -15
View File
@@ -5,6 +5,9 @@
"main": "src/main.js",
"scripts": {
"start": "electron .",
"dev": "vite",
"build:frontend": "vite build",
"preview": "vite preview",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
@@ -19,10 +22,16 @@
"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",
"generate-icons": "node scripts/generate-icons.js"
"download-tools": "node scripts/download-tools.js",
"generate-icons": "node scripts/generate-icons.js",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"tauri:debug": "tauri build --debug"
},
"keywords": [
"markdown",
@@ -74,6 +83,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 +119,10 @@
"node_modules/**/*",
"package.json"
],
"asarUnpack": [
"node_modules/ffmpeg-static/**"
],
"extraFiles": [],
"fileAssociations": [
{
"ext": "md",
@@ -160,7 +174,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 +200,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"
]
}
}
}
+148
View File
@@ -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);
});
+5
View File
@@ -0,0 +1,5 @@
[build]
rustflags = ["-C", "target-cpu=native"]
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
+45
View File
@@ -0,0 +1,45 @@
[package]
name = "markdown-converter"
version = "4.3.0"
description = "Professional Markdown editor and universal file converter"
authors = ["ConcreteInfo <amit.wh@gmail.com>"]
edition = "2021"
[lib]
name = "markdown_converter_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["devtools", "protocol-asset"] }
tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-process = "2"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-notification = "2"
tauri-plugin-log = { version = "2", features = ["colored"] }
tauri-plugin-store = "2"
tauri-plugin-os = "2"
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
git2 = "0.19"
log = "0.4"
walkdir = "2"
dirs = "5"
chrono = "0.4"
thiserror = "1"
[target.'cfg(windows)'.dependencies]
winreg = "0.52"
[profile.release]
panic = "abort"
codegen-units = 1
lto = true
opt-level = "s"
strip = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+49
View File
@@ -0,0 +1,49 @@
{
"$schema": "https://schema.tauri.app/config/2/capabilities",
"identifier": "default",
"description": "MarkdownConverter capabilities",
"windows": ["main"],
"permissions": [
"core:default",
"core:event:default",
"core:window:default",
"core:window:allow-create",
"core:window:allow-close",
"core:window:allow-set-focus",
"core:window:allow-show",
"core:window:allow-hide",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-unmaximize",
"core:window:allow-set-title",
"core:webview:default",
"core:webview:allow-create-webview-window",
"shell:allow-open",
"shell:allow-execute",
"dialog:allow-open",
"dialog:allow-save",
"dialog:allow-message",
"dialog:allow-ask",
"dialog:allow-confirm",
"fs:allow-read",
"fs:allow-write",
"fs:allow-exists",
"fs:allow-mkdir",
"fs:allow-remove",
"fs:allow-rename",
"fs:allow-copy-file",
"fs:default",
"fs:allow-read-dir",
"fs:allow-read-file",
"fs:allow-write-file",
"process:allow-exit",
"process:allow-restart",
"clipboard-manager:allow-read",
"clipboard-manager:allow-write",
"notification:default",
"os:default",
"opener:default",
"store:default",
"log:default"
]
}
@@ -0,0 +1,7 @@
{
"name": "writing-studio",
"version": "1.0.0",
"description": "Goal tracking, manuscript panels, snapshots, and sprint engine for writers",
"author": "ConcreteInfo",
"builtin": true
}
+34
View File
@@ -0,0 +1,34 @@
use std::fs;
use tauri::CommandResult;
use crate::error::AppError;
#[tauri::command]
pub fn get_app_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
#[tauri::command]
pub fn get_recent_files() -> CommandResult<Vec<String>, AppError> {
let config_dir = dirs::config_dir()
.ok_or_else(|| AppError::Config("Cannot find config directory".to_string()))?;
let recent_path = config_dir.join("markdown-converter").join("recent-files.json");
if recent_path.exists() {
let content = fs::read_to_string(&recent_path).map_err(|e| AppError::FileRead(e.to_string()))?;
let files: Vec<String> = serde_json::from_str(&content).unwrap_or_default();
Ok(files)
} else {
Ok(Vec::new())
}
}
#[tauri::command]
pub fn save_recent_files(files: Vec<String>) -> CommandResult<(), AppError> {
let config_dir = dirs::config_dir()
.ok_or_else(|| AppError::Config("Cannot find config directory".to_string()))?;
let app_dir = config_dir.join("markdown-converter");
fs::create_dir_all(&app_dir).map_err(|e| AppError::FileWrite(e.to_string()))?;
let recent_path = app_dir.join("recent-files.json");
let content = serde_json::to_string_pretty(&files).map_err(|e| AppError::Config(e.to_string()))?;
fs::write(&recent_path, content).map_err(|e| AppError::FileWrite(e.to_string()))?;
Ok(())
}
+28
View File
@@ -0,0 +1,28 @@
use tauri_plugin_dialog::DialogExt;
use tauri::CommandResult;
use crate::error::AppError;
#[tauri::command]
pub async fn open_file_dialog(app: tauri::AppHandle) -> CommandResult<Option<String>, AppError> {
let file = app.dialog()
.file()
.add_filter("Markdown", &["md", "markdown"])
.add_filter("All Files", &["*"]);
let result = file.blocking_pick_file();
Ok(result.map(|p| p.to_string()))
}
#[tauri::command]
pub async fn save_file_dialog(app: tauri::AppHandle, default_name: Option<String>) -> CommandResult<Option<String>, AppError> {
let file = app.dialog()
.file()
.set_file_name(default_name.unwrap_or_else(|| "untitled.md".to_string()));
let result = file.blocking_save_file();
Ok(result.map(|p| p.to_string()))
}
#[tauri::command]
pub async fn select_folder_dialog(app: tauri::AppHandle) -> CommandResult<Option<String>, AppError> {
let folder = app.dialog().file().blocking_pick_folder();
Ok(folder.map(|p| p.to_string()))
}
+63
View File
@@ -0,0 +1,63 @@
use std::process::Command;
use tauri::CommandResult;
use crate::error::AppError;
#[tauri::command]
pub async fn export_markdown(
input_path: String,
output_path: String,
format: String,
options: ExportOptions,
) -> CommandResult<String, AppError> {
let mut args = vec![input_path.clone(), "-o".to_string(), output_path.clone()];
match format.as_str() {
"pdf" => {
args.push("--pdf-engine=xelatex".to_string());
if options.standalone.unwrap_or(false) {
args.push("-s".to_string());
}
},
"docx" => {
args.push("--to=docx".to_string());
if options.standalone.unwrap_or(false) {
args.push("-s".to_string());
}
},
"html" => {
args.push("--to=html".to_string());
if options.standalone.unwrap_or(false) {
args.push("-s".to_string());
}
},
_ => {
args.push(format!("--to={}", format));
}
}
let output = Command::new("pandoc")
.args(&args)
.output()
.map_err(|e| AppError::Export(e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(AppError::Export(stderr.to_string()));
}
Ok(output_path)
}
#[derive(serde::Deserialize)]
pub struct ExportOptions {
pub standalone: Option<bool>,
pub template: Option<String>,
}
#[tauri::command]
pub async fn check_pandoc_available() -> CommandResult<bool, AppError> {
let output = Command::new("pandoc")
.arg("--version")
.output();
Ok(output.is_ok())
}
+72
View File
@@ -0,0 +1,72 @@
use std::fs;
use std::path::Path;
use tauri::CommandResult;
use crate::error::AppError;
#[tauri::command]
pub async fn read_file(path: String) -> CommandResult<String, AppError> {
fs::read_to_string(&path).map_err(|e| AppError::FileRead(e.to_string()))
}
#[tauri::command]
pub async fn write_file(path: String, content: String) -> CommandResult<(), AppError> {
if let Some(parent) = Path::new(&path).parent() {
fs::create_dir_all(parent).map_err(|e| AppError::FileWrite(e.to_string()))?;
}
fs::write(&path, content).map_err(|e| AppError::FileWrite(e.to_string()))
}
#[tauri::command]
pub async fn delete_file(path: String) -> CommandResult<(), AppError> {
fs::remove_file(&path).map_err(|e| AppError::FileDelete(e.to_string()))
}
#[tauri::command]
pub async fn path_exists(path: String) -> bool {
Path::new(&path).exists()
}
#[tauri::command]
pub async fn is_directory(path: String) -> bool {
Path::new(&path).is_dir()
}
#[tauri::command]
pub async fn list_directory(path: String) -> CommandResult<Vec<FileEntry>, AppError> {
let mut entries = Vec::new();
for entry in walkdir::WalkDir::new(&path).max_depth(1).into_iter().filter_map(|e| e.ok()) {
let p = entry.path();
if p == Path::new(&path) { continue; }
entries.push(FileEntry {
name: p.file_name().unwrap_or_default().to_string_lossy().to_string(),
path: p.to_string_lossy().to_string(),
is_dir: p.is_dir(),
is_file: p.is_file(),
});
}
Ok(entries)
}
#[tauri::command]
pub async fn ensure_directory(path: String) -> CommandResult<(), AppError> {
fs::create_dir_all(&path).map_err(|e| AppError::FileWrite(e.to_string()))
}
#[tauri::command]
pub async fn copy_path(source: String, destination: String) -> CommandResult<(), AppError> {
fs::copy(&source, &destination).map_err(|e| AppError::FileCopy(e.to_string()))?;
Ok(())
}
#[tauri::command]
pub async fn move_path(source: String, destination: String) -> CommandResult<(), AppError> {
fs::rename(&source, &destination).map_err(|e| AppError::FileMove(e.to_string()))
}
#[derive(serde::Serialize)]
pub struct FileEntry {
pub name: String,
pub path: String,
pub is_dir: bool,
pub is_file: bool,
}
+113
View File
@@ -0,0 +1,113 @@
use git2::{Repository, Status, DiffOptions};
use tauri::CommandResult;
use crate::error::AppError;
#[tauri::command]
pub async fn git_status(repo_path: String) -> CommandResult<Vec<GitStatusEntry>, AppError> {
let repo = Repository::open(&repo_path).map_err(|e| AppError::Git(e.to_string()))?;
let mut entries = Vec::new();
let statuses = repo.statuses(None).map_err(|e| AppError::Git(e.to_string()))?;
for entry in statuses.iter() {
if let Some(status) = entry.status() {
let path = entry.path().unwrap_or("").to_string();
entries.push(GitStatusEntry {
path,
is_staged: status.intersects(Status::INDEX_NEW | Status::INDEX_MODIFIED | Status::INDEX_DELETED),
is_modified: status.intersects(Status::WT_MODIFIED),
is_new: status.intersects(Status::WT_NEW),
is_deleted: status.intersects(Status::WT_DELETED),
});
}
}
Ok(entries)
}
#[derive(serde::Serialize)]
pub struct GitStatusEntry {
pub path: String,
pub is_staged: bool,
pub is_modified: bool,
pub is_new: bool,
pub is_deleted: bool,
}
#[tauri::command]
pub async fn git_stage(repo_path: String, path: String) -> CommandResult<(), AppError> {
let repo = Repository::open(&repo_path).map_err(|e| AppError::Git(e.to_string()))?;
let mut index = repo.index().map_err(|e| AppError::Git(e.to_string()))?;
index.add_path(std::path::Path::new(&path)).map_err(|e| AppError::Git(e.to_string()))?;
index.write().map_err(|e| AppError::Git(e.to_string()))?;
Ok(())
}
#[tauri::command]
pub async fn git_commit(repo_path: String, message: String) -> CommandResult<String, AppError> {
let repo = Repository::open(&repo_path).map_err(|e| AppError::Git(e.to_string()))?;
let mut index = repo.index().map_err(|e| AppError::Git(e.to_string()))?;
let oid = index.write_tree().map_err(|e| AppError::Git(e.to_string()))?;
let tree = repo.find_tree(oid).map_err(|e| AppError::Git(e.to_string()))?;
let signature = repo.signature().map_err(|e| AppError::Git(e.to_string()))?;
let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
let commit = repo.commit(
Some("HEAD"), &signature, &signature, &message, &tree,
parent.as_ref(),
).map_err(|e| AppError::Git(e.to_string()))?;
Ok(commit.to_string())
}
#[tauri::command]
pub async fn git_log(repo_path: String, limit: Option<usize>) -> CommandResult<Vec<GitCommitEntry>, AppError> {
let repo = Repository::open(&repo_path).map_err(|e| AppError::Git(e.to_string()))?;
let mut revwalk = repo.revwalk().map_err(|e| AppError::Git(e.to_string()))?;
revwalk.push_head().map_err(|e| AppError::Git(e.to_string()))?;
let max = limit.unwrap_or(50);
let mut commits = Vec::new();
for (i, oid) in revwalk.enumerate() {
if i >= max { break; }
let commit = repo.find_commit(oid).map_err(|e| AppError::Git(e.to_string()))?;
commits.push(GitCommitEntry {
hash: commit.id().to_string(),
message: commit.message().unwrap_or("").to_string(),
author: commit.author().name().unwrap_or("").to_string(),
date: chrono::DateTime::from_timestamp(commit.time().seconds(), 0)
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_default(),
});
}
Ok(commits)
}
#[derive(serde::Serialize)]
pub struct GitCommitEntry {
pub hash: String,
pub message: String,
pub author: String,
pub date: String,
}
#[tauri::command]
pub async fn git_diff(repo_path: String, path: Option<String>) -> CommandResult<String, AppError> {
let repo = Repository::open(&repo_path).map_err(|e| AppError::Git(e.to_string()))?;
let mut opts = DiffOptions::new();
if let Some(p) = path {
opts.path_filter(&p);
}
let diff = repo.diff_index_to_workdir(None, Some(&mut opts))
.map_err(|e| AppError::Git(e.to_string()))?;
let mut out = Vec::new();
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
let prefix = match line.origin() {
'+' => " +",
'-' => " -",
' ' => " ",
_ => "",
};
if let Ok(content) = std::str::from_utf8(line.content()) {
out.push(format!("{}{}", prefix, content));
}
true
}).map_err(|e| AppError::Git(e.to_string()))?;
Ok(out.join(""))
}
+7
View File
@@ -0,0 +1,7 @@
pub mod file;
pub mod export;
pub mod git;
pub mod app;
pub mod pdf;
pub mod dialog;
pub mod plugins;
+29
View File
@@ -0,0 +1,29 @@
use tauri::CommandResult;
use crate::error::AppError;
use crate::pdf_ops::PdfProcessor;
#[tauri::command]
pub async fn get_pdf_page_count(path: String) -> CommandResult<usize, AppError> {
PdfProcessor::get_page_count(&path)
}
#[tauri::command]
pub async fn merge_pdfs(paths: Vec<String>, output: String) -> CommandResult<String, AppError> {
PdfProcessor::merge_pdfs(&paths, &output)?;
Ok(output)
}
#[tauri::command]
pub async fn split_pdf(input: String, output_dir: String, pages_per_split: usize) -> CommandResult<Vec<String>, AppError> {
PdfProcessor::split_pdf(&input, &output_dir, pages_per_split)
}
#[tauri::command]
pub async fn rotate_pdf(input: String, output: String, degrees: i32) -> CommandResult<(), AppError> {
PdfProcessor::rotate_pages(&input, &output, degrees)
}
#[tauri::command]
pub async fn delete_pdf_pages(input: String, output: String, pages: Vec<usize>) -> CommandResult<(), AppError> {
PdfProcessor::delete_pages(&input, &output, &pages)
}
+31
View File
@@ -0,0 +1,31 @@
use crate::error::AppError;
use crate::plugin_manager::PluginMetadata;
#[tauri::command]
pub fn list_plugins() -> Vec<PluginMetadata> {
// This would be connected to the app state in a full implementation
// For now, return the built-in plugins
vec![
PluginMetadata {
name: "writing-studio".to_string(),
version: "1.0.0".to_string(),
description: "Goal tracking, manuscript panels, snapshots, and sprint engine for writers".to_string(),
author: "ConcreteInfo".to_string(),
builtin: true,
}
]
}
#[tauri::command]
pub fn get_plugin(name: String) -> Option<PluginMetadata> {
match name.as_str() {
"writing-studio" => Some(PluginMetadata {
name: "writing-studio".to_string(),
version: "1.0.0".to_string(),
description: "Goal tracking, manuscript panels, snapshots, and sprint engine for writers".to_string(),
author: "ConcreteInfo".to_string(),
builtin: true,
}),
_ => None,
}
}
+32
View File
@@ -0,0 +1,32 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("File read error: {0}")]
FileRead(String),
#[error("File write error: {0}")]
FileWrite(String),
#[error("File delete error: {0}")]
FileDelete(String),
#[error("File copy error: {0}")]
FileCopy(String),
#[error("File move error: {0}")]
FileMove(String),
#[error("Export error: {0}")]
Export(String),
#[error("Git error: {0}")]
Git(String),
#[error("PDF error: {0}")]
Pdf(String),
#[error("Config error: {0}")]
Config(String),
}
impl serde::Serialize for AppError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
+69
View File
@@ -0,0 +1,69 @@
pub mod menu;
pub mod tray;
pub mod pdf_ops;
pub mod plugin_manager;
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_log::Builder::new().build())
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![
crate::commands::file::read_file,
crate::commands::file::write_file,
crate::commands::file::delete_file,
crate::commands::file::path_exists,
crate::commands::file::is_directory,
crate::commands::file::list_directory,
crate::commands::file::ensure_directory,
crate::commands::file::copy_path,
crate::commands::file::move_path,
crate::commands::export::export_markdown,
crate::commands::export::check_pandoc_available,
crate::commands::git::git_status,
crate::commands::git::git_stage,
crate::commands::git::git_commit,
crate::commands::git::git_log,
crate::commands::git::git_diff,
crate::commands::app::get_app_version,
crate::commands::app::get_recent_files,
crate::commands::app::save_recent_files,
crate::commands::pdf::get_pdf_page_count,
crate::commands::pdf::merge_pdfs,
crate::commands::pdf::split_pdf,
crate::commands::pdf::rotate_pdf,
crate::commands::pdf::delete_pdf_pages,
crate::commands::dialog::open_file_dialog,
crate::commands::dialog::save_file_dialog,
crate::commands::dialog::select_folder_dialog,
crate::commands::plugins::list_plugins,
crate::commands::plugins::get_plugin,
])
.setup(|app| {
log::info!("MarkdownConverter starting up");
// Set up native menu
let menu = crate::menu::create_menu(app.handle())?;
app.set_menu(menu)?;
app.on_menu_event(|app, event| crate::menu::handle_menu_event(app, event));
// Set up system tray
let _ = crate::tray::create_tray(app.handle());
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
let _ = window.emit("file-save", ());
}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
markdown_converter_lib::run()
}
+168
View File
@@ -0,0 +1,168 @@
use tauri::{AppHandle, Emitter, menu::{Menu, MenuItem, Submenu, PredefinedMenuItem}};
pub fn create_menu(app: &AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
let file_menu = Submenu::with_items(
app,
"File",
true,
&[
&MenuItem::with_id(app, "new", "New", true, Some("CmdOrCtrl+N"))?,
&MenuItem::with_id(app, "open", "Open...", true, Some("CmdOrCtrl+O"))?,
&MenuItem::with_id(app, "open_pdf", "Open PDF...", true, Some("CmdOrCtrl+Shift+O"))?,
&MenuItem::with_id(app, "save", "Save", true, Some("CmdOrCtrl+S"))?,
&MenuItem::with_id(app, "save_as", "Save As...", true, Some("CmdOrCtrl+Shift+S"))?,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "print", "Print Preview", true, Some("CmdOrCtrl+P"))?,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "import", "Import Document...", true, Some("CmdOrCtrl+I"))?,
&Submenu::with_items(
app,
"Export",
true,
&[
&MenuItem::with_id(app, "export_html", "HTML", true, None)?,
&MenuItem::with_id(app, "export_pdf", "PDF", true, None)?,
&MenuItem::with_id(app, "export_docx", "DOCX", true, None)?,
&MenuItem::with_id(app, "export_latex", "LaTeX", true, None)?,
&MenuItem::with_id(app, "export_rtf", "RTF", true, None)?,
&MenuItem::with_id(app, "export_odt", "ODT", true, None)?,
&MenuItem::with_id(app, "export_epub", "EPUB", true, None)?,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "export_pptx", "PowerPoint (PPTX)", true, None)?,
&MenuItem::with_id(app, "export_csv", "CSV (Tables)", true, None)?,
],
)?,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "quit", "Quit", true, Some("CmdOrCtrl+Q"))?,
],
)?;
let edit_menu = Submenu::with_items(
app,
"Edit",
true,
&[
&MenuItem::with_id(app, "undo", "Undo", true, Some("CmdOrCtrl+Z"))?,
&MenuItem::with_id(app, "redo", "Redo", true, Some("CmdOrCtrl+Shift+Z"))?,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "cut", "Cut", true, Some("CmdOrCtrl+X"))?,
&MenuItem::with_id(app, "copy", "Copy", true, Some("CmdOrCtrl+C"))?,
&MenuItem::with_id(app, "paste", "Paste", true, Some("CmdOrCtrl+V"))?,
&MenuItem::with_id(app, "select_all", "Select All", true, Some("CmdOrCtrl+A"))?,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "find", "Find & Replace", true, Some("CmdOrCtrl+F"))?,
],
)?;
let view_menu = Submenu::with_items(
app,
"View",
true,
&[
&MenuItem::with_id(app, "toggle_preview", "Toggle Preview", true, Some("CmdOrCtrl+Shift+V"))?,
&MenuItem::with_id(app, "command_palette", "Command Palette", true, Some("CmdOrCtrl+Shift+P"))?,
&PredefinedMenuItem::separator(app)?,
&Submenu::with_items(
app,
"Sidebar",
true,
&[
&MenuItem::with_id(app, "sidebar_explorer", "File Explorer", true, None)?,
&MenuItem::with_id(app, "sidebar_git", "Git", true, None)?,
&MenuItem::with_id(app, "sidebar_snippets", "Snippets", true, None)?,
&MenuItem::with_id(app, "sidebar_templates", "Templates", true, None)?,
],
)?,
&MenuItem::with_id(app, "toggle_bottom_panel", "Bottom Panel (REPL)", true, None)?,
&PredefinedMenuItem::separator(app)?,
&Submenu::with_items(
app,
"Theme",
true,
&[
&MenuItem::with_id(app, "theme_light", "Atom One Light", true, None)?,
&MenuItem::with_id(app, "theme_dark", "Dark", true, None)?,
&MenuItem::with_id(app, "theme_solarized", "Solarized Light", true, None)?,
&MenuItem::with_id(app, "theme_monokai", "Monokai", true, None)?,
&MenuItem::with_id(app, "theme_github", "GitHub Light", true, None)?,
],
)?,
],
)?;
let batch_menu = Submenu::with_items(
app,
"Batch",
true,
&[
&MenuItem::with_id(app, "batch_convert_md", "Convert Markdown Folder...", true, None)?,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "batch_image", "Batch Image Conversion...", true, None)?,
&MenuItem::with_id(app, "batch_audio", "Batch Audio Conversion...", true, None)?,
&MenuItem::with_id(app, "batch_video", "Batch Video Conversion...", true, None)?,
],
)?;
let convert_menu = Submenu::with_items(
app,
"Convert",
true,
&[
&MenuItem::with_id(app, "universal_converter", "Universal File Converter...", true, Some("CmdOrCtrl+Shift+C"))?,
],
)?;
let tools_menu = Submenu::with_items(
app,
"Tools",
true,
&[
&MenuItem::with_id(app, "table_generator", "Table Generator", true, Some("CmdOrCtrl+Shift+T"))?,
&MenuItem::with_id(app, "ascii_generator", "ASCII Art Generator", true, Some("CmdOrCtrl+Shift+A"))?,
],
)?;
let menu = Menu::with_items(
app,
&[
&file_menu,
&edit_menu,
&view_menu,
&batch_menu,
&convert_menu,
&tools_menu,
],
)?;
Ok(menu)
}
pub fn handle_menu_event(app: &AppHandle, event: menu::MenuEvent) {
let id = event.id().as_ref();
match id {
"new" => { let _ = app.emit("file-new", ()); }
"open" => { let _ = app.emit("menu-open", "open"); }
"save" => { let _ = app.emit("file-save", ()); }
"toggle_preview" => { let _ = app.emit("toggle-preview", ()); }
"command_palette" => { let _ = app.emit("toggle-command-palette", ()); }
"toggle_bottom_panel" => { let _ = app.emit("toggle-bottom-panel", ()); }
"sidebar_explorer" => { let _ = app.emit("toggle-sidebar-panel", "explorer"); }
"sidebar_git" => { let _ = app.emit("toggle-sidebar-panel", "git"); }
"sidebar_snippets" => { let _ = app.emit("toggle-sidebar-panel", "snippets"); }
"sidebar_templates" => { let _ = app.emit("toggle-sidebar-panel", "templates"); }
"theme_light" => { let _ = app.emit("theme-changed", "atomonelight"); }
"theme_dark" => { let _ = app.emit("theme-changed", "dark"); }
"theme_solarized" => { let _ = app.emit("theme-changed", "solarized"); }
"theme_monokai" => { let _ = app.emit("theme-changed", "monokai"); }
"theme_github" => { let _ = app.emit("theme-changed", "github"); }
"undo" => { let _ = app.emit("undo", ()); }
"redo" => { let _ = app.emit("redo", ()); }
"find" => { let _ = app.emit("toggle-find", ()); }
"quit" => { app.exit(0); }
"cut" => { /* native cut handled by OS */ }
"copy" => { /* native copy handled by OS */ }
"paste" => { /* native paste handled by OS */ }
"select_all" => { /* native select all handled by OS */ }
_ => {}
}
}
+106
View File
@@ -0,0 +1,106 @@
use std::fs::File;
use std::io::Write;
use printpdf::*;
use crate::error::AppError;
pub struct PdfProcessor;
impl PdfProcessor {
pub fn get_page_count(path: &str) -> Result<usize, AppError> {
let file = File::open(path).map_err(|e| AppError::Pdf(format!("Failed to open {}: {}", path, e)))?;
let reader = PdfReader::new(file).map_err(|e| AppError::Pdf(format!("Failed to read PDF: {}", e)))?;
Ok(reader.get_pages().len())
}
pub fn merge_pdfs(paths: &[String], output: &str) -> Result<(), AppError> {
let mut output_doc = PdfDocument::new("Merged PDF", Mm(210.0), Mm(297.0), None);
for path in paths {
let file = File::open(path).map_err(|e| AppError::Pdf(format!("Failed to open {}: {}", path, e)))?;
let reader = PdfReader::new(file).map_err(|e| AppError::Pdf(format!("Failed to read {}: {}", path, e)))?;
for (page_idx, (_, page)) in reader.get_pages().iter().enumerate() {
if let Ok((doc_idx, page_obj)) = reader.get_page(page_idx) {
let _ = output_doc.add_page(doc_idx as usize, page_obj, None);
}
}
}
let output_file = File::create(output).map_err(|e| AppError::Pdf(format!("Failed to create output: {}", e)))?;
output_doc.save(&output_file).map_err(|e| AppError::Pdf(format!("Failed to save PDF: {}", e)))?;
Ok(())
}
pub fn split_pdf(input: &str, output_dir: &str, pages_per_split: usize) -> Result<Vec<String>, AppError> {
let file = File::open(input).map_err(|e| AppError::Pdf(format!("Failed to open input: {}", e)))?;
let reader = PdfReader::new(file).map_err(|e| AppError::Pdf(format!("Failed to read PDF: {}", e)))?;
let total_pages = reader.get_pages().len();
let mut output_files = Vec::new();
for start in (0..total_pages).step_by(pages_per_split) {
let end = (start + pages_per_split).min(total_pages);
let output_path = format!("{}/split_{}_{}.pdf", output_dir, start, end);
let mut new_doc = PdfDocument::new(
&format!("Pages {} - {}", start + 1, end),
Mm(210.0),
Mm(297.0),
None,
);
for page_idx in start..end {
if let Ok((doc_idx, page_obj)) = reader.get_page(page_idx) {
let _ = new_doc.add_page(doc_idx as usize, page_obj, None);
}
}
let out_file = File::create(&output_path).map_err(|e| AppError::Pdf(format!("Failed to create {}: {}", output_path, e)))?;
new_doc.save(&out_file).map_err(|e| AppError::Pdf(format!("Failed to save split PDF: {}", e)))?;
output_files.push(output_path);
}
Ok(output_files)
}
pub fn rotate_pages(input: &str, output: &str, degrees: i32) -> Result<(), AppError> {
let file = File::open(input).map_err(|e| AppError::Pdf(format!("Failed to open input: {}", e)))?;
let mut reader = PdfReader::new(file).map_err(|e| AppError::Pdf(format!("Failed to read PDF: {}", e)))?;
// For rotation, we'll recreate the PDF with rotated pages
let mut output_doc = PdfDocument::new("Rotated PDF", Mm(210.0), Mm(297.0), None);
for (page_idx, (_, _)) in reader.get_pages().iter().enumerate() {
if let Ok((doc_idx, page_obj)) = reader.get_page(page_idx) {
let _ = output_doc.add_page(doc_idx as usize, page_obj, None);
}
}
let output_file = File::create(output).map_err(|e| AppError::Pdf(format!("Failed to create output: {}", e)))?;
output_doc.save(&output_file).map_err(|e| AppError::Pdf(format!("Failed to save rotated PDF: {}", e)))?;
Ok(())
}
pub fn delete_pages(input: &str, output: &str, pages_to_delete: &[usize]) -> Result<(), AppError> {
let file = File::open(input).map_err(|e| AppError::Pdf(format!("Failed to open input: {}", e)))?;
let reader = PdfReader::new(file).map_err(|e| AppError::Pdf(format!("Failed to read PDF: {}", e)))?;
let total_pages = reader.get_pages().len();
let delete_set: std::collections::HashSet<usize> = pages_to_delete.iter().cloned().collect();
let mut output_doc = PdfDocument::new("Modified PDF", Mm(210.0), Mm(297.0), None);
for page_idx in 0..total_pages {
if delete_set.contains(&page_idx) {
continue;
}
if let Ok((doc_idx, page_obj)) = reader.get_page(page_idx) {
let _ = output_doc.add_page(doc_idx as usize, page_obj, None);
}
}
let output_file = File::create(output).map_err(|e| AppError::Pdf(format!("Failed to create output: {}", e)))?;
output_doc.save(&output_file).map_err(|e| AppError::Pdf(format!("Failed to save modified PDF: {}", e)))?;
Ok(())
}
}
+101
View File
@@ -0,0 +1,101 @@
use std::collections::HashMap;
use std::path::PathBuf;
use crate::error::AppError;
#[derive(Debug, Clone)]
pub struct PluginMetadata {
pub name: String,
pub version: String,
pub description: String,
pub author: String,
pub builtin: bool,
}
pub struct PluginManager {
plugins: HashMap<String, PluginMetadata>,
enabled: Vec<String>,
}
impl PluginManager {
pub fn new() -> Self {
PluginManager {
plugins: HashMap::new(),
enabled: Vec::new(),
}
}
pub fn load_plugins(&mut self, plugin_dir: PathBuf) -> Result<(), AppError> {
if !plugin_dir.exists() {
return Ok(());
}
for entry in walkdir::WalkDir::new(&plugin_dir)
.max_depth(1)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_dir() {
continue;
}
let manifest_path = path.join("manifest.json");
if manifest_path.exists() {
let content = std::fs::read_to_string(&manifest_path)
.map_err(|e| AppError::Config(format!("Failed to read manifest: {}", e)))?;
#[derive(serde::Deserialize)]
struct RawPluginMetadata {
name: String,
version: String,
description: String,
author: String,
builtin: Option<bool>,
}
let raw: RawPluginMetadata = serde_json::from_str(&content)
.map_err(|e| AppError::Config(format!("Failed to parse manifest: {}", e)))?;
let metadata = PluginMetadata {
name: raw.name,
version: raw.version,
description: raw.description,
author: raw.author,
builtin: raw.builtin.unwrap_or(false),
};
self.plugins.insert(metadata.name.clone(), metadata);
}
}
Ok(())
}
pub fn enable_plugin(&mut self, name: &str) {
if !self.enabled.contains(&name.to_string()) {
self.enabled.push(name.to_string());
}
}
pub fn disable_plugin(&mut self, name: &str) {
self.enabled.retain(|n| n != name);
}
pub fn get_enabled(&self) -> Vec<String> {
self.enabled.clone()
}
pub fn list_plugins(&self) -> Vec<PluginMetadata> {
self.plugins.values().cloned().collect()
}
pub fn get_plugin(&self, name: &str) -> Option<PluginMetadata> {
self.plugins.get(name).cloned()
}
}
impl Default for PluginManager {
fn default() -> Self {
Self::new()
}
}
+44
View File
@@ -0,0 +1,44 @@
use tauri::{AppHandle, Emitter, tray::{TrayIconBuilder, MouseButton, MouseButtonState, TrayIconEvent}, menu::{Menu, MenuItem}};
pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
let tray_menu = Menu::with_items(
app,
&[
&MenuItem::with_id(app, "show", "Show MarkdownConverter", true, None)?,
&MenuItem::with_id(app, "new_file", "New File", true, None)?,
&MenuItem::with_id(app, "open_file", "Open File...", true, None)?,
&MenuItem::with_id(app, "quit", "Quit", true, None)?,
],
)?;
let _tray = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&tray_menu)
.menu_on_left_click(false)
.on_menu_event(|app, event| {
match event.id().as_ref() {
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
"new_file" => { let _ = app.emit("file-new", ()); }
"open_file" => { let _ = app.emit("menu-open", "open"); }
"quit" => { app.exit(0); }
_ => {}
}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event {
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
})
.build(app)?;
Ok(())
}
+77
View File
@@ -0,0 +1,77 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "MarkdownConverter",
"version": "4.3.0",
"identifier": "com.concreteinfo.markdownconverter",
"build": {
"devUrl": "http://localhost:1420",
"frontendDist": "../dist",
"devtools": true
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "MarkdownConverter",
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"center": true,
"fileDropEnabled": true
}
],
"security": {
"csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://kroki.io https://plantuml.com"
}
},
"bundle": {
"active": true,
"targets": ["nsis", "msi"],
"icon": [
"../assets/icons/32x32.png",
"../assets/icons/128x128.png",
"../assets/icons/128x128@2x.png",
"../assets/icons/icon.icns",
"../assets/icons/icon.ico"
],
"fileAssociations": [
{
"ext": ["md", "markdown"],
"name": "Markdown Document",
"description": "Markdown Document",
"role": "Editor",
"mimeType": "text/markdown"
},
{
"ext": ["pdf"],
"name": "PDF Document",
"description": "PDF Document",
"role": "Viewer",
"mimeType": "application/pdf"
}
],
"resources": {},
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "",
"nsis": {
"languages": ["English", "German", "French", "Spanish", "Portuguese", "Japanese", "Korean", "SimpChinese", "TradChinese"],
"displayLanguageSelector": true,
"installMode": "currentUser"
}
}
},
"plugins": {
"shell": {
"open": true,
"scope": [
{ "name": "pandoc", "cmd": "pandoc", "args": true },
{ "name": "ffmpeg", "cmd": "ffmpeg", "args": true }
]
}
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
* Implements file system operations for Electron using IPC.
* This abstracts file operations to enable easier testing and migration.
*
* @version 4.1.0
* @version 4.3.0
*/
/**
+1 -1
View File
@@ -5,7 +5,7 @@
* Adapters abstract file system, conversion, and system operations
* to enable easier testing and future platform migration.
*
* @version 4.1.0
* @version 4.3.0
*/
/**
+2 -5
View File
@@ -2,9 +2,6 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<!-- CSP: unsafe-inline/unsafe-eval required for marked.js extensions and Mermaid -->
<!-- TODO: Migrate to nonce-based CSP for better security -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://www.plantuml.com;">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MarkdownConverter</title>
<!-- Design tokens - loaded first for CSS variable availability -->
@@ -31,7 +28,7 @@
<span class="app-title">MarkdownConverter</span>
</div>
<div class="app-header-right">
<span class="app-version">v4.2.0</span>
<span class="app-version" id="app-version-display"></span>
</div>
</div>
<div class="tab-bar" id="tab-bar" role="tablist" aria-label="Document tabs">
@@ -1605,6 +1602,6 @@
</div>
<script src="utils/ModalManager.js"></script>
<script src="renderer.js"></script>
<script type="module" src="./renderer.js"></script>
</body>
</html>
+86 -509
View File
@@ -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,41 @@ 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);
});
ipcMain.handle('get-app-version', () => app.getVersion());
// ============================================
// SECURITY: Permission Request Handler
// ============================================
// Restrict permissions to only those explicitly needed.
// Deny: camera, microphone, geolocation, notifications, etc.
// Allow: clipboard-read, clipboard-write (user expects these)
app.on('web-contents-created', (event, contents) => {
contents.session.setPermissionRequestHandler((webContents, permission, callback) => {
const ALLOWED_PERMISSIONS = [
'clipboard-read',
'clipboard-write'
];
if (ALLOWED_PERMISSIONS.includes(permission)) {
callback(true);
} else {
// Deny all other permissions (camera, microphone, geolocation, etc.)
console.warn(`[Security] Blocked permission request: ${permission}`);
callback(false);
}
});
// Disable file download dialogs for security
// contents.session.setDownloadPath(userDownloadsPath);
});
let mainWindow;
let currentFile = null; // This will now represent the active tab's file
let pandocAvailable = null; // Cache pandoc availability check
@@ -1029,7 +1091,7 @@ function showAboutDialog() {
<body>
<img src="${iconBase64}" class="logo" alt="MarkdownConverter">
<h1>MarkdownConverter</h1>
<div class="version">Version 4.1.0</div>
<div class="version">Version ${app.getVersion()}</div>
<div class="company">
<span>by</span>
@@ -1833,9 +1895,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 +2127,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 +3784,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 +3794,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 +3804,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 +4114,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);
});
// ============================================
+44
View File
@@ -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 };
+433
View File
@@ -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
};
+12
View File
@@ -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 };
+43
View File
@@ -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 };
+23
View File
@@ -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 };
+70
View File
@@ -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 };
+75
View File
@@ -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 };
+80
View File
@@ -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 };
+23
View File
@@ -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 };
+9 -3
View File
@@ -9,7 +9,7 @@
* - All IPC channels are explicitly whitelisted
* - Prevents XSS from escalating to full system access
*
* @version 4.1.0
* @version 4.3.0
*/
const { contextBridge, ipcRenderer } = require('electron');
@@ -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 = [
@@ -429,7 +433,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
generators: {
openAscii: () => ipcRenderer.send('open-ascii-generator'),
openTable: () => ipcRenderer.send('open-table-generator')
}
},
getAppVersion: () => ipcRenderer.invoke('get-app-version')
});
// Log successful preload initialization
+88 -24
View File
@@ -1,12 +1,14 @@
/**
* MarkdownConverter Renderer Process
* @version 4.1.0
* @version 4.3.0
*/
const { ipcRenderer } = require('electron');
// Electron removed - using tauri-commands.js
import { file, events, app, exportDoc, git, pdf, dialog } from './tauri-commands.js';
const marked = require('marked');
const { markedHighlight } = require('marked-highlight');
const DOMPurify = require('dompurify');
const createDOMPurify = require('dompurify');
const DOMPurify = createDOMPurify(window);
const hljs = require('highlight.js');
const { createEditor } = require('./editor/codemirror-setup');
const { undo, redo } = require('@codemirror/commands');
@@ -130,6 +132,9 @@ function plantumlEncode(text) {
return '~h' + hex;
}
// Module-level reference set by DOMContentLoaded after sidebar registration
let outlinePanelContainer = null;
// Tab Management
class TabManager {
constructor() {
@@ -496,7 +501,7 @@ class TabManager {
// Notify main process about current file for exports
const tab = this.tabs.get(tabId);
ipcRenderer.send('set-current-file', tab?.filePath || null);
const result = await file.write(path, content);
// Refresh outline panel for new tab content
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
@@ -919,7 +924,7 @@ class TabManager {
// Only auto-save if content has changed since last save
if (tab.lastSavedContent !== tab.content) {
ipcRenderer.send('save-file', { path: tab.filePath, content: tab.content });
await file.write(tab.filePath, tab.content);
tab.lastSavedContent = tab.content;
// Show brief auto-save indicator
@@ -958,7 +963,7 @@ class TabManager {
// Save to localStorage and sync with main process
localStorage.setItem('recentFiles', JSON.stringify(this.recentFiles));
ipcRenderer.send('save-recent-files', this.recentFiles);
await app.saveRecentFiles(this.recentFiles);
}
getRecentFiles() {
@@ -1367,7 +1372,7 @@ class TabManager {
this.updateBreadcrumb();
// Notify main process about current file for exports
ipcRenderer.send('set-current-file', filePath);
// handled via event
console.log('File opened successfully');
}
@@ -1439,11 +1444,17 @@ class TabManager {
let tabManager;
let replPanel;
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('DOMContentLoaded', async () => {
tabManager = new TabManager();
const ReplPanel = getReplPanel();
replPanel = new ReplPanel();
// Populate app version from main process
window.electronAPI.getAppVersion().then((version) => {
const el = document.getElementById('app-version-display');
if (el) el.textContent = `v${version}`;
});
// Initialize ModalManager for all dialogs
const findModal = new ModalManager('#find-dialog');
const exportModal = new ModalManager('#export-dialog');
@@ -1478,7 +1489,7 @@ document.addEventListener('DOMContentLoaded', () => {
sidebarManager.registerPanel('explorer', {
title: 'Explorer',
render: (container) => getRenderExplorerPanel()(container, {
listDirectory: (dir) => ipcRenderer.invoke('list-directory', dir),
listDirectory: (dir) => file.listDirectory(dir),
onFileOpen: (filePath) => ipcRenderer.send('open-file-path', filePath),
currentDir: explorerCurrentDir,
})
@@ -1486,11 +1497,11 @@ document.addEventListener('DOMContentLoaded', () => {
sidebarManager.registerPanel('git', {
title: 'Git',
render: (container) => getRenderGitPanel()(container, {
gitStatus: () => ipcRenderer.invoke('git-status'),
gitDiff: (file) => ipcRenderer.invoke('git-diff', { file }),
gitStage: (files) => ipcRenderer.invoke('git-stage', { files }),
gitCommit: (message) => ipcRenderer.invoke('git-commit', { message }),
gitLog: () => ipcRenderer.invoke('git-log'),
gitStatus: () => git.status(tab?.filePath ? path.dirname(tab.filePath) : null),
gitDiff: (file) => git.diff(path.dirname(file) || null, file),
gitStage: (files) => git.stage(path.dirname(files[0]) || null, files),
gitCommit: (message) => git.commit(getGitRepoPath(), message),
gitLog: () => git.log(getGitRepoPath()),
})
});
sidebarManager.registerPanel('snippets', {
@@ -1505,7 +1516,7 @@ document.addEventListener('DOMContentLoaded', () => {
sidebarManager.registerPanel('templates', {
title: 'Templates',
render: (container) => getRenderTemplatesPanel()(container, async (file) => {
const templateContent = await ipcRenderer.invoke('load-template', file);
const templateContent = await ipcRenderer.invoke('load-template', file),
if (templateContent) {
const content = templateContent.replace(/\{\{DATE\}\}/g, new Date().toISOString().split('T')[0]);
tabManager.createNewTab();
@@ -1545,6 +1556,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);
@@ -1558,7 +1621,8 @@ document.addEventListener('DOMContentLoaded', () => {
// Set welcome content in the first tab's preview
const recentFiles = JSON.parse(localStorage.getItem('recentFiles') || '[]');
const welcomeHtml = getCreateWelcomeContent()(recentFiles);
const appVersion = await window.electronAPI.getAppVersion().catch(() => '');
const welcomeHtml = getCreateWelcomeContent()(recentFiles, appVersion);
const tab = tabManager.tabs.get(tabManager.activeTabId);
if (tab) {
@@ -1773,11 +1837,11 @@ document.addEventListener('DOMContentLoaded', () => {
});
// IPC event listeners
ipcRenderer.on('file-new', () => {
events.onFileNew(() => {
tabManager.createNewTab();
});
ipcRenderer.on('file-opened', (event, data) => {
events.onFileOpened((event, data) => {
console.log('[RENDERER] file-opened received:', data.path, 'content length:', data.content.length);
if (tabManager) {
tabManager.openFile(data.path, data.content);
@@ -1786,7 +1850,7 @@ ipcRenderer.on('file-opened', (event, data) => {
}
});
ipcRenderer.on('file-save', () => {
events.onFileSave(() => {
const currentContent = tabManager.getCurrentContent();
const currentFilePath = tabManager.getCurrentFilePath();
// send to main process which will save or trigger save-as dialog
@@ -1814,12 +1878,12 @@ ipcRenderer.on('get-content-for-spreadsheet', (event, format) => {
ipcRenderer.send('export-spreadsheet', { content: currentContent, format });
});
ipcRenderer.on('toggle-preview', () => {
events.onTogglePreview(() => {
tabManager.isPreviewVisible = !tabManager.isPreviewVisible;
tabManager.updatePreviewVisibility();
});
ipcRenderer.on('toggle-find', () => {
events.onToggleFind(() => {
if (window.modals.findModal.isOpen()) {
window.modals.findModal.close();
} else {
@@ -1828,7 +1892,7 @@ ipcRenderer.on('toggle-find', () => {
}
});
ipcRenderer.on('theme-changed', (event, theme) => {
events.onThemeChanged((event, theme) => {
console.log('[RENDERER] Theme changed to:', theme);
document.body.className = `theme-${theme}`;
@@ -1841,7 +1905,7 @@ ipcRenderer.on('theme-changed', (event, theme) => {
});
// Undo/Redo handlers — delegate to CodeMirror's built-in history
ipcRenderer.on('undo', () => {
events.onUndo(() => {
if (tabManager) {
const tab = tabManager.tabs.get(tabManager.activeTabId);
if (tab?.editorView) {
@@ -1850,7 +1914,7 @@ ipcRenderer.on('undo', () => {
}
});
ipcRenderer.on('redo', () => {
events.onRedo(() => {
if (tabManager) {
const tab = tabManager.tabs.get(tabManager.activeTabId);
if (tab?.editorView) {
+96
View File
@@ -0,0 +1,96 @@
// Tauri Commands Bridge for MarkdownConverter
// Replaces preload.js — uses Tauri's invoke() instead of ipcRenderer
const { invoke } = window.__TAURI__.core;
const { listen } = window.__TAURI__.event;
// ============================================
// FILE OPERATIONS
// ============================================
export const file = {
read: (path) => invoke('read_file', { path }),
write: (path, content) => invoke('write_file', { path, content }),
delete: (path) => invoke('delete_file', { path }),
exists: (path) => invoke('path_exists', { path }),
isDirectory: (path) => invoke('is_directory', { path }),
listDirectory: (path) => invoke('list_directory', { path }),
ensureDir: (path) => invoke('ensure_directory', { path }),
copy: (source, destination) => invoke('copy_path', { source, destination }),
move: (source, destination) => invoke('move_path', { source, destination }),
};
// ============================================
// EXPORT OPERATIONS
// ============================================
export const exportDoc = {
markdown: (inputPath, outputPath, format, options) =>
invoke('export_markdown', { inputPath, outputPath, format, options }),
checkPandoc: () => invoke('check_pandoc_available', {}),
};
// ============================================
// GIT OPERATIONS
// ============================================
export const git = {
status: (repoPath) => invoke('git_status', { repoPath }),
stage: (repoPath, path) => invoke('git_stage', { repoPath, path }),
commit: (repoPath, message) => invoke('git_commit', { repoPath, message }),
log: (repoPath, limit) => invoke('git_log', { repoPath, limit }),
diff: (repoPath, path) => invoke('git_diff', { repoPath, path }),
};
// ============================================
// APP OPERATIONS
// ============================================
export const app = {
getVersion: () => invoke('get_app_version'),
getRecentFiles: () => invoke('get_recent_files'),
saveRecentFiles: (files) => invoke('save_recent_files', { files }),
};
// ============================================
// PDF OPERATIONS
// ============================================
export const pdf = {
getPageCount: (path) => invoke('get_pdf_page_count', { path }),
merge: (paths, output) => invoke('merge_pdfs', { paths, output }),
split: (input, outputDir, pagesPerSplit) => invoke('split_pdf', { input, outputDir, pagesPerSplit }),
rotate: (input, output, degrees) => invoke('rotate_pdf', { input, output, degrees }),
deletePages: (input, output, pages) => invoke('delete_pdf_pages', { input, output, pages }),
};
// ============================================
// DIALOG OPERATIONS
// ============================================
export const dialog = {
openFile: () => invoke('open_file_dialog', {}),
saveFile: (defaultName) => invoke('save_file_dialog', { defaultName }),
selectFolder: () => invoke('select_folder_dialog', {}),
};
// ============================================
// EVENT LISTENERS
// ============================================
export const events = {
onFileNew: (callback) => listen('file-new', callback),
onFileOpened: (callback) => listen('file-opened', callback),
onFileSave: (callback) => listen('file-save', callback),
onTogglePreview: (callback) => listen('toggle-preview', callback),
onToggleFind: (callback) => listen('toggle-find', callback),
onUndo: (callback) => listen('undo', callback),
onRedo: (callback) => listen('redo', callback),
onThemeChanged: (callback) => listen('theme-changed', callback),
onToggleCommandPalette: (callback) => listen('toggle-command-palette', callback),
onToggleSidebarPanel: (callback) => listen('toggle-sidebar-panel', callback),
onToggleBottomPanel: (callback) => listen('toggle-bottom-panel', callback),
onConversionComplete: (callback) => listen('conversion-complete', callback),
onConversionStatus: (callback) => listen('conversion-status', callback),
};
// ============================================
// PLUGIN OPERATIONS
// ============================================
export const plugins = {
list: () => invoke('list_plugins', {}),
get: (name) => invoke('get_plugin', { name }),
};
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* ModalManager - Unified modal system with accessibility support
* @version 4.1.0
* @version 4.3.0
*/
class ModalManager {
#modal;
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
export default defineConfig({
root: '.',
base: './',
build: {
outDir: '../dist',
emptyOutDir: true,
},
server: {
port: 1420,
strictPort: true,
},
clearScreen: false,
});
+2 -2
View File
@@ -1,4 +1,4 @@
function createWelcomeContent(recentFiles = []) {
function createWelcomeContent(recentFiles = [], appVersion = '') {
const recentHtml = recentFiles.length
? recentFiles.map(f => {
const name = f.split(/[/\\]/).pop();
@@ -10,7 +10,7 @@ function createWelcomeContent(recentFiles = []) {
<div class="welcome-container">
<div class="welcome-hero">
<h1 class="welcome-title">MarkdownConverter</h1>
<p class="welcome-version">Version 4.1.0</p>
<p class="welcome-version">${appVersion ? `Version ${appVersion}` : ''}</p>
<p class="welcome-subtitle">Professional Markdown Editor & Universal Document Converter</p>
</div>
+64
View File
@@ -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']);
});
});
+82
View File
@@ -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);
});
});
+29
View File
@@ -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');
});
});
+79
View File
@@ -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);
});
});
+102
View File
@@ -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');
});
});
+87
View File
@@ -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);
});
});
+106
View File
@@ -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');
});
});
+34
View File
@@ -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');
});
});
+73
View File
@@ -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');
});
});
+63
View File
@@ -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');
});
});