Compare commits

..
33 Commits
Author SHA1 Message Date
amitwh 5ad1d1d4b3 chore(release): bump version to 4.4.3 2026-06-11 21:17:37 +05:30
amitwh f480449301 fix(renderer): resolve syntax errors and undefined electronAPI on startup 2026-06-11 21:15:25 +05:30
amitwh 96df5652d6 fix: resolve list-directory IPC handler closing brace syntax error 2026-05-26 11:39:57 +05:30
amitwh a9e05d2c0f feat: implement Custom Preview CSS, Reveal.js options, Large File Mode, and Interactive PDF Thumbnail Sidebar 2026-05-25 23:11:47 +05:30
amitwh c982b3e90f chore(diagnostics): add logging to _renderPreview to trace markdown rendering
This will help identify whether the issue is:
1. marked.parse returning a Promise instead of string
2. DOMPurify.sanitize failing
3. The preview element not existing
4. Libraries not being loaded

Amit Haridas
2026-05-25 00:32:27 +05:30
amitwh cfaafc07b2 chore(deps): update vulnerable packages to patched versions
Updated packages:
- simple-git 3.32.3 → 3.36.0 (RCE vulnerability)
- fast-uri 3.1.0 → 3.1.2 (host confusion, path traversal)
- dompurify 3.3.1 → 3.4.5 (XSS bypasses)
- mermaid 11.13.0 → 11.15.0 (CSS injection, DoS)
- uuid 11.1.0 → 11.1.1 (buffer bounds check)
- ws 8.20.0 → 8.20.0 (uninitialized memory)
- ip-address 10.1.0 → 10.2.0 (XSS)
- @xmldom/xmldom 0.8.11 → 0.9.10 (XML injection, DoS)
- docx4js 3.3.0 → 2.0.1 (breaks xml2js dep chain)
- brace-expansion (transitive update)

Also applied lint:fix autofix (const correctness).

Amit Haridas
2026-05-25 00:00:28 +05:30
amitwh 94ad99dc4d fix(renderer): ensure tab content visibility after file open
- Add missing updateUI() call at end of openFile() to set .active class
  on tab content. Without this, the CSS rule .tab-content:not(.active)
  { display: none } kept newly opened files invisible.
- Add diagnostic logging to file-opened IPC handler and openFile()
  to trace future file loading issues.
- Add diagnostic logging to openFileFromPath() in main process.

Amit Haridas
2026-05-24 23:54:31 +05:30
amitwh baf644d62b fix(modal): prevent duplicate ModalManager declaration
window.ModalManager was set unconditionally, causing "Identifier
'ModalManager' has already been declared" when script tag in HTML
also loaded ModalManager before renderer.js required it.

Now checks !window.ModalManager before setting.

Amit Haridas
2026-05-22 22:08:06 +05:30
amitwh f9a5420ad2 4.4.1: update version everywhere, fix DOMPurify initialization
- Bump version to 4.4.1
- DOMPurify now initialized with window context (fixes markdown rendering)
- Add 'it' to eslint globals

Amit Haridas
2026-05-22 21:54:05 +05:30
amitwh f8361174f2 chore: add it to eslint globals, add debug logging to createEditor
Amit Haridas
2026-05-22 21:42:34 +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
55 changed files with 21015 additions and 578 deletions
+17 -10
View File
@@ -19,17 +19,17 @@ jobs:
node-version: 20 node-version: 20
cache: npm cache: npm
- name: Install rpmbuild
run: sudo apt-get update && sudo apt-get install -y rpm
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Download external tools (pandoc)
run: node scripts/download-tools.js
- name: Run tests - name: Run tests
run: npm test run: npm test
- name: Build Linux packages - name: Build Linux packages
run: npm run build:linux run: npm run build:linux-ci -- --publish=never
- name: Upload Linux artifacts - name: Upload Linux artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -56,14 +56,19 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Download external tools (pandoc)
run: node scripts/download-tools.js
- name: Run tests - name: Run tests
run: npm test run: npm test
- name: Decode certificate (if available) - name: Decode certificate (if available)
if: ${{ secrets.CSC_LINK_BASE64 != '' }} if: ${{ env.CSC_LINK_BASE64 != '' }}
shell: pwsh shell: pwsh
env:
CSC_LINK_BASE64: ${{ secrets.CSC_LINK_BASE64 }}
run: | 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) [IO.File]::WriteAllBytes("${{ github.workspace }}\code-signing-cert.pfx", $bytes)
echo "CERT_AVAILABLE=true" >> $env:GITHUB_ENV echo "CERT_AVAILABLE=true" >> $env:GITHUB_ENV
@@ -72,13 +77,13 @@ jobs:
env: env:
CSC_LINK: code-signing-cert.pfx CSC_LINK: code-signing-cert.pfx
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} 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) - name: Build Windows packages (unsigned)
if: ${{ env.CERT_AVAILABLE != 'true' }} if: ${{ env.CERT_AVAILABLE != 'true' }}
env: env:
CSC_IDENTITY_AUTO_DISCOVERY: false CSC_IDENTITY_AUTO_DISCOVERY: 'false'
run: npm run build:win-unsigned run: npm run build:win-unsigned -- --publish=never
- name: Upload Windows artifacts - name: Upload Windows artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -86,24 +91,26 @@ jobs:
name: windows-artifacts name: windows-artifacts
path: | path: |
dist/*.exe dist/*.exe
dist/*-win.zip
dist/*.zip dist/*.zip
retention-days: 5 retention-days: 5
release: release:
needs: [build-linux, build-windows] needs: [build-linux, build-windows]
if: always()
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Download Linux artifacts - name: Download Linux artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
continue-on-error: true
with: with:
name: linux-artifacts name: linux-artifacts
path: dist path: dist
- name: Download Windows artifacts - name: Download Windows artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
continue-on-error: true
with: with:
name: windows-artifacts name: windows-artifacts
path: dist path: dist
+7 -1
View File
@@ -16,7 +16,10 @@ out/
.cache/ .cache/
.npm/ .npm/
.electron/ .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 # Code signing certificates — never commit private keys
*.pfx *.pfx
@@ -40,3 +43,6 @@ pdf\ modal.png
CLAUDE.md CLAUDE.md
agents.md agents.md
coverage/ coverage/
# Superpowers brainstorm artifacts
.superpowers/
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,426 @@
# MarkdownConverter v5.0 — Platform Design
**Date:** 2026-04-14
**Status:** Approved
**Author:** Amit Haridas
## Overview
Transform MarkdownConverter from a monolithic editor into an extensible platform with a plugin system and three feature packs, shipped together as v5.0.
## Subsystems
1. **Plugin System** — Lightweight plugin registry with extension points (sidebar, commands, settings, status bar, export hooks, event bus)
2. **Writing Studio Plugin** — Manuscript manager, goal tracking, writing sprints, snapshots, smart proofreading
3. **AI Assistant Plugin** — Multi-provider AI writing assistant (Ollama, LMStudio, GGUF direct with GPU, Anthropic, OpenAI)
4. **Collaboration Plugin** — Git-based async collaboration, comments/annotations, review requests
## Core Principle
**Existing functionality is never replaced or broken.** The plugin system is additive. All existing keyboard shortcuts, features, and UI remain untouched. Plugin shortcuts use `Ctrl+Alt+` namespace.
---
## 1. Plugin System
### File Structure
```
src/
plugins/
plugin-registry.js # Load, register, lifecycle
plugin-api.js # Base class plugins extend
plugin-loader.js # Discovers and validates manifests
built-in/
writing-studio/
manifest.json
index.js
panels/
components/
ai-assistant/
manifest.json
index.js
providers/
collaboration/
manifest.json
index.js
```
### Manifest Schema
```json
{
"id": "writing-studio",
"name": "Writing Studio",
"version": "1.0.0",
"description": "Manuscript management, goal tracking, writing sprints",
"icon": "pen-tool",
"extensionPoints": {
"sidebar": { "panel": "panels/manuscript-panel.js", "order": 30 },
"settings": { "section": "settings/index.js" },
"statusBar": { "indicators": ["sprint-timer", "word-goal"] },
"commands": [
{ "id": "start-sprint", "label": "Start Writing Sprint", "shortcut": "Ctrl+Alt+S" },
{ "id": "take-snapshot", "label": "Take Snapshot", "shortcut": "Ctrl+Alt+N" }
],
"exportHooks": {
"preExport": "hooks/pre-export.js",
"postExport": "hooks/post-export.js"
}
},
"settings": [
{ "key": "dailyGoal", "type": "number", "default": 1000, "label": "Daily word goal" },
{ "key": "sprintDuration", "type": "number", "default": 25, "label": "Sprint duration (min)" }
]
}
```
### Plugin Lifecycle
1. PluginLoader discovers manifests in `built-in/` + user plugins directory
2. PluginRegistry validates manifests
3. Each plugin calls `Plugin.init(context)` receiving scoped API context
4. Extension points registered (sidebar panels, commands, status bar items)
5. Plugins activate lazily — sidebar panel loads JS when user clicks tab
### Plugin Context API
Each plugin's `init()` receives:
```javascript
{
sidebar: {
registerPanel(id, { icon, title, component })
},
commands: {
register(id, label, handler, shortcut?)
},
statusBar: {
registerIndicator(id, { position, render })
},
settings: {
get(key), // plugin-scoped
set(key, value), // auto-persisted via electron-store
onChanged(key, callback)
},
editor: {
getContent(), // current document
getSelection(), // selected text
insertAtCursor(text), // requires opt-in
onContentChanged(callback)
},
events: {
on(event, handler),
emit(event, data)
},
exports: {
registerPreHook(handler),
registerPostHook(handler)
},
ipc: {
invoke(channel, ...args),
on(channel, handler)
}
}
```
### Event Bus Events
Each event has a versioned payload schema. Breaking changes increment the version suffix.
```
document:opened → { filePath: string, tabId: string }
document:saved → { filePath: string, tabId: string }
document:changed → { tabId: string, content: string, wordCount: number }
editor:selection-changed → { tabId: string, text: string, from: {line,ch}, to: {line,ch} }
tab:switched → { tabId: string, filePath: string }
tab:closed → { tabId: string, filePath: string }
export:started → { format: string, filePath: string }
export:completed → { format: string, filePath: string, outputPath: string }
export:failed → { format: string, error: string }
plugin:loaded → { pluginId: string, version: string }
plugin:activated → { pluginId: string }
plugin:deactivated → { pluginId: string }
app:ready → {}
app:before-quit → {}
```
### Design Rules
- Built-in plugins use the same API as future third-party plugins
- Lazy activation — sidebar panels don't load until clicked
- Scoped settings: `plugins.<id>.<key>` in electron-store
- Plugin commands globally unique — registry rejects duplicate command IDs at load time
- **Plugin sandboxing**: each plugin handler is wrapped in try/catch. For CPU-intensive operations (AI inference, diff computation), plugins must delegate to main process via IPC. Handlers that block the renderer for >5s trigger a warning notification. Memory-hungry operations (GGUF inference) run in isolated child processes.
- **Cross-plugin graceful degradation**: plugins check `context.events.hasHandler('ai:analyze')` before emitting cross-plugin requests. If no handler (AI plugin disabled), show a "this feature requires the AI plugin" prompt instead of failing silently. All cross-plugin calls have a 30s timeout with default fallback behavior.
---
## 2. Writing Studio Plugin
### 2A. Manuscript / Project Manager
Folder-based project structure:
```
~/Manuscripts/
my-novel/
.project.json # { title, targets, metadata }
01-chapter-one.md
02-chapter-two.md
characters/
protagonist.md
research/
world-building.md
.snapshots/
2026-04-14T10-30.json
```
`.project.json`:
```json
{
"title": "My Novel",
"type": "manuscript",
"target": { "words": 80000, "deadline": "2026-09-01" },
"chapters": [
{ "file": "01-chapter-one.md", "title": "The Beginning", "status": "draft" }
],
"metadata": { "author": "", "genre": "", "synopsis": "" }
}
```
Sidebar panel shows project tree with drag-to-reorder, word counts per chapter, target progress bar. "Compile manuscript" exports all chapters as a single document.
### 2B. Goal Tracking & Writing Sprints
- **Status bar**: daily progress bar + sprint timer
- **Writing sprint**: configurable duration (15/25/30/45/60 min), word count delta, WPM at end
- **Goal tracking**: daily/weekly word goals, streak tracking, 30-day bar chart
- **Enhanced analytics**: session tracking, readability scores, productive time-of-day heatmap
- Data stored in `plugins.writing-studio.history` as date-keyed map
### 2C. Snapshot & Versioning
- `Ctrl+Alt+N` or toolbar button saves snapshot
- Stored as JSON: `{ timestamp, content, wordCount, cursorPos, label }`
- Snapshot panel in sidebar: Restore, Diff (side-by-side), auto-snapshot interval
- Snapshots in `.snapshots/` inside project folder, or app data if no project
### 2D. Smart Proofreading
Delegates to AI plugin via event bus. Writing Studio provides:
- Right-click context menu: "Check grammar", "Suggest alternatives", "Analyze readability"
- Inline wavy underline decorations for issues
- Proofread panel: issues categorized by type with Accept/Dismiss
### Commands
| Command | Shortcut | Action |
|---------|----------|--------|
| `start-sprint` | `Ctrl+Alt+S` | Start writing sprint |
| `stop-sprint` | `Ctrl+Alt+Shift+S` | Stop sprint |
| `take-snapshot` | `Ctrl+Alt+N` | Save snapshot |
| `restore-last-snapshot` | `Ctrl+Alt+Z` | Restore latest snapshot |
| `new-project` | — | Create manuscript project |
| `compile-manuscript` | `Ctrl+Alt+E` | Export all chapters |
| `proofread-document` | `Ctrl+Alt+G` | AI proofread |
---
## 3. AI Assistant Plugin
### Provider Architecture
```
AI Plugin
├── Provider Interface
│ ├── complete(prompt, options) → string
│ ├── stream(prompt, options) → AsyncIterable
│ └── analyze(text, type) → AnalysisResult
├── Providers
│ ├── OllamaProvider — localhost:11434
│ ├── LMStudioProvider — localhost:1234/v1
│ ├── GGUFProvider — direct llama.cpp with GPU support
│ ├── AnthropicProvider — Claude API
│ └── OpenAIProvider — GPT API
└── Features
├── Grammar/style check
├── Inline auto-complete
├── AI chat panel (sidebar)
├── Document analysis
└── Smart commands (command palette)
```
### Provider Details
**Ollama:** `GET /api/tags` for models, `POST /api/generate` and `POST /api/chat` for inference.
**LMStudio:** OpenAI-compatible API at `localhost:1234/v1`. `GET /v1/models`, standard chat completion format, SSE streaming.
**GGUF Direct (with GPU):**
- Ships bundled llama.cpp binaries per platform (CUDA, Vulkan, Metal, CPU variants)
- Auto-detects GPU: CUDA (nvidia-smi), Vulkan driver, Metal (macOS)
- GPU layer offloading: configurable, auto-suggests based on VRAM vs model size
- Settings: GPU backend selection, layer count, context length, thread count
- "Keep model loaded" option for faster repeated requests
- WASM fallback for sandboxed environments (CPU-only)
- External binary path for advanced users with custom builds
- **Process isolation**: llama.cpp runs as a spawned child process (not in main process). If it crashes, detected via exit handler, auto-restarted with notification. GPU memory freed on crash. App remains stable.
- Process management: spawn in server mode on localhost ephemeral port, clean up on app quit or model unload
**Cloud (Anthropic/OpenAI):**
- API key stored encrypted via electron safeStorage
- Token usage tracking with estimated cost
- Rate limit awareness with request queueing and backoff
### IPC Design
All provider HTTP requests go through main process:
- No CORS issues
- API keys never in renderer
- Main process enforces rate limiting
- GGUF inference in isolated child process
**Request/response lifecycle:**
```
Renderer → ipc.invoke('ai:complete') → Main → HTTP to provider → result → Renderer
```
**Streaming lifecycle with error handling:**
```
Renderer → ipc.invoke('ai:stream', { requestId, prompt })
← Main assigns requestId, returns { requestId }
← ipc.on('ai:chunk', { requestId, text }) — repeated
← ipc.on('ai:done', { requestId }) — success
← ipc.on('ai:error', { requestId, error }) — failure
// Cancellation
Renderer → ipc.invoke('ai:cancel', { requestId })
← Main aborts HTTP request, emits 'ai:done'
// Orphan cleanup: if renderer disconnects (crash/close),
// main process detects via 'render-view-deleted' and aborts all active streams.
// Heartbeat: if no chunk received in 30s, main emits 'ai:error' with timeout.
```
### Features
1. **Inline suggestions**: ghost text after configurable delay, Tab to accept, Esc to dismiss
2. **AI chat panel**: sidebar conversation, "Insert" / "Replace selection" buttons
3. **Document analysis**: grammar, style, tone, with accept/reject per suggestion
4. **Smart commands**: summarize, generate outline, find inconsistencies, translate, explain code
### Privacy
- Local-first: default provider is Ollama
- No telemetry: requests go direct to provider
- Content gating: exclude file types from AI
- Status bar shows "AI: processing..." with cancel option
- Cloud usage stats in settings (tokens, cost)
### Cross-Plugin Integration
```javascript
// Writing Studio calls AI Plugin
context.events.emit('ai:analyze', { text, type: 'grammar', callback });
```
---
## 4. Collaboration Plugin
### 4A. Enhanced Git Panel
Upgrades to existing git panel:
- Remote management (add/remove remotes, push/pull)
- Branch list and switching
- Commit history with diff viewer (side-by-side or unified)
- Conflict resolution UI (accept-ours/accept-theirs/per-edit)
### 4B. Shared Repository Workflow
1. Writer A creates project + initializes git + pushes to shared repo
2. Writer B clones repo from within MarkdownConverter
3. Both write on their own branches
4. Writer A creates review request (simplified PR)
Review request: changed files, word count diff, commit messages. Reviewer can approve, request changes, leave inline comments. Reviews are git branches + comments as git notes.
### 4C. Comments & Annotations
Inline comments stored as JSON in `.comments/` directory (git-tracked):
```json
{
"id": "uuid",
"file": "03-chapter-three.md",
"anchor": {
"contextBefore": "The hero looked at the horizon and said,",
"selectedText": "I will not go quietly into that dark night",
"contextAfter": "He turned to face the army alone."
},
"line": 142,
"text": "This dialogue feels unnatural",
"author": "amit",
"timestamp": "2026-04-14T14:30:00Z",
"replies": [],
"resolved": false
}
```
- **Anchor-based positioning**: comments store `contextBefore`, `selectedText`, and `contextAfter` (not absolute byte offsets). On file change, re-anchor by searching for the context text. If context no longer matches, mark comment as "detached" and show a warning. Falls back to `line` number as rough position.
- Highlighted text in editor with tooltip on hover
- Comment panel in sidebar: all unresolved comments across files
- Resolution workflow: add → address → reply → resolve
- Resolved comments dim but stay visible
### 4D. Change Notifications
- Status bar indicator: `↓ 3 new commits`
- Click to see changes, one-click pull
- Conflicts trigger resolution UI
- Push button only when local commits ahead of remote
### 4E. Offline-First
All writing happens locally. Git is the sync mechanism. No internet required for writing, commenting, snapshots, or sprints. Push/pull on user action or auto-sync setting.
### Commands
| Command | Shortcut | Action |
|---------|----------|--------|
| `collab:commit` | `Ctrl+Shift+G` | Commit with message |
| `collab:push` | — | Push current branch |
| `collab:pull` | — | Pull from remote |
| `collab:add-comment` | `Ctrl+Alt+C` | Comment on selection |
| `collab:next-comment` | `F8` | Next unresolved comment |
| `collab:prev-comment` | `Shift+F8` | Previous comment |
| `collab:create-review` | — | Create review request |
### Cross-Plugin Integration
```javascript
context.events.emit('snapshot:created', { file, snapshotId });
context.events.on('project:chapter-opened', (chapter) => { /* load comments */ });
context.events.on('comment:added', (comment) => { /* AI could suggest fix */ });
```
---
## Bundle Size Impact
- llama.cpp binaries: ~15MB per GPU variant. Only target platform shipped. GPU variants (CUDA/Vulkan/Metal) downloaded on demand if user enables GGUF direct loading — not bundled by default. Only CPU fallback bundled (~15MB).
- Plugin system core: ~30KB
- Each built-in plugin: ~50-100KB
- Diff library (jsdiff): ~15KB
- Total estimated increase: ~20-30MB (core), additional ~30-50MB per GPU variant (lazy download)
## Testing Strategy
- Plugin system: unit tests for registry, loader, context API mocking
- Each plugin: isolated unit tests, integration tests via plugin context
- AI provider tests: mock HTTP responses, test streaming parsing
- Git tests: use test repository fixture
- E2E: verify plugin loading doesn't break existing features
+1
View File
@@ -75,6 +75,7 @@ module.exports = [
jest: 'readonly', jest: 'readonly',
describe: 'readonly', describe: 'readonly',
test: 'readonly', test: 'readonly',
it: 'readonly',
expect: 'readonly', expect: 'readonly',
beforeEach: 'readonly', beforeEach: 'readonly',
afterEach: 'readonly', afterEach: 'readonly',
+13547
View File
File diff suppressed because it is too large Load Diff
+25 -16
View File
@@ -1,6 +1,6 @@
{ {
"name": "markdown-converter", "name": "markdown-converter",
"version": "4.3.0", "version": "4.4.3",
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting", "description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
"main": "src/main.js", "main": "src/main.js",
"scripts": { "scripts": {
@@ -19,9 +19,11 @@
"create-cert": "powershell -ExecutionPolicy Bypass -File scripts/create-selfsigned-cert.ps1", "create-cert": "powershell -ExecutionPolicy Bypass -File scripts/create-selfsigned-cert.ps1",
"build:mac": "electron-builder --mac", "build:mac": "electron-builder --mac",
"build:linux": "electron-builder --linux", "build:linux": "electron-builder --linux",
"build:linux-ci": "electron-builder --linux deb AppImage",
"build:local": "electron-builder --linux --win", "build:local": "electron-builder --linux --win",
"dist": "electron-builder --publish=never", "dist": "electron-builder --publish=never",
"dist:all": "electron-builder -mwl", "dist:all": "electron-builder -mwl",
"download-tools": "node scripts/download-tools.js",
"generate-icons": "node scripts/generate-icons.js" "generate-icons": "node scripts/generate-icons.js"
}, },
"keywords": [ "keywords": [
@@ -71,9 +73,10 @@
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"core-util-is": "^1.0.3", "core-util-is": "^1.0.3",
"docx": "^9.6.0", "docx": "^9.6.0",
"docx4js": "^3.3.0", "docx4js": "^2.0.1",
"dompurify": "^3.3.1", "dompurify": "^3.3.1",
"electron-store": "^10.1.0", "electron-store": "^10.1.0",
"ffmpeg-static": "^5.3.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"html2pdf.js": "^0.14.0", "html2pdf.js": "^0.14.0",
"marked": "^17.0.3", "marked": "^17.0.3",
@@ -109,6 +112,10 @@
"node_modules/**/*", "node_modules/**/*",
"package.json" "package.json"
], ],
"asarUnpack": [
"node_modules/ffmpeg-static/**"
],
"extraFiles": [],
"fileAssociations": [ "fileAssociations": [
{ {
"ext": "md", "ext": "md",
@@ -160,7 +167,14 @@
"artifactName": "${productName}-${version}-${arch}.${ext}", "artifactName": "${productName}-${version}-${arch}.${ext}",
"requestedExecutionLevel": "asInvoker", "requestedExecutionLevel": "asInvoker",
"legalTrademarks": "Copyright (C) 2024-2025 ConcreteInfo", "legalTrademarks": "Copyright (C) 2024-2025 ConcreteInfo",
"verifyUpdateCodeSignature": false "verifyUpdateCodeSignature": false,
"signAndEditExecutable": false,
"extraFiles": [
{
"from": "bin/win32/pandoc.exe",
"to": "bin/pandoc.exe"
}
]
}, },
"nsis": { "nsis": {
"oneClick": false, "oneClick": false,
@@ -182,29 +196,24 @@
"target": [ "target": [
"deb", "deb",
"AppImage", "AppImage",
"snap", "snap"
"rpm"
], ],
"category": "Utility", "category": "Utility",
"maintainer": "ConcreteInfo <amit.wh@gmail.com>" "maintainer": "ConcreteInfo <amit.wh@gmail.com>",
"extraFiles": [
{
"from": "bin/linux/pandoc",
"to": "bin/pandoc"
}
]
}, },
"deb": { "deb": {
"depends": [ "depends": [
"pandoc",
"ffmpeg",
"imagemagick", "imagemagick",
"libreoffice-common" "libreoffice-common"
], ],
"description": "Professional Markdown editor and universal file converter", "description": "Professional Markdown editor and universal file converter",
"maintainer": "ConcreteInfo <amit.wh@gmail.com>" "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);
});
+1 -1
View File
@@ -4,7 +4,7 @@
* Implements file system operations for Electron using IPC. * Implements file system operations for Electron using IPC.
* This abstracts file operations to enable easier testing and migration. * This abstracts file operations to enable easier testing and migration.
* *
* @version 4.1.0 * @version 4.4.1
*/ */
/** /**
+1 -1
View File
@@ -5,7 +5,7 @@
* Adapters abstract file system, conversion, and system operations * Adapters abstract file system, conversion, and system operations
* to enable easier testing and future platform migration. * to enable easier testing and future platform migration.
* *
* @version 4.1.0 * @version 4.4.1
*/ */
/** /**
+5
View File
@@ -59,6 +59,11 @@ const jetBrainsMonoTheme = EditorView.theme({
* @returns {EditorView} the created editor view * @returns {EditorView} the created editor view
*/ */
function createEditor(parentElement, options = {}) { function createEditor(parentElement, options = {}) {
console.log('[createEditor] Called with parentElement:', parentElement?.id, 'dimensions:', parentElement?.clientWidth, 'x', parentElement?.clientHeight);
if (!parentElement) {
console.error('[createEditor] ERROR: parentElement is null or undefined!');
return null;
}
const { const {
content = '', content = '',
onChange = () => {}, onChange = () => {},
+58 -1
View File
@@ -31,7 +31,7 @@
<span class="app-title">MarkdownConverter</span> <span class="app-title">MarkdownConverter</span>
</div> </div>
<div class="app-header-right"> <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> </div>
<div class="tab-bar" id="tab-bar" role="tablist" aria-label="Document tabs"> <div class="tab-bar" id="tab-bar" role="tablist" aria-label="Document tabs">
@@ -288,6 +288,52 @@
</div> </div>
</div> </div>
<div class="export-section revealjs-only" style="display: none;">
<label>Reveal.js Options:</label>
<div class="form-row">
<label for="reveal-theme">Slide Theme:</label>
<select id="reveal-theme">
<option value="black">Black</option>
<option value="white">White</option>
<option value="league">League</option>
<option value="beige">Beige</option>
<option value="sky">Sky</option>
<option value="night">Night</option>
<option value="serif">Serif</option>
<option value="simple">Simple</option>
<option value="solarized">Solarized</option>
<option value="blood">Blood</option>
<option value="moon">Moon</option>
</select>
</div>
<div class="form-row">
<label for="reveal-transition">Transition:</label>
<select id="reveal-transition">
<option value="slide">Slide</option>
<option value="none">None</option>
<option value="fade">Fade</option>
<option value="convex">Convex</option>
<option value="concave">Concave</option>
<option value="zoom">Zoom</option>
</select>
</div>
<div class="form-row">
<label for="reveal-speed">Transition Speed:</label>
<select id="reveal-speed">
<option value="default">Default</option>
<option value="fast">Fast</option>
<option value="slow">Slow</option>
</select>
</div>
<div class="checkbox-group">
<label><input type="checkbox" id="reveal-slide-number"> Slide Numbers</label>
<label><input type="checkbox" id="reveal-controls" checked> Controls</label>
<label><input type="checkbox" id="reveal-progress" checked> Progress Bar</label>
<label><input type="checkbox" id="reveal-history" checked> Slide History</label>
<label><input type="checkbox" id="reveal-center" checked> Center Content</label>
</div>
</div>
<div class="export-section"> <div class="export-section">
<label>Bibliography:</label> <label>Bibliography:</label>
<div class="form-row"> <div class="form-row">
@@ -905,6 +951,7 @@
<button class="modal-close" id="pdf-editor-dialog-close" aria-label="Close">&times;</button> <button class="modal-close" id="pdf-editor-dialog-close" aria-label="Close">&times;</button>
</div> </div>
<div class="modal-body pdf-editor-body"> <div class="modal-body pdf-editor-body">
<div class="pdf-editor-left-panel">
<!-- Merge PDFs Section --> <!-- Merge PDFs Section -->
<div id="pdf-merge-section" class="pdf-operation-section hidden"> <div id="pdf-merge-section" class="pdf-operation-section hidden">
<div class="export-section"> <div class="export-section">
@@ -1287,6 +1334,16 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Right Panel: Scrollable Visual Page Sidebar -->
<div id="pdf-thumbnail-sidebar" class="pdf-editor-right-panel hidden">
<h4>Page Preview &amp; Editor</h4>
<p class="sidebar-help">Select a PDF to view pages, check to delete, click ↻ to rotate, and use ◀ / ▶ to reorder.</p>
<div id="pdf-thumbnail-grid" class="pdf-thumbnail-grid">
<!-- Page thumbnail cards will be rendered here dynamically -->
</div>
</div>
</div>
<div class="modal-footer"> <div class="modal-footer">
<button id="pdf-editor-cancel" class="btn btn-secondary" data-close>Cancel</button> <button id="pdf-editor-cancel" class="btn btn-secondary" data-close>Cancel</button>
<button id="pdf-editor-process" class="btn btn-primary">Process</button> <button id="pdf-editor-process" class="btn btn-primary">Process</button>
+147 -512
View File
@@ -2,8 +2,9 @@ const { app, BrowserWindow, Menu, dialog, ipcMain, shell } = require('electron')
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { exec, execFile, spawn } = require('child_process'); const { exec, execFile, spawn } = require('child_process');
const { PDFDocument, rgb, degrees, StandardFonts } = require('pdf-lib');
const WordTemplateExporter = require('./wordTemplateExporter'); const WordTemplateExporter = require('./wordTemplateExporter');
const PDFOperations = require('./main/PDFOperations');
const GitOperations = require('./main/GitOperations');
// Add MiKTeX to PATH for LaTeX support // Add MiKTeX to PATH for LaTeX support
if (process.platform === 'win32') { 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() { function getPandocPath() {
// Pandoc is expected to be in the system's PATH. if (app.isPackaged) {
// The command will be executed directly. Quoting is handled by exec. 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'; 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 // Check if Pandoc is available
function checkPandocAvailable() { function checkPandocAvailable() {
return new Promise((resolve) => { 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 mainWindow;
let currentFile = null; // This will now represent the active tab's file let currentFile = null; // This will now represent the active tab's file
let pandocAvailable = null; // Cache pandoc availability check let pandocAvailable = null; // Cache pandoc availability check
@@ -787,6 +849,20 @@ function createMenu() {
} }
}, },
{ type: 'separator' }, { type: 'separator' },
{
label: 'Custom Preview CSS',
submenu: [
{
label: 'Load Custom Preview CSS...',
click: () => mainWindow.webContents.send('load-custom-css')
},
{
label: 'Clear Custom Preview CSS',
click: () => mainWindow.webContents.send('clear-custom-css')
}
]
},
{ type: 'separator' },
{ label: 'Reload', accelerator: 'CmdOrCtrl+R', role: 'reload' }, { label: 'Reload', accelerator: 'CmdOrCtrl+R', role: 'reload' },
{ label: 'Toggle DevTools', accelerator: 'F12', role: 'toggleDevTools' }, { label: 'Toggle DevTools', accelerator: 'F12', role: 'toggleDevTools' },
{ type: 'separator' }, { type: 'separator' },
@@ -1029,7 +1105,7 @@ function showAboutDialog() {
<body> <body>
<img src="${iconBase64}" class="logo" alt="MarkdownConverter"> <img src="${iconBase64}" class="logo" alt="MarkdownConverter">
<h1>MarkdownConverter</h1> <h1>MarkdownConverter</h1>
<div class="version">Version 4.1.0</div> <div class="version">Version ${app.getVersion()}</div>
<div class="company"> <div class="company">
<span>by</span> <span>by</span>
@@ -1833,9 +1909,12 @@ function checkConverterAvailable(tool) {
case 'imagemagick': case 'imagemagick':
toolName = isWin ? 'magick' : 'convert'; toolName = isWin ? 'magick' : 'convert';
break; break;
case 'ffmpeg': case 'ffmpeg': {
toolName = 'ffmpeg'; // ffmpeg-static is always bundled — check it directly
break; const ffmpegBin = getFFmpegPath();
resolve(fs.existsSync(ffmpegBin) || ffmpegBin === 'ffmpeg');
return;
}
default: default:
resolve(false); resolve(false);
return; return;
@@ -2062,8 +2141,8 @@ function convertWithImageMagick(inputFile, outputPath) {
// FFmpeg conversion - returns {command, args} for execFile // FFmpeg conversion - returns {command, args} for execFile
function convertWithFFmpeg(inputFile, outputPath) { function convertWithFFmpeg(inputFile, outputPath) {
return { return {
command: 'ffmpeg', command: getFFmpegPath(),
args: ['-i', inputFile, outputPath, '-y'] args: ['-i', inputFile, outputPath, '-y'],
}; };
} }
@@ -2232,8 +2311,32 @@ function performExportWithOptions(format, options) {
dialog.showErrorBox('Export Error', sanitizeErrorMessage(`Failed to export: ${err.message}`)); dialog.showErrorBox('Export Error', sanitizeErrorMessage(`Failed to export: ${err.message}`));
} }
} else if (format === 'revealjs') { } else if (format === 'revealjs') {
pandocCmd = `${getPandocPath()} "${currentFile}" -t revealjs -s -o "${outputFile}" --slide-level=2`; let revealCmd = `${getPandocPath()} "${currentFile}" -t revealjs -s -o "${outputFile}" --slide-level=2`;
exportWithPandoc(pandocCmd, outputFile, format); if (options) {
if (options.revealTheme) revealCmd += ` -V theme="${options.revealTheme}"`;
if (options.revealTransition) revealCmd += ` -V transition="${options.revealTransition}"`;
if (options.revealTransitionSpeed) revealCmd += ` -V transitionSpeed="${options.revealTransitionSpeed}"`;
if (options.revealControls !== undefined) revealCmd += ` -V controls="${options.revealControls}"`;
if (options.revealSlideNumber !== undefined) revealCmd += ` -V slideNumber="${options.revealSlideNumber}"`;
if (options.revealProgress !== undefined) revealCmd += ` -V progress="${options.revealProgress}"`;
if (options.revealHistory !== undefined) revealCmd += ` -V history="${options.revealHistory}"`;
if (options.revealCenter !== undefined) revealCmd += ` -V center="${options.revealCenter}"`;
// Support for templates, metadata, bibliography
if (options.template && options.template !== 'default') {
revealCmd += ` --template="${options.template}"`;
}
if (options.metadata) {
for (const [key, value] of Object.entries(options.metadata)) {
if (value.trim()) {
revealCmd += ` -M ${key}="${value.replace(/"/g, '\\"')}"`;
}
}
}
if (options.bibliography) revealCmd += ` --bibliography="${options.bibliography}"`;
if (options.csl) revealCmd += ` --csl="${options.csl}"`;
}
exportWithPandoc(revealCmd, outputFile, format);
} else if (format === 'beamer') { } else if (format === 'beamer') {
pandocCmd = `${getPandocPath()} "${currentFile}" -t beamer -o "${outputFile}"`; pandocCmd = `${getPandocPath()} "${currentFile}" -t beamer -o "${outputFile}"`;
exportWithPandoc(pandocCmd, outputFile, format); exportWithPandoc(pandocCmd, outputFile, format);
@@ -3694,7 +3797,7 @@ app.on('open-file', (event, filePath) => {
// Handle file opening from command line or file association // Handle file opening from command line or file association
function openFileFromPath(filePath) { function openFileFromPath(filePath) {
console.log('[MAIN] openFileFromPath called with:', filePath); console.log('[MAIN] openFileFromPath called with:', filePath);
console.log('[MAIN] rendererReady:', rendererReady, 'mainWindow exists:', !!mainWindow); console.log('[MAIN] rendererReady:', rendererReady, 'mainWindow exists:', !!mainWindow, 'webContents exists:', !!(mainWindow?.webContents));
if (fs.existsSync(filePath)) { if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath); const stats = fs.statSync(filePath);
if (stats.size > MAX_FILE_SIZE) { if (stats.size > MAX_FILE_SIZE) {
@@ -3704,6 +3807,7 @@ function openFileFromPath(filePath) {
currentFile = filePath; currentFile = filePath;
const content = fs.readFileSync(filePath, 'utf-8'); const content = fs.readFileSync(filePath, 'utf-8');
console.log('[MAIN] File read successfully, content length:', content.length); console.log('[MAIN] File read successfully, content length:', content.length);
console.log('[MAIN] About to send file-opened. mainWindow:', !!mainWindow, 'webContents:', !!(mainWindow?.webContents), 'rendererReady:', rendererReady);
if (mainWindow && mainWindow.webContents && rendererReady) { if (mainWindow && mainWindow.webContents && rendererReady) {
// Send file immediately - renderer-ready means UI is initialized // Send file immediately - renderer-ready means UI is initialized
console.log('[MAIN] Sending file-opened to renderer'); console.log('[MAIN] Sending file-opened to renderer');
@@ -3719,433 +3823,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) => { ipcMain.on('process-pdf-operation', async (event, data) => {
try { try {
mainWindow.webContents.send('pdf-operation-progress', { mainWindow.webContents.send('pdf-operation-progress', {
@@ -4153,43 +3833,7 @@ ipcMain.on('process-pdf-operation', async (event, data) => {
progress: 10 progress: 10
}); });
let result; const result = await PDFOperations.executeOperation(data.operation, data);
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}` };
}
mainWindow.webContents.send('pdf-operation-complete', result); mainWindow.webContents.send('pdf-operation-complete', result);
} catch (error) { } catch (error) {
mainWindow.webContents.send('pdf-operation-complete', { mainWindow.webContents.send('pdf-operation-complete', {
@@ -4199,12 +3843,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) => { ipcMain.on('get-pdf-page-count', async (event, filePath) => {
try { try {
const pdfBytes = fs.readFileSync(filePath); const count = await PDFOperations.getPageCount(filePath);
const pdf = await PDFDocument.load(pdfBytes);
const count = pdf.getPageCount();
event.reply('pdf-page-count', { count }); event.reply('pdf-page-count', { count });
} catch (error) { } catch (error) {
event.reply('pdf-page-count', { error: error.message }); event.reply('pdf-page-count', { error: error.message });
@@ -4388,6 +4029,25 @@ ipcMain.handle('list-directory', async (event, dirPath) => {
} }
}); });
ipcMain.handle('select-custom-css', async (event) => {
const result = dialog.showOpenDialogSync(mainWindow, {
title: 'Select Custom Preview CSS',
properties: ['openFile'],
filters: [
{ name: 'CSS Stylesheets', extensions: ['css'] }
]
});
if (result && result[0]) {
const filePath = result[0];
const content = fs.readFileSync(filePath, 'utf-8');
return { path: filePath, content };
}
return null;
});
ipcMain.handle('read-file', async (event, filePath) => { ipcMain.handle('read-file', async (event, filePath) => {
const validation = validatePath(filePath); const validation = validatePath(filePath);
if (!validation.valid || !isPathAccessible(validation.resolved)) { if (!validation.valid || !isPathAccessible(validation.resolved)) {
@@ -4512,49 +4172,24 @@ ipcMain.on('open-file-path', (event, filePath) => {
// ============================================ // ============================================
// Git IPC Handlers // 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 () => { ipcMain.handle('git-status', async () => {
try { const dir = currentFile ? path.dirname(currentFile) : process.cwd();
const git = getGitRepo(); return GitOperations.getStatus(dir);
return await git.status();
} catch (err) {
return { error: 'Not a git repository' };
}
}); });
ipcMain.handle('git-stage', async (event, { files }) => { ipcMain.handle('git-stage', async (event, { files }) => {
try { const dir = currentFile ? path.dirname(currentFile) : process.cwd();
const git = getGitRepo(); return GitOperations.stage(dir, files);
await git.add(files);
return await git.status();
} catch (err) {
return { error: err.message };
}
}); });
ipcMain.handle('git-commit', async (event, { message }) => { ipcMain.handle('git-commit', async (event, { message }) => {
try { const dir = currentFile ? path.dirname(currentFile) : process.cwd();
const git = getGitRepo(); return GitOperations.commit(dir, message);
return await git.commit(message);
} catch (err) {
return { error: err.message };
}
}); });
ipcMain.handle('git-log', async () => { ipcMain.handle('git-log', async () => {
try { const dir = currentFile ? path.dirname(currentFile) : process.cwd();
const git = getGitRepo(); return GitOperations.log(dir);
return await git.log({ maxCount: 20 });
} catch (err) {
return { error: err.message };
}
}); });
// ============================================ // ============================================
+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();
const 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];
const 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 };
+12 -3
View File
@@ -9,7 +9,7 @@
* - All IPC channels are explicitly whitelisted * - All IPC channels are explicitly whitelisted
* - Prevents XSS from escalating to full system access * - Prevents XSS from escalating to full system access
* *
* @version 4.1.0 * @version 4.4.1
*/ */
const { contextBridge, ipcRenderer } = require('electron'); const { contextBridge, ipcRenderer } = require('electron');
@@ -23,6 +23,7 @@ const ALLOWED_SEND_CHANNELS = [
'save-recent-files', 'save-recent-files',
'clear-recent-files', 'clear-recent-files',
'renderer-ready', 'renderer-ready',
'select-custom-css',
// Theme // Theme
'get-theme', 'get-theme',
@@ -135,7 +136,11 @@ const ALLOWED_SEND_CHANNELS = [
'export', 'export',
// Git diff // Git diff
'git-diff' 'git-diff',
// Plugin settings
'plugin-settings:get',
'plugin-settings:set'
]; ];
const ALLOWED_RECEIVE_CHANNELS = [ const ALLOWED_RECEIVE_CHANNELS = [
@@ -146,6 +151,8 @@ const ALLOWED_RECEIVE_CHANNELS = [
'get-content-for-save', 'get-content-for-save',
'get-content-for-spreadsheet', 'get-content-for-spreadsheet',
'recent-files-cleared', 'recent-files-cleared',
'load-custom-css',
'clear-custom-css',
// UI toggles // UI toggles
'toggle-preview', 'toggle-preview',
@@ -429,7 +436,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
generators: { generators: {
openAscii: () => ipcRenderer.send('open-ascii-generator'), openAscii: () => ipcRenderer.send('open-ascii-generator'),
openTable: () => ipcRenderer.send('open-table-generator') openTable: () => ipcRenderer.send('open-table-generator')
} },
getAppVersion: () => ipcRenderer.invoke('get-app-version')
}); });
// Log successful preload initialization // Log successful preload initialization
+586 -25
View File
@@ -1,16 +1,64 @@
/** /**
* MarkdownConverter Renderer Process * MarkdownConverter Renderer Process
* @version 4.1.0 * @version 4.4.3
*/ */
const { ipcRenderer } = require('electron'); const { ipcRenderer } = require('electron');
const marked = require('marked'); const marked = require('marked');
const { markedHighlight } = require('marked-highlight'); const { markedHighlight } = require('marked-highlight');
const DOMPurify = require('dompurify'); const createDOMPurify = require('dompurify');
const DOMPurify = createDOMPurify(window);
const hljs = require('highlight.js'); const hljs = require('highlight.js');
const { createEditor } = require('./editor/codemirror-setup'); const { createEditor } = require('./editor/codemirror-setup');
const { undo, redo } = require('@codemirror/commands'); const { undo, redo } = require('@codemirror/commands');
const { ModalManager } = require('./utils/ModalManager');
// Define a fallback window.electronAPI object for when contextIsolation is disabled
// and the preload script is not loaded in the main window context.
if (typeof window !== 'undefined' && !window.electronAPI) {
window.electronAPI = {
send: (channel, data) => {
ipcRenderer.send(channel, data);
},
invoke: async (channel, data) => {
return await ipcRenderer.invoke(channel, data);
},
on: (channel, callback) => {
const subscription = (event, ...args) => callback(...args);
ipcRenderer.on(channel, subscription);
return () => {
ipcRenderer.removeListener(channel, subscription);
};
},
once: (channel, callback) => {
ipcRenderer.once(channel, (event, ...args) => callback(...args));
},
removeAllListeners: (channel) => {
ipcRenderer.removeAllListeners(channel);
},
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
file: {
read: (filePath) => ipcRenderer.invoke('read-file', filePath),
write: (filePath, content) => ipcRenderer.invoke('write-file', { path: filePath, content }),
delete: (filePath) => ipcRenderer.invoke('delete-file', filePath),
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-directory', dirPath),
exists: (filePath) => ipcRenderer.invoke('path-exists', filePath),
isDirectory: (filePath) => ipcRenderer.invoke('is-directory', filePath),
copy: (source, destination) => ipcRenderer.invoke('copy-path', { source, destination }),
move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination })
}
};
}
// Use window.ModalManager if already set by script tag, otherwise require it.
// This prevents "Identifier 'ModalManager' has already been declared" when
// both the script tag in index.html and CommonJS require() declare it.
let _ModalManager;
if (typeof window !== 'undefined' && window.ModalManager) {
_ModalManager = window.ModalManager;
} else {
const result = require('./utils/ModalManager');
_ModalManager = result.ModalManager || result;
}
// Lazy-loaded modules — defer heavy imports until first use // Lazy-loaded modules — defer heavy imports until first use
let _SidebarManager, _renderTemplatesPanel, _renderExplorerPanel, _renderGitPanel, _renderSnippetsPanel; let _SidebarManager, _renderTemplatesPanel, _renderExplorerPanel, _renderGitPanel, _renderSnippetsPanel;
let _ReplPanel, _CommandPalette, _PrintPreview, _createWelcomeContent; let _ReplPanel, _CommandPalette, _PrintPreview, _createWelcomeContent;
@@ -101,19 +149,24 @@ marked.use({
start(src) { return src.match(/^:::(note|warning|tip|danger|info)/m)?.index; }, start(src) { return src.match(/^:::(note|warning|tip|danger|info)/m)?.index; },
tokenizer(src) { tokenizer(src) {
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m); const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
if (match) { if (match && match.index === 0) {
const admonitionType = match[1];
const text = match[2].trim();
const tokens = [];
this.lexer.blockTokens(text, tokens);
return { return {
type: 'admonition', type: 'admonition',
raw: match[0], raw: match[0],
admonitionType: match[1], admonitionType,
text: match[2].trim() text,
tokens
}; };
} }
}, },
renderer(token) { renderer(token) {
const icons = { note: '\u2139', warning: '\u26A0', tip: '\uD83D\uDCA1', danger: '\uD83D\uDD34', info: '\u2139' }; const icons = { note: '\u2139', warning: '\u26A0', tip: '\uD83D\uDCA1', danger: '\uD83D\uDD34', info: '\u2139' };
const icon = icons[token.admonitionType] || '\u2139'; const icon = icons[token.admonitionType] || '\u2139';
const inner = this.parser.parse(token.text); const inner = this.parser.parse(token.tokens || []);
return `<div class="admonition admonition-${token.admonitionType}"> return `<div class="admonition admonition-${token.admonitionType}">
<div class="admonition-title">${icon} ${token.admonitionType.charAt(0).toUpperCase() + token.admonitionType.slice(1)}</div> <div class="admonition-title">${icon} ${token.admonitionType.charAt(0).toUpperCase() + token.admonitionType.slice(1)}</div>
<div class="admonition-content">${inner}</div> <div class="admonition-content">${inner}</div>
@@ -130,6 +183,21 @@ function plantumlEncode(text) {
return '~h' + hex; return '~h' + hex;
} }
// Scopes user custom CSS by prefixing all selectors to protect application UI
function scopeCSS(cssText, scopeSelector) {
if (!cssText) return '';
return cssText.replace(/([^\r\n,{}]+)(,(?=[^}]*{)|(?=[^{]*{))/g, (match, selector, separator) => {
const trimmed = selector.trim();
if (!trimmed || trimmed.startsWith('@') || trimmed.startsWith(':root') || trimmed.startsWith('from') || trimmed.startsWith('to') || /^\d+%$/.test(trimmed)) {
return match;
}
return scopeSelector + ' ' + trimmed + (separator || '');
});
}
// Module-level reference set by DOMContentLoaded after sidebar registration
const outlinePanelContainer = null;
// Tab Management // Tab Management
class TabManager { class TabManager {
constructor() { constructor() {
@@ -143,6 +211,7 @@ class TabManager {
this.recentFiles = JSON.parse(localStorage.getItem('recentFiles') || '[]'); this.recentFiles = JSON.parse(localStorage.getItem('recentFiles') || '[]');
this.previewDebounceTimers = new Map(); // Debounce timers per tab this.previewDebounceTimers = new Map(); // Debounce timers per tab
this.previewDebounceDelay = 300; // 300ms debounce this.previewDebounceDelay = 300; // 300ms debounce
this.previewCache = new Map(); // Performance optimization render cache
// Initialize first tab // Initialize first tab
this.tabs.set(1, { this.tabs.set(1, {
@@ -226,7 +295,7 @@ class TabManager {
editorView: null, editorView: null,
findMatches: [], findMatches: [],
currentMatchIndex: -1, currentMatchIndex: -1,
type: type, type: type, // 'markdown' or 'pdf'
// PDF-specific state // PDF-specific state
pdfDoc: null, pdfDoc: null,
pdfCurrentPage: 1, pdfCurrentPage: 1,
@@ -239,6 +308,7 @@ class TabManager {
this.switchToTab(newTabId); this.switchToTab(newTabId);
this.startAutoSave(); this.startAutoSave();
this.updateTabBar(); this.updateTabBar();
console.log('createNewTab: tab created with type:', type);
} }
createPdfTab(filePath) { createPdfTab(filePath) {
@@ -458,6 +528,17 @@ class TabManager {
onChange: (newContent) => { onChange: (newContent) => {
tab.content = newContent; tab.content = newContent;
tab.isDirty = true; tab.isDirty = true;
// Dynamically enable/disable Large File Mode on edit
if (newContent.length > 1024 * 1024) {
if (!tab.largeFileMode) {
tab.largeFileMode = true;
this.isPreviewVisible = false;
this.updatePreviewVisibility();
notifyUser('Large content detected (>1MB). Large File Mode enabled to maintain peak responsiveness. Live preview auto-render is disabled.', 'warning');
}
} else {
tab.largeFileMode = false;
}
this.updatePreview(tab.id); this.updatePreview(tab.id);
this.updateWordCount(); this.updateWordCount();
this.updateTabBar(); this.updateTabBar();
@@ -656,6 +737,11 @@ class TabManager {
const tab = this.tabs.get(tabId); const tab = this.tabs.get(tabId);
if (!tab || tab.type === 'pdf') return; if (!tab || tab.type === 'pdf') return;
// Guard against auto-renders in large file mode
if (tab.largeFileMode && !immediate) {
return;
}
// Clear existing debounce timer for this tab // Clear existing debounce timer for this tab
if (this.previewDebounceTimers.has(tabId)) { if (this.previewDebounceTimers.has(tabId)) {
clearTimeout(this.previewDebounceTimers.get(tabId)); clearTimeout(this.previewDebounceTimers.get(tabId));
@@ -674,20 +760,55 @@ class TabManager {
}, this.previewDebounceDelay)); }, this.previewDebounceDelay));
} }
_hash(str) {
if (!str) return 0;
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0; // Convert to 32bit integer
}
return hash;
}
_renderPreview(tabId) { _renderPreview(tabId) {
const tab = this.tabs.get(tabId); const tab = this.tabs.get(tabId);
const preview = document.getElementById(`preview-${tabId}`); const preview = document.getElementById(`preview-${tabId}`);
console.log('[_renderPreview] tabId:', tabId, 'tab exists:', !!tab, 'preview exists:', !!preview, 'content length:', tab?.content?.length);
if (!tab || !preview) return; if (!tab || !preview) return;
try { try {
// Check if libraries are available // Check if libraries are available
if (!marked || !DOMPurify) { if (!marked || !DOMPurify) {
console.error('[_renderPreview] Libraries not available:', { marked: !!marked, DOMPurify: !!DOMPurify });
preview.innerHTML = '<div class="preview-error"><div class="preview-error-icon">⚠️</div><div class="preview-error-title">Libraries Not Loaded</div><div class="preview-error-message">Required libraries (marked/DOMPurify) could not be loaded. Please check your installation.</div></div>'; preview.innerHTML = '<div class="preview-error"><div class="preview-error-icon">⚠️</div><div class="preview-error-title">Libraries Not Loaded</div><div class="preview-error-message">Required libraries (marked/DOMPurify) could not be loaded. Please check your installation.</div></div>';
return; return;
} }
// Cache lookup
const contentHash = this._hash(tab.content);
let sanitizedHtml;
if (this.previewCache.has(contentHash)) {
console.log('[_renderPreview] Cache hit for hash:', contentHash);
sanitizedHtml = this.previewCache.get(contentHash);
} else {
console.log('[_renderPreview] Cache miss. About to call marked.parse, content length:', tab.content.length);
const html = marked.parse(tab.content); const html = marked.parse(tab.content);
let sanitizedHtml = DOMPurify.sanitize(html); console.log('[_renderPreview] marked.parse returned type:', typeof html, 'isPromise:', html instanceof Promise);
if (html instanceof Promise) {
console.error('[_renderPreview] marked.parse returned a Promise! Need to await it.');
return;
}
sanitizedHtml = DOMPurify.sanitize(html);
console.log('[_renderPreview] DOMPurify.sanitize returned, length:', sanitizedHtml.length);
// Add to cache and maintain cache size limit (100 entries)
this.previewCache.set(contentHash, sanitizedHtml);
if (this.previewCache.size > 100) {
const oldestKey = this.previewCache.keys().next().value;
this.previewCache.delete(oldestKey);
}
}
// TOC generation // TOC generation
if (sanitizedHtml.includes('[[toc]]') || sanitizedHtml.includes('[TOC]')) { if (sanitizedHtml.includes('[[toc]]') || sanitizedHtml.includes('[TOC]')) {
@@ -1323,10 +1444,22 @@ class TabManager {
} }
} }
checkForLargeFile(tab, content) {
if (content && content.length > 1024 * 1024) { // >1MB
tab.largeFileMode = true;
this.isPreviewVisible = false;
this.updatePreviewVisibility();
notifyUser('Large file detected (>1MB). Large File Mode enabled to maintain peak responsiveness. Live preview auto-render is disabled.', 'warning');
} else {
tab.largeFileMode = false;
}
}
// File operations // File operations
openFile(filePath, content) { openFile(filePath, content) {
console.log('openFile called with:', filePath, 'content length:', content.length); console.log('openFile called with:', filePath, 'content length:', content.length);
let tab = this.tabs.get(this.activeTabId); let tab = this.tabs.get(this.activeTabId);
console.log('openFile tab state before:', { id: tab?.id, hasEditorView: !!tab?.editorView, contentLength: tab?.content?.length, filePath: tab?.filePath, isDirty: tab?.isDirty });
// Handle both forward and back slashes for cross-platform compatibility // Handle both forward and back slashes for cross-platform compatibility
const fileName = filePath.split(/[\\/]/).pop(); const fileName = filePath.split(/[\\/]/).pop();
@@ -1340,24 +1473,31 @@ class TabManager {
tab.originalContent = content; tab.originalContent = content;
tab.isDirty = false; tab.isDirty = false;
this.checkForLargeFile(tab, content);
// Update the editor and preview // Update the editor and preview
this.setEditorContent(this.activeTabId, content); this.setEditorContent(this.activeTabId, content);
this.updatePreview(this.activeTabId); console.log('openFile after setEditorContent, tab state:', { id: tab.id, hasEditorView: !!tab.editorView, contentLength: tab.content.length });
this.updatePreview(this.activeTabId, true); // immediate=true for initial load
this.updateWordCount(); this.updateWordCount();
} else { } else {
// Create new tab for the file // Create new tab for the file
console.log('Creating new tab for file'); console.log('Creating new tab for file');
this.createNewTab(); this.createNewTab();
tab = this.tabs.get(this.activeTabId); tab = this.tabs.get(this.activeTabId);
console.log('openFile after createNewTab, tab:', { id: tab.id, hasEditorView: !!tab.editorView });
tab.filePath = filePath; tab.filePath = filePath;
tab.title = fileName; tab.title = fileName;
tab.content = content; tab.content = content;
tab.originalContent = content; tab.originalContent = content;
tab.isDirty = false; tab.isDirty = false;
this.checkForLargeFile(tab, content);
// Set content in the CodeMirror editor // Set content in the CodeMirror editor
this.setEditorContent(this.activeTabId, content); this.setEditorContent(this.activeTabId, content);
this.updatePreview(this.activeTabId); console.log('openFile after setEditorContent, tab state:', { id: tab.id, hasEditorView: !!tab.editorView, contentLength: tab.content.length });
this.updatePreview(this.activeTabId, true); // immediate=true for initial load
this.updateWordCount(); this.updateWordCount();
} }
this.startAutoSave(); this.startAutoSave();
@@ -1365,6 +1505,7 @@ class TabManager {
this.updateTabBar(); this.updateTabBar();
this.updateFilePath(); this.updateFilePath();
this.updateBreadcrumb(); this.updateBreadcrumb();
this.updateUI(); // Ensure tab content is visible
// Notify main process about current file for exports // Notify main process about current file for exports
ipcRenderer.send('set-current-file', filePath); ipcRenderer.send('set-current-file', filePath);
@@ -1439,11 +1580,25 @@ class TabManager {
let tabManager; let tabManager;
let replPanel; let replPanel;
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', async () => {
const ModalManager = _ModalManager;
tabManager = new TabManager(); tabManager = new TabManager();
// Load saved Custom Preview CSS if present
const savedCSSContent = localStorage.getItem('customPreviewCSSContent');
if (savedCSSContent) {
applyCustomPreviewCSS(savedCSSContent);
}
const ReplPanel = getReplPanel(); const ReplPanel = getReplPanel();
replPanel = new ReplPanel(); 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 // Initialize ModalManager for all dialogs
const findModal = new ModalManager('#find-dialog'); const findModal = new ModalManager('#find-dialog');
const exportModal = new ModalManager('#export-dialog'); const exportModal = new ModalManager('#export-dialog');
@@ -1473,7 +1628,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Initialize sidebar // Initialize sidebar
const SidebarManager = getSidebarManager(); const SidebarManager = getSidebarManager();
const sidebarManager = new SidebarManager(); const sidebarManager = new SidebarManager();
let explorerCurrentDir = null; const explorerCurrentDir = null;
sidebarManager.registerPanel('explorer', { sidebarManager.registerPanel('explorer', {
title: 'Explorer', title: 'Explorer',
@@ -1545,6 +1700,63 @@ 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');
// Initialize command palette
const CommandPalette = getCommandPalette();
const commandPalette = new CommandPalette();
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 // Initialize Zen Mode
const ZenModeClass = getZenMode(); const ZenModeClass = getZenMode();
const zenMode = new ZenModeClass(tabManager); const zenMode = new ZenModeClass(tabManager);
@@ -1558,7 +1770,8 @@ document.addEventListener('DOMContentLoaded', () => {
// Set welcome content in the first tab's preview // Set welcome content in the first tab's preview
const recentFiles = JSON.parse(localStorage.getItem('recentFiles') || '[]'); 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); const tab = tabManager.tabs.get(tabManager.activeTabId);
if (tab) { if (tab) {
@@ -1642,10 +1855,6 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}); });
// Initialize command palette
const CommandPalette = getCommandPalette();
const commandPalette = new CommandPalette();
// Initialize print preview // Initialize print preview
const PrintPreview = getPrintPreview(); const PrintPreview = getPrintPreview();
const printPreview = new PrintPreview(); const printPreview = new PrintPreview();
@@ -1703,6 +1912,8 @@ document.addEventListener('DOMContentLoaded', () => {
commandPalette.register('Insert Image', '', () => tabManager.wrapSelection('![', '](image.jpg)')); commandPalette.register('Insert Image', '', () => tabManager.wrapSelection('![', '](image.jpg)'));
commandPalette.register('Toggle Zen Mode', 'F11', () => zenMode.toggle()); commandPalette.register('Toggle Zen Mode', 'F11', () => zenMode.toggle());
commandPalette.register('Writing Analytics', 'Ctrl+Shift+A', () => getShowAnalyticsModal()(tabManager)); commandPalette.register('Writing Analytics', 'Ctrl+Shift+A', () => getShowAnalyticsModal()(tabManager));
commandPalette.register('Load Custom Preview CSS', '', triggerLoadCustomCSS);
commandPalette.register('Clear Custom Preview CSS', '', triggerClearCustomCSS);
// Keyboard shortcuts // Keyboard shortcuts
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
@@ -1772,6 +1983,7 @@ document.addEventListener('DOMContentLoaded', () => {
}, 30000); }, 30000);
}); });
// IPC event listeners
// IPC event listeners // IPC event listeners
ipcRenderer.on('file-new', () => { ipcRenderer.on('file-new', () => {
tabManager.createNewTab(); tabManager.createNewTab();
@@ -1779,8 +1991,20 @@ ipcRenderer.on('file-new', () => {
ipcRenderer.on('file-opened', (event, data) => { ipcRenderer.on('file-opened', (event, data) => {
console.log('[RENDERER] file-opened received:', data.path, 'content length:', data.content.length); console.log('[RENDERER] file-opened received:', data.path, 'content length:', data.content.length);
console.log('[RENDERER] activeTabId before openFile:', tabManager.activeTabId);
// Diagnostic: check library state
console.log('[RENDERER] Library state:', {
marked: typeof marked,
DOMPurify: typeof DOMPurify,
markedParse: typeof marked?.parse,
dompurifySanitize: typeof DOMPurify?.sanitize,
tabManagerExists: !!tabManager
});
if (tabManager) { if (tabManager) {
tabManager.openFile(data.path, data.content); tabManager.openFile(data.path, data.content);
console.log('[RENDERER] activeTabId after openFile:', tabManager.activeTabId);
const tab = tabManager.tabs.get(tabManager.activeTabId);
console.log('[RENDERER] tab state:', { id: tab?.id, hasEditorView: !!tab?.editorView, contentLength: tab?.content?.length });
} else { } else {
console.error('[RENDERER] tabManager not initialized!'); console.error('[RENDERER] tabManager not initialized!');
} }
@@ -1859,6 +2083,49 @@ ipcRenderer.on('redo', () => {
} }
}); });
// Custom Preview CSS event handlers and trigger helpers
function applyCustomPreviewCSS(cssContent) {
let styleTag = document.getElementById('custom-preview-style');
if (!styleTag) {
styleTag = document.createElement('style');
styleTag.id = 'custom-preview-style';
document.head.appendChild(styleTag);
}
const scopedCSS = scopeCSS(cssContent, '.preview-content');
styleTag.textContent = scopedCSS;
}
async function triggerLoadCustomCSS() {
try {
const result = await window.electronAPI.invoke('select-custom-css');
if (result) {
console.log('[RENDERER] Custom CSS loaded from:', result.path);
localStorage.setItem('customPreviewCSSPath', result.path);
localStorage.setItem('customPreviewCSSContent', result.content);
applyCustomPreviewCSS(result.content);
notifyUser('Custom preview stylesheet applied successfully.', 'success');
}
} catch (err) {
console.error('[RENDERER] Error loading custom CSS:', err);
notifyUser('Failed to load custom CSS stylesheet.', 'error');
}
}
function triggerClearCustomCSS() {
localStorage.removeItem('customPreviewCSSPath');
localStorage.removeItem('customPreviewCSSContent');
const styleTag = document.getElementById('custom-preview-style');
if (styleTag) {
styleTag.remove();
}
notifyUser('Custom preview stylesheet cleared.', 'info');
}
ipcRenderer.on('load-custom-css', triggerLoadCustomCSS);
ipcRenderer.on('clear-custom-css', triggerClearCustomCSS);
// Font size adjustment // Font size adjustment
let currentFontSize = parseInt(localStorage.getItem('fontSize')) || 15; let currentFontSize = parseInt(localStorage.getItem('fontSize')) || 15;
@@ -1978,13 +2245,39 @@ function initializeExportForm(format) {
document.getElementById('export-citeproc').checked = false; document.getElementById('export-citeproc').checked = false;
document.getElementById('export-toc-depth').value = 3; document.getElementById('export-toc-depth').value = 3;
// PDF-specific fields // Toggle and reset PDF-specific fields
const pdfOnly = document.querySelector('.pdf-only');
if (pdfOnly) {
pdfOnly.style.display = (format === 'pdf') ? 'block' : 'none';
}
if (format === 'pdf') { if (format === 'pdf') {
document.getElementById('pdf-engine').value = 'xelatex'; document.getElementById('pdf-engine').value = 'xelatex';
document.getElementById('pdf-geometry').value = 'margin=1in'; document.getElementById('pdf-geometry').value = 'margin=1in';
document.getElementById('custom-geometry').style.display = 'none'; document.getElementById('custom-geometry').style.display = 'none';
} }
// Toggle and reset Reveal.js-specific fields
const revealjsOnly = document.querySelector('.revealjs-only');
if (revealjsOnly) {
revealjsOnly.style.display = (format === 'revealjs') ? 'block' : 'none';
}
const revealTheme = document.getElementById('reveal-theme');
if (revealTheme) revealTheme.value = 'black';
const revealTransition = document.getElementById('reveal-transition');
if (revealTransition) revealTransition.value = 'slide';
const revealSpeed = document.getElementById('reveal-speed');
if (revealSpeed) revealSpeed.value = 'default';
const revealSlideNumber = document.getElementById('reveal-slide-number');
if (revealSlideNumber) revealSlideNumber.checked = false;
const revealControls = document.getElementById('reveal-controls');
if (revealControls) revealControls.checked = true;
const revealProgress = document.getElementById('reveal-progress');
if (revealProgress) revealProgress.checked = true;
const revealHistory = document.getElementById('reveal-history');
if (revealHistory) revealHistory.checked = true;
const revealCenter = document.getElementById('reveal-center');
if (revealCenter) revealCenter.checked = true;
// Clear bibliography fields // Clear bibliography fields
document.getElementById('bibliography-file').value = ''; document.getElementById('bibliography-file').value = '';
document.getElementById('csl-file').value = ''; document.getElementById('csl-file').value = '';
@@ -2044,6 +2337,18 @@ function collectExportOptions() {
} }
} }
// Reveal.js-specific options
if (currentExportFormat === 'revealjs') {
options.revealTheme = document.getElementById('reveal-theme').value;
options.revealTransition = document.getElementById('reveal-transition').value;
options.revealTransitionSpeed = document.getElementById('reveal-speed').value;
options.revealSlideNumber = document.getElementById('reveal-slide-number').checked;
options.revealControls = document.getElementById('reveal-controls').checked;
options.revealProgress = document.getElementById('reveal-progress').checked;
options.revealHistory = document.getElementById('reveal-history').checked;
options.revealCenter = document.getElementById('reveal-center').checked;
}
// Bibliography // Bibliography
const bibFile = document.getElementById('bibliography-file').value.trim(); const bibFile = document.getElementById('bibliography-file').value.trim();
const cslFile = document.getElementById('csl-file').value.trim(); const cslFile = document.getElementById('csl-file').value.trim();
@@ -2054,6 +2359,15 @@ function collectExportOptions() {
if (currentExportFormat === 'pdf') { if (currentExportFormat === 'pdf') {
options.pdfEngine = 'xelatex'; options.pdfEngine = 'xelatex';
options.geometry = 'margin=1in'; options.geometry = 'margin=1in';
} else if (currentExportFormat === 'revealjs') {
options.revealTheme = 'black';
options.revealTransition = 'slide';
options.revealTransitionSpeed = 'default';
options.revealSlideNumber = false;
options.revealControls = true;
options.revealProgress = true;
options.revealHistory = true;
options.revealCenter = true;
} }
} }
@@ -2971,6 +3285,18 @@ function showPDFEditorDialog(operation, openedFilePath = null) {
section.classList.add('hidden'); section.classList.add('hidden');
}); });
// Hide visual thumbnail sidebar by default
const sidebar = document.getElementById('pdf-thumbnail-sidebar');
if (sidebar) {
sidebar.classList.add('hidden');
}
const body = document.querySelector('.pdf-editor-body');
if (body) {
body.classList.remove('side-by-side');
}
pdfDocInstance = null;
pdfPreviewState = { pages: [], filePath: '' };
// Show the appropriate section and set title // Show the appropriate section and set title
let sectionId, titleText; let sectionId, titleText;
switch (operation) { switch (operation) {
@@ -3004,7 +3330,11 @@ function showPDFEditorDialog(operation, openedFilePath = null) {
titleText = 'Rotate Pages'; titleText = 'Rotate Pages';
if (openedFilePath) { if (openedFilePath) {
const rotateInput = document.getElementById('rotate-input-path'); const rotateInput = document.getElementById('rotate-input-path');
if (rotateInput) rotateInput.value = openedFilePath; if (rotateInput) {
rotateInput.value = openedFilePath;
// Trigger thumbnail generation
setTimeout(() => onPDFFileSelected('rotate-input-path', openedFilePath), 50);
}
} }
break; break;
case 'delete': case 'delete':
@@ -3012,7 +3342,11 @@ function showPDFEditorDialog(operation, openedFilePath = null) {
titleText = 'Delete Pages'; titleText = 'Delete Pages';
if (openedFilePath) { if (openedFilePath) {
const deleteInput = document.getElementById('delete-input-path'); const deleteInput = document.getElementById('delete-input-path');
if (deleteInput) deleteInput.value = openedFilePath; if (deleteInput) {
deleteInput.value = openedFilePath;
// Trigger thumbnail generation
setTimeout(() => onPDFFileSelected('delete-input-path', openedFilePath), 50);
}
} }
break; break;
case 'reorder': case 'reorder':
@@ -3020,7 +3354,11 @@ function showPDFEditorDialog(operation, openedFilePath = null) {
titleText = 'Reorder Pages'; titleText = 'Reorder Pages';
if (openedFilePath) { if (openedFilePath) {
const reorderInput = document.getElementById('reorder-input-path'); const reorderInput = document.getElementById('reorder-input-path');
if (reorderInput) reorderInput.value = openedFilePath; if (reorderInput) {
reorderInput.value = openedFilePath;
// Trigger thumbnail generation
setTimeout(() => onPDFFileSelected('reorder-input-path', openedFilePath), 50);
}
} }
break; break;
case 'watermark': case 'watermark':
@@ -3181,6 +3519,7 @@ document.addEventListener('DOMContentLoaded', () => {
const file = e.target.files[0]; const file = e.target.files[0];
if (file) { if (file) {
document.getElementById(button.inputId).value = file.path; document.getElementById(button.inputId).value = file.path;
onPDFFileSelected(button.inputId, file.path);
} }
}; };
input.click(); input.click();
@@ -3190,6 +3529,7 @@ document.addEventListener('DOMContentLoaded', () => {
const file = e.target.files[0]; const file = e.target.files[0];
if (file) { if (file) {
document.getElementById(button.inputId).value = file.path; document.getElementById(button.inputId).value = file.path;
onPDFFileSelected(button.inputId, file.path);
} }
}; };
input.click(); input.click();
@@ -3334,7 +3674,7 @@ function showPDFValidationMessage(message, focusSelector = null) {
// Process PDF Operation // Process PDF Operation
function processPDFOperation() { function processPDFOperation() {
const operation = currentPDFOperation; const operation = currentPDFOperation;
let operationData = { operation }; const operationData = { operation };
try { try {
switch (operation) { switch (operation) {
@@ -4124,7 +4464,7 @@ function textToASCII(text, style) {
lines[i] = ''; lines[i] = '';
} }
for (let char of upperText) { for (const char of upperText) {
const charLines = font.chars[char] || font.chars[' ']; const charLines = font.chars[char] || font.chars[' '];
if (charLines) { if (charLines) {
for (let i = 0; i < font.height; i++) { for (let i = 0; i < font.height; i++) {
@@ -4162,7 +4502,7 @@ function createASCIIBox(text, style, padding) {
} }
// Content lines // Content lines
for (let line of lines) { for (const line of lines) {
const paddedLine = line.padEnd(maxLength, ' '); const paddedLine = line.padEnd(maxLength, ' ');
result += chars.v + ' '.repeat(padding) + paddedLine + ' '.repeat(padding) + chars.v + '\n'; result += chars.v + ' '.repeat(padding) + paddedLine + ' '.repeat(padding) + chars.v + '\n';
} }
@@ -4798,3 +5138,224 @@ ipcRenderer.on('show-video-tool', (event, tool) => {
{ duration: 5000 } { duration: 5000 }
); );
}); });
// ============================================
// PDF EDITOR VISUAL PAGE SIDEBAR HANDLERS
// ============================================
let pdfDocInstance = null;
let pdfPreviewState = { pages: [], filePath: '' };
function onPDFFileSelected(inputId, filePath) {
const eligibleInputs = ['rotate-input-path', 'delete-input-path', 'reorder-input-path'];
if (!eligibleInputs.includes(inputId)) {
return;
}
const sidebar = document.getElementById('pdf-thumbnail-sidebar');
if (sidebar) {
sidebar.classList.remove('hidden');
}
const body = document.querySelector('.pdf-editor-body');
if (body) {
body.classList.add('side-by-side');
}
loadPDFThumbnails(filePath);
}
async function loadPDFThumbnails(filePath) {
const grid = document.getElementById('pdf-thumbnail-grid');
if (!grid) return;
// Reset previous inputs
const rotateInput = document.getElementById('rotate-pages');
if (rotateInput) rotateInput.value = '';
const deleteInput = document.getElementById('delete-pages');
if (deleteInput) deleteInput.value = '';
const reorderInput = document.getElementById('reorder-pages');
if (reorderInput) reorderInput.value = '';
grid.innerHTML = '<div class="loading-thumbnails">Loading page thumbnails...</div>';
try {
const pdfjs = getPdfjsLib();
const loadingTask = pdfjs.getDocument(filePath);
pdfDocInstance = await loadingTask.promise;
pdfPreviewState.filePath = filePath;
pdfPreviewState.pages = [];
for (let i = 1; i <= pdfDocInstance.numPages; i++) {
pdfPreviewState.pages.push({
originalPageNum: i,
rotation: 0,
isDeleted: false
});
}
renderThumbnailGrid();
} catch (err) {
console.error('Error loading PDF thumbnails:', err);
grid.innerHTML = '<div class="thumbnail-error">Failed to load PDF thumbnails.</div>';
}
}
function renderThumbnailGrid() {
const grid = document.getElementById('pdf-thumbnail-grid');
if (!grid || !pdfDocInstance) return;
grid.innerHTML = '';
pdfPreviewState.pages.forEach((pageInfo, index) => {
const card = document.createElement('div');
card.className = 'pdf-thumbnail-card';
if (pageInfo.isDeleted) {
card.classList.add('marked-delete');
}
const heading = document.createElement('div');
heading.className = 'pdf-thumbnail-header';
heading.innerHTML = `<span class="badge">Page ${pageInfo.originalPageNum}</span>`;
const canvasContainer = document.createElement('div');
canvasContainer.className = 'canvas-container';
const canvas = document.createElement('canvas');
canvas.className = 'pdf-thumbnail-canvas';
if (pageInfo.rotation > 0) {
canvas.classList.add('rot' + pageInfo.rotation);
}
canvasContainer.appendChild(canvas);
// Render thumbnail canvas
renderThumbnail(pdfDocInstance, pageInfo.originalPageNum, canvas, pageInfo.rotation);
const controlsRow = document.createElement('div');
controlsRow.className = 'pdf-thumbnail-controls';
// Rotate Button
const rotateBtn = document.createElement('button');
rotateBtn.type = 'button';
rotateBtn.className = 'btn-rotate-thumbnail';
rotateBtn.innerHTML = '↻ Rotate 90°';
rotateBtn.addEventListener('click', () => {
pageInfo.rotation = (pageInfo.rotation + 90) % 360;
canvas.className = 'pdf-thumbnail-canvas';
if (pageInfo.rotation > 0) {
canvas.classList.add('rot' + pageInfo.rotation);
}
renderThumbnail(pdfDocInstance, pageInfo.originalPageNum, canvas, pageInfo.rotation);
syncRotateInput();
});
// Delete Checkbox
const deleteLabel = document.createElement('label');
deleteLabel.className = 'delete-thumbnail-checkbox';
const deleteChk = document.createElement('input');
deleteChk.type = 'checkbox';
deleteChk.checked = pageInfo.isDeleted;
deleteChk.addEventListener('change', (e) => {
pageInfo.isDeleted = e.target.checked;
card.classList.toggle('marked-delete', pageInfo.isDeleted);
syncDeleteInput();
});
deleteLabel.appendChild(deleteChk);
deleteLabel.appendChild(document.createTextNode(' Delete'));
// Reorder buttons
const reorderDiv = document.createElement('div');
reorderDiv.className = 'pdf-thumbnail-reorder';
const leftBtn = document.createElement('button');
leftBtn.type = 'button';
leftBtn.className = 'btn-reorder-left';
leftBtn.innerHTML = '◀';
leftBtn.disabled = (index === 0);
leftBtn.addEventListener('click', () => {
const temp = pdfPreviewState.pages[index];
pdfPreviewState.pages[index] = pdfPreviewState.pages[index - 1];
pdfPreviewState.pages[index - 1] = temp;
renderThumbnailGrid();
syncReorderInput();
});
const rightBtn = document.createElement('button');
rightBtn.type = 'button';
rightBtn.className = 'btn-reorder-right';
rightBtn.innerHTML = '▶';
rightBtn.disabled = (index === pdfPreviewState.pages.length - 1);
rightBtn.addEventListener('click', () => {
const temp = pdfPreviewState.pages[index];
pdfPreviewState.pages[index] = pdfPreviewState.pages[index + 1];
pdfPreviewState.pages[index + 1] = temp;
renderThumbnailGrid();
syncReorderInput();
});
reorderDiv.appendChild(leftBtn);
reorderDiv.appendChild(rightBtn);
controlsRow.appendChild(rotateBtn);
controlsRow.appendChild(deleteLabel);
controlsRow.appendChild(reorderDiv);
card.appendChild(heading);
card.appendChild(canvasContainer);
card.appendChild(controlsRow);
grid.appendChild(card);
});
}
async function renderThumbnail(pdfDoc, originalPageNum, canvasElement, rotation) {
try {
const page = await pdfDoc.getPage(originalPageNum);
const ctx = canvasElement.getContext('2d');
// Render at a fixed width of 100px
const unscaledViewport = page.getViewport({ scale: 1, rotation: 0 });
const scale = 100 / unscaledViewport.width;
const viewport = page.getViewport({ scale: scale, rotation: 0 });
canvasElement.width = viewport.width;
canvasElement.height = viewport.height;
await page.render({
canvasContext: ctx,
viewport: viewport
}).promise;
} catch (err) {
console.error('Error rendering page canvas:', err);
}
}
function syncRotateInput() {
const rotatedPages = pdfPreviewState.pages
.filter(p => p.rotation > 0)
.map(p => p.originalPageNum);
const rotateInput = document.getElementById('rotate-pages');
if (rotateInput) {
rotateInput.value = rotatedPages.join(', ');
}
}
function syncDeleteInput() {
const deletedPages = pdfPreviewState.pages
.filter(p => p.isDeleted)
.map(p => p.originalPageNum);
const deleteInput = document.getElementById('delete-pages');
if (deleteInput) {
deleteInput.value = deletedPages.join(', ');
}
}
function syncReorderInput() {
const order = pdfPreviewState.pages.map(p => p.originalPageNum);
const reorderInput = document.getElementById('reorder-pages');
if (reorderInput) {
reorderInput.value = order.join(', ');
}
}
+251
View File
@@ -2372,3 +2372,254 @@ body[class*="dark"] .breadcrumb-bar {
@keyframes analyticsSlideUp { from{transform:translateY(16px);opacity:0} to{transform:translateY(0);opacity:1} } @keyframes analyticsSlideUp { from{transform:translateY(16px);opacity:0} to{transform:translateY(0);opacity:1} }
body[class*="dark"] .analytics-modal { background:var(--gray-800,#1f2937); } body[class*="dark"] .analytics-modal { background:var(--gray-800,#1f2937); }
body[class*="dark"] .word-tag { background:var(--gray-700,#374151); } body[class*="dark"] .word-tag { background:var(--gray-700,#374151); }
/* ========================================
Interactive PDF Thumbnail Sidebar Styles
======================================== */
.pdf-editor-body.side-by-side {
display: flex;
flex-direction: row;
gap: 16px;
height: 550px;
overflow: hidden;
}
.pdf-editor-left-panel {
flex: 1.2;
overflow-y: auto;
padding-right: 8px;
height: 100%;
}
.pdf-editor-right-panel {
flex: 0.8;
display: flex;
flex-direction: column;
border-left: 1px solid var(--border-color, #e5e7eb);
padding-left: 16px;
height: 100%;
overflow: hidden;
}
.pdf-editor-right-panel h4 {
margin: 0 0 4px 0;
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.pdf-editor-right-panel .sidebar-help {
margin: 0 0 12px 0;
font-size: 11px;
color: var(--text-secondary, #6b7280);
line-height: 1.4;
}
.pdf-thumbnail-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 12px;
overflow-y: auto;
padding: 4px;
}
.pdf-thumbnail-card {
display: flex;
flex-direction: column;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 8px;
background: var(--bg-primary, #ffffff);
box-shadow: var(--shadow-sm);
padding: 8px;
transition: all var(--transition-speed, 0.3s) var(--transition-ease);
position: relative;
overflow: hidden;
}
.pdf-thumbnail-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
border-color: var(--primary-light, #8b9aff);
}
.pdf-thumbnail-card.marked-delete {
border-color: var(--error, #ef4444);
background: rgba(239, 68, 68, 0.05);
}
.pdf-thumbnail-card.marked-delete::after {
content: "DELETE";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-15deg);
background: rgba(239, 68, 68, 0.95);
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 4px 8px;
border-radius: 4px;
pointer-events: none;
box-shadow: var(--shadow-sm);
letter-spacing: 0.5px;
white-space: nowrap;
}
.pdf-thumbnail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.pdf-thumbnail-header .badge {
font-size: 11px;
font-weight: 600;
background: var(--bg-tertiary, #f3f4f6);
color: var(--text-primary);
padding: 2px 6px;
border-radius: 4px;
font-family: var(--font-mono, monospace);
}
.canvas-container {
display: flex;
justify-content: center;
align-items: center;
background: var(--bg-secondary, #f9fafb);
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
padding: 6px;
height: 120px;
overflow: hidden;
}
.pdf-thumbnail-canvas {
max-width: 100%;
max-height: 100%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
transition: transform 0.3s ease;
}
/* CSS Rotations */
.rot90 {
transform: rotate(90deg);
}
.rot180 {
transform: rotate(180deg);
}
.rot270 {
transform: rotate(270deg);
}
.pdf-thumbnail-controls {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
}
.pdf-thumbnail-controls button,
.pdf-thumbnail-controls label {
font-size: 11px;
}
.btn-rotate-thumbnail {
background: var(--bg-tertiary, #f3f4f6);
border: 1px solid var(--border-color, #e5e7eb);
color: var(--text-primary);
border-radius: 4px;
padding: 3px 6px;
cursor: pointer;
width: 100%;
text-align: center;
transition: all 0.2s;
font-weight: 500;
}
.btn-rotate-thumbnail:hover {
background: var(--primary-light, #8b9aff);
color: #fff;
border-color: var(--primary-dark);
}
.delete-thumbnail-checkbox {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
cursor: pointer;
user-select: none;
font-weight: 500;
color: var(--text-secondary);
}
.delete-thumbnail-checkbox input {
margin: 0;
cursor: pointer;
}
.pdf-thumbnail-reorder {
display: flex;
justify-content: space-between;
gap: 4px;
}
.btn-reorder-left,
.btn-reorder-right {
flex: 1;
background: var(--bg-tertiary, #f3f4f6);
border: 1px solid var(--border-color, #e5e7eb);
color: var(--text-primary);
border-radius: 4px;
padding: 2px;
cursor: pointer;
transition: all 0.2s;
}
.btn-reorder-left:hover:not(:disabled),
.btn-reorder-right:hover:not(:disabled) {
background: var(--bg-secondary);
border-color: var(--text-muted);
}
.btn-reorder-left:disabled,
.btn-reorder-right:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.loading-thumbnails,
.thumbnail-error {
grid-column: 1 / -1;
text-align: center;
padding: 40px 20px;
font-size: 13px;
color: var(--text-muted);
}
.thumbnail-error {
color: var(--error, #ef4444);
}
/* Dark mode adjustments */
body[class*="dark"] .pdf-thumbnail-card {
background: var(--gray-800, #1f2937);
border-color: var(--gray-700, #374151);
}
body[class*="dark"] .pdf-thumbnail-header .badge {
background: var(--gray-700, #374151);
color: var(--gray-200);
}
body[class*="dark"] .canvas-container {
background: var(--gray-900, #111827);
border-color: var(--gray-700);
}
body[class*="dark"] .btn-rotate-thumbnail,
body[class*="dark"] .btn-reorder-left,
body[class*="dark"] .btn-reorder-right {
background: var(--gray-700, #374151);
border-color: var(--gray-600);
color: var(--gray-200);
}
+3 -3
View File
@@ -1,6 +1,6 @@
/** /**
* ModalManager - Unified modal system with accessibility support * ModalManager - Unified modal system with accessibility support
* @version 4.1.0 * @version 4.4.3
*/ */
class ModalManager { class ModalManager {
#modal; #modal;
@@ -237,8 +237,8 @@ class ModalManager {
} }
} }
// Export for use in renderer // Export for use in renderer - avoid duplicate declaration
if (typeof window !== 'undefined') { if (typeof window !== 'undefined' && !window.ModalManager) {
window.ModalManager = ModalManager; window.ModalManager = ModalManager;
} }
+2 -2
View File
@@ -1,4 +1,4 @@
function createWelcomeContent(recentFiles = []) { function createWelcomeContent(recentFiles = [], appVersion = '') {
const recentHtml = recentFiles.length const recentHtml = recentFiles.length
? recentFiles.map(f => { ? recentFiles.map(f => {
const name = f.split(/[/\\]/).pop(); const name = f.split(/[/\\]/).pop();
@@ -10,7 +10,7 @@ function createWelcomeContent(recentFiles = []) {
<div class="welcome-container"> <div class="welcome-container">
<div class="welcome-hero"> <div class="welcome-hero">
<h1 class="welcome-title">MarkdownConverter</h1> <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> <p class="welcome-subtitle">Professional Markdown Editor & Universal Document Converter</p>
</div> </div>
+1 -1
View File
@@ -655,7 +655,7 @@ class WordTemplateExporter {
*/ */
parseInlineFormatting(text) { parseInlineFormatting(text) {
let xml = ''; let xml = '';
let pos = 0; const pos = 0;
// Patterns for inline formatting // Patterns for inline formatting
const patterns = [ const patterns = [
+1 -1
View File
@@ -117,7 +117,7 @@ class ZenMode {
_applyTypewriterBehavior(view) { _applyTypewriterBehavior(view) {
// We use a custom update listener that scrolls the cursor to center. // We use a custom update listener that scrolls the cursor to center.
// This is stored on the instance so we can clean it up. // This is stored on the instance so we can clean it up.
let scrollTimeout = null; const scrollTimeout = null;
const scrollFn = () => { const scrollFn = () => {
if (!this.active) return; if (!this.active) return;
+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']);
});
});
+123
View File
@@ -0,0 +1,123 @@
/**
* Git Operations Utilities Test Suite
* Tests for async patterns and error handling
* @version 4.3.0
*/
describe('GitOperations Utilities', () => {
describe('error handling patterns', () => {
it('should handle git errors gracefully', () => {
const errorResponse = { error: 'Not a git repository' };
expect(errorResponse).toHaveProperty('error');
expect(errorResponse.error).toContain('repository');
});
it('should return error object on failure', () => {
const failureResult = { error: 'Failed to commit' };
expect(failureResult).toBeDefined();
expect(failureResult.error).toBeTruthy();
});
});
describe('async patterns', () => {
it('should handle async operations', async () => {
const asyncFn = async () => {
return { status: 'success' };
};
const result = await asyncFn();
expect(result).toHaveProperty('status');
});
it('should handle async errors', async () => {
const asyncFnWithError = async () => {
throw new Error('Git operation failed');
};
try {
await asyncFnWithError();
expect(true).toBe(false); // Should not reach here
} catch (err) {
expect(err.message).toContain('Git');
}
});
});
describe('git operations', () => {
it('should document expected operations', () => {
const gitOps = [
'getStatus',
'stage',
'commit',
'log',
'diff',
'branch',
'checkout',
'push',
'pull'
];
expect(gitOps.length).toBeGreaterThan(0);
gitOps.forEach(op => {
expect(typeof op).toBe('string');
expect(op.length).toBeGreaterThan(0);
});
});
it('should handle directory paths', () => {
const paths = [
'/home/user/project',
'./current/dir',
'../parent/dir'
];
paths.forEach(pathStr => {
expect(typeof pathStr).toBe('string');
expect(pathStr.length).toBeGreaterThan(0);
});
});
it('should handle commit messages', () => {
const messages = [
'fix: bug in git panel',
'feat: add new feature',
'refactor: clean up code'
];
messages.forEach(msg => {
expect(typeof msg).toBe('string');
expect(msg.length).toBeGreaterThan(0);
});
});
});
describe('response structures', () => {
it('should return status info', () => {
const statusResponse = {
conflicted: [],
created: [],
deleted: [],
modified: [],
renamed: [],
staged: ['file.md']
};
expect(statusResponse).toHaveProperty('staged');
expect(Array.isArray(statusResponse.staged)).toBe(true);
});
it('should return log entries', () => {
const logResponse = {
all: [
{ hash: 'abc123', message: 'fix: something' }
],
latest: { hash: 'abc123', message: 'fix: something' }
};
expect(logResponse).toHaveProperty('all');
expect(Array.isArray(logResponse.all)).toBe(true);
});
});
});
+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);
});
});
+118
View File
@@ -76,6 +76,75 @@ describe('Markdown Extensions', () => {
}); });
}); });
describe('Admonitions full parsing integration (mocked environment)', () => {
const extension = {
name: 'admonition',
level: 'block',
start(src) { return src.match(/^:::(note|warning|tip|danger|info)/m)?.index; },
tokenizer(src) {
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
if (match && match.index === 0) {
const admonitionType = match[1];
const text = match[2].trim();
const tokens = [];
this.lexer.blockTokens(text, tokens);
return {
type: 'admonition',
raw: match[0],
admonitionType,
text,
tokens
};
}
},
renderer(token) {
const icons = { note: '', warning: '⚠', tip: '💡', danger: '🔴', info: '' };
const icon = icons[token.admonitionType] || '';
const inner = this.parser.parse(token.tokens || []);
return `<div class="admonition admonition-${token.admonitionType}">
<div class="admonition-title">${icon} ${token.admonitionType.charAt(0).toUpperCase() + token.admonitionType.slice(1)}</div>
<div class="admonition-content">${inner}</div>
</div>`;
}
};
test('tokenizer correctly extracts tokens and calls blockTokens', () => {
const src = ':::note\nThis is a note.\n:::';
const mockLexer = {
blockTokens: jest.fn((text, tokens) => {
tokens.push({ type: 'text', text });
})
};
const context = { lexer: mockLexer };
const result = extension.tokenizer.call(context, src);
expect(result).toBeDefined();
expect(result.type).toBe('admonition');
expect(result.admonitionType).toBe('note');
expect(result.text).toBe('This is a note.');
expect(mockLexer.blockTokens).toHaveBeenCalledWith('This is a note.', expect.any(Array));
expect(result.tokens).toEqual([{ type: 'text', text: 'This is a note.' }]);
});
test('renderer correctly translates tokens to HTML', () => {
const token = {
type: 'admonition',
admonitionType: 'warning',
tokens: [{ type: 'text', text: 'Be careful!' }]
};
const mockParser = {
parse: jest.fn((tokens) => '<p>Be careful!</p>')
};
const context = { parser: mockParser };
const html = extension.renderer.call(context, token);
expect(html).toContain('admonition admonition-warning');
expect(html).toContain('⚠ Warning');
expect(html).toContain('<p>Be careful!</p>');
expect(mockParser.parse).toHaveBeenCalledWith(token.tokens);
});
});
describe('PlantUML hex encoding', () => { describe('PlantUML hex encoding', () => {
const plantumlEncode = (text) => { const plantumlEncode = (text) => {
const hex = Array.from(Buffer.from(text, 'utf-8')) const hex = Array.from(Buffer.from(text, 'utf-8'))
@@ -127,4 +196,53 @@ describe('Markdown Extensions', () => {
expect(slugify('simple')).toBe('simple'); expect(slugify('simple')).toBe('simple');
}); });
}); });
describe('scopeCSS utility', () => {
const scopeCSS = (cssText, scopeSelector) => {
if (!cssText) return '';
return cssText.replace(/([^\r\n,{}]+)(,(?=[^}]*{)|(?=[^{]*{))/g, (match, selector, separator) => {
const trimmed = selector.trim();
if (!trimmed || trimmed.startsWith('@') || trimmed.startsWith(':root') || trimmed.startsWith('from') || trimmed.startsWith('to') || /^\d+%$/.test(trimmed)) {
return match;
}
return scopeSelector + ' ' + trimmed + (separator || '');
});
};
test('scopes standard tag selector', () => {
const css = 'h1 { color: red; }';
const scoped = scopeCSS(css, '.preview-content');
expect(scoped).toBe('.preview-content h1{ color: red; }');
});
test('scopes multiple class selectors', () => {
const css = '.title, .content { font-family: sans-serif; }';
const scoped = scopeCSS(css, '.preview-content');
expect(scoped).toBe('.preview-content .title,.preview-content .content{ font-family: sans-serif; }');
});
test('ignores @rules like @media', () => {
const css = '@media (max-width: 600px) { h1 { color: blue; } }';
const scoped = scopeCSS(css, '.preview-content');
expect(scoped).toContain('@media (max-width: 600px)');
});
test('ignores :root selector', () => {
const css = ':root { --color: red; }';
const scoped = scopeCSS(css, '.preview-content');
expect(scoped).toBe(':root { --color: red; }');
});
test('ignores keyframe percentages', () => {
const css = '0% { opacity: 0; } 100% { opacity: 1; }';
const scoped = scopeCSS(css, '.preview-content');
expect(scoped).toContain('0%');
expect(scoped).toContain('100%');
});
test('handles empty input', () => {
expect(scopeCSS('', '.preview-content')).toBe('');
expect(scopeCSS(null, '.preview-content')).toBe('');
});
});
}); });
+95
View File
@@ -0,0 +1,95 @@
/**
* PDF Operations Utilities Test Suite
* Tests for helper functions and logic patterns
* @version 4.3.0
*/
describe('PDFOperations Utilities', () => {
describe('page range parsing', () => {
it('should parse single page numbers', () => {
// Test logic: parsing "1" should extract page index 0
const input = '1';
const pages = [0]; // Parsed result
expect(pages.length).toBe(1);
expect(pages[0]).toBe(0);
});
it('should parse page ranges', () => {
// Test logic: parsing "1-3" should extract pages 0, 1, 2
const input = '1-3';
const pages = [0, 1, 2]; // Expected result
expect(pages.length).toBe(3);
expect(pages).toEqual([0, 1, 2]);
});
it('should handle multiple ranges', () => {
// Test logic: parsing "1-2,4-5" should extract pages 0,1,3,4
const input = '1-2,4-5';
const pages = [0, 1, 3, 4]; // Expected result
expect(pages.length).toBe(4);
expect(pages).toEqual([0, 1, 3, 4]);
});
it('should sort pages in ascending order', () => {
const unsorted = [2, 0, 3, 1];
const sorted = unsorted.sort((a, b) => a - b);
expect(sorted).toEqual([0, 1, 2, 3]);
});
});
describe('hex color conversion', () => {
it('should validate hex color format', () => {
const validHex = '#FF5733';
const isValid = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.test(validHex);
expect(isValid).toBe(true);
});
it('should detect invalid hex colors', () => {
const invalidColors = ['#GG5733', '#12345', 'notahex'];
invalidColors.forEach(color => {
const isValid = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.test(color);
expect(isValid).toBe(false);
});
});
});
describe('error handling', () => {
it('should handle empty input', () => {
const input = '';
expect(input.length).toBe(0);
// Should not process empty strings
expect(input === '').toBe(true);
});
it('should handle invalid page numbers', () => {
const testPages = [
{ input: '0', isValid: false },
{ input: '-1', isValid: false },
{ input: 'abc', isValid: false },
{ input: '1', isValid: true },
{ input: '5', isValid: true }
];
testPages.forEach(({ input, isValid }) => {
const num = parseInt(input);
if (isNaN(num)) {
// Non-numeric string
expect(isValid).toBe(false);
} else if (num < 1) {
// Zero or negative
expect(isValid).toBe(false);
} else {
// Positive number
expect(isValid).toBe(true);
}
});
});
});
});
+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');
});
});
+114
View File
@@ -0,0 +1,114 @@
/**
* Security: Path Traversal Prevention Test Suite
* @version 4.3.0
*/
const path = require('path');
describe('Security: Path Handling', () => {
describe('path traversal prevention', () => {
it('should detect path traversal patterns', () => {
const maliciousPaths = [
'../etc/passwd',
'../../sensitive',
'./../outside'
];
maliciousPaths.forEach(pathStr => {
// Path traversal attempts contain .. patterns
expect(pathStr).toMatch(/\.\./);
});
});
it('should normalize relative paths safely', () => {
const safePaths = [
'./documents/file.md',
'relative/path/file.txt'
];
safePaths.forEach(pathStr => {
const normalized = path.normalize(pathStr);
// Safe relative paths should normalize cleanly
expect(normalized).toBeDefined();
expect(typeof normalized).toBe('string');
});
});
it('should detect absolute paths', () => {
const absolutePath = '/etc/passwd';
const isAbsolute = path.isAbsolute(absolutePath);
// Linux/Mac: /path is absolute
if (process.platform !== 'win32') {
expect(isAbsolute).toBe(true);
}
});
it('should safely join paths with base directory', () => {
const baseDir = '/safe/base/directory';
const userInput = 'documents/file.md';
const joined = path.join(baseDir, userInput);
// Result should contain the safe base
expect(joined).toContain('base');
expect(joined).toContain('documents');
});
});
describe('filename safety', () => {
it('should identify safe filenames', () => {
const safeNames = [
'document.md',
'my-file.txt',
'file_name.pdf',
'report_2026_04_24.xlsx'
];
safeNames.forEach(name => {
// Safe names should not contain path separators or null bytes
const isSafe = !/[\\/\0]/.test(name) && name.length > 0;
expect(isSafe).toBe(true);
});
});
it('should flag filenames with path separators', () => {
const problematicNames = [
'file/with/slashes.txt',
'file\\with\\backslashes.txt'
];
problematicNames.forEach(name => {
// These contain path separators and should be flagged
const hasPathSeparators = /[\\/]/.test(name);
expect(hasPathSeparators).toBe(true);
});
});
it('should enforce minimum filename length', () => {
const emptyName = '';
expect(emptyName.length).toBe(0);
expect(emptyName.length > 0).toBe(false);
});
});
describe('validation patterns', () => {
it('should validate path existence check pattern', () => {
const validationPattern = /^[a-zA-Z0-9._\-/]+$/;
const validPaths = ['documents/file.md', 'folder_2026/data.csv'];
validPaths.forEach(pathStr => {
// These should match a reasonable filename pattern
expect(typeof pathStr).toBe('string');
});
});
it('should prevent null byte injection', () => {
const pathWithNullByte = 'file.txt\0.exe';
const isSafe = !pathWithNullByte.includes('\0');
expect(isSafe).toBe(false); // Has null byte, not safe
});
});
});
+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');
});
});