mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6747b12f0 | ||
|
|
7a641b2618 | ||
|
|
edefcb8409 | ||
|
|
5ee986fab8 | ||
|
|
c1573dba08 | ||
|
|
9576abc979 | ||
|
|
caa3f3d35a | ||
|
|
2a6f0fc302 | ||
|
|
4da5b7b9c4 | ||
|
|
1ba42592d7 | ||
|
|
bc6f1a7d41 | ||
|
|
539502d7ff | ||
|
|
b5771dd914 | ||
|
|
a816b6ec32 | ||
|
|
54c9484cb5 | ||
|
|
36e26318cd | ||
|
|
8abd580295 | ||
|
|
80294c9876 | ||
|
|
148b549c83 | ||
|
|
a3b4065984 | ||
|
|
620227307d | ||
|
|
4426b75c6f | ||
|
|
b7e12f7010 | ||
|
|
f0ab54cd60 | ||
|
|
72a7a854d0 | ||
|
|
50d0638c5c | ||
|
|
7a3493e54f | ||
|
|
b1a5784bcc | ||
|
|
24cb99658e | ||
|
|
c042cf4580 |
@@ -23,3 +23,6 @@ jobs:
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Run linter
|
||||
run: npm run lint
|
||||
|
||||
@@ -19,17 +19,17 @@ jobs:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install rpmbuild
|
||||
run: sudo apt-get update && sudo apt-get install -y rpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Download external tools (pandoc)
|
||||
run: node scripts/download-tools.js
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Build Linux packages
|
||||
run: npm run build:linux
|
||||
run: npm run build:linux-ci -- --publish=never
|
||||
|
||||
- name: Upload Linux artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
retention-days: 5
|
||||
|
||||
build-windows:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -53,17 +53,37 @@ jobs:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install Wine and NSIS
|
||||
run: |
|
||||
sudo dpkg --add-architecture i386
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y wine64 wine32 nsis
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Windows packages
|
||||
run: npm run build:win
|
||||
- name: Download external tools (pandoc)
|
||||
run: node scripts/download-tools.js
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Decode certificate (if available)
|
||||
if: ${{ env.CSC_LINK_BASE64 != '' }}
|
||||
shell: pwsh
|
||||
env:
|
||||
CSC_LINK_BASE64: ${{ secrets.CSC_LINK_BASE64 }}
|
||||
run: |
|
||||
$bytes = [Convert]::FromBase64String("$env:CSC_LINK_BASE64")
|
||||
[IO.File]::WriteAllBytes("${{ github.workspace }}\code-signing-cert.pfx", $bytes)
|
||||
echo "CERT_AVAILABLE=true" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Build Windows packages (signed)
|
||||
if: ${{ env.CERT_AVAILABLE == 'true' }}
|
||||
env:
|
||||
CSC_LINK: code-signing-cert.pfx
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
run: npm run build:win-signed -- --publish=never
|
||||
|
||||
- name: Build Windows packages (unsigned)
|
||||
if: ${{ env.CERT_AVAILABLE != 'true' }}
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
run: npm run build:win-unsigned -- --publish=never
|
||||
|
||||
- name: Upload Windows artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -71,23 +91,26 @@ jobs:
|
||||
name: windows-artifacts
|
||||
path: |
|
||||
dist/*.exe
|
||||
dist/*-win.zip
|
||||
dist/*.zip
|
||||
retention-days: 5
|
||||
|
||||
release:
|
||||
needs: [build-linux, build-windows]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download Linux artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: linux-artifacts
|
||||
path: dist
|
||||
|
||||
- name: Download Windows artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: windows-artifacts
|
||||
path: dist
|
||||
|
||||
+15
-3
@@ -8,14 +8,22 @@ Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.vscode/
|
||||
.vscode/*
|
||||
!.vscode/launch.json
|
||||
.idea/
|
||||
*.iml
|
||||
out/
|
||||
.cache/
|
||||
.npm/
|
||||
.electron/
|
||||
package-lock.json
|
||||
# package-lock.json is intentionally tracked for reproducible CI builds
|
||||
|
||||
# Downloaded tool binaries (fetched at build time via scripts/download-tools.js)
|
||||
bin/
|
||||
|
||||
# Code signing certificates — never commit private keys
|
||||
*.pfx
|
||||
*.p12
|
||||
|
||||
# Screenshots and temp files
|
||||
*.png.bak
|
||||
@@ -33,4 +41,8 @@ pdf\ modal.png
|
||||
# Claude/AI development files
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
agents.mdcoverage/
|
||||
agents.md
|
||||
coverage/
|
||||
|
||||
# Superpowers brainstorm artifacts
|
||||
.superpowers/
|
||||
|
||||
Vendored
+48
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debug Main Process",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
|
||||
"windows": {
|
||||
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd"
|
||||
},
|
||||
"args": ["."],
|
||||
"outputCapture": "std",
|
||||
"env": {
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Debug Renderer Process",
|
||||
"type": "chrome",
|
||||
"request": "attach",
|
||||
"port": 9222,
|
||||
"webRoot": "${workspaceFolder}/src",
|
||||
"timeout": 30000
|
||||
},
|
||||
{
|
||||
"name": "Debug Main + Renderer",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
|
||||
"windows": {
|
||||
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd"
|
||||
},
|
||||
"args": [".", "--remote-debugging-port=9222"],
|
||||
"outputCapture": "std",
|
||||
"env": {
|
||||
"NODE_ENV": "development"
|
||||
},
|
||||
"serverReadyAction": {
|
||||
"pattern": "listening on port ([0-9]+)",
|
||||
"uriFormat": "http://localhost:%s",
|
||||
"action": "debugWithChrome"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
Core application code lives in `src/`. Use `src/main.js` for the Electron main process, `src/preload.js` for the preload bridge, and `src/renderer.js` plus `src/editor/`, `src/sidebar/`, `src/repl/`, and `src/utils/` for renderer-side features. Electron adapter code is in `src/adapters/electron/`. Reusable markdown/document templates live in `src/templates/`. Static assets and icons are in `assets/`. Tests are in `tests/`, and build output goes to `dist/`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `npm start`: launch the Electron app locally.
|
||||
- `npm test`: run the Jest suite once.
|
||||
- `npm run test:watch`: rerun tests during local development.
|
||||
- `npm run test:coverage`: generate coverage output.
|
||||
- `npm run lint` / `npm run lint:fix`: check or fix ESLint issues in `src` and `tests`.
|
||||
- `npm run format` / `npm run format:check`: apply or verify Prettier formatting.
|
||||
- `npm run build:linux`, `npm run build:win`, `npm run build:mac`: create platform packages with `electron-builder`.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
This repo uses Prettier and ESLint. Follow `.prettierrc`: 2-space indentation, single quotes, semicolons, trailing commas where valid in ES5, and a 100-character line width. Prefer `camelCase` for variables/functions, `PascalCase` for classes, and kebab-case for file names only when already established. Keep module boundaries clear: UI logic in renderer modules, OS/file-system work behind Electron IPC and adapters.
|
||||
|
||||
## Testing Guidelines
|
||||
Tests use Jest with `jest-environment-jsdom`. Add new tests under `tests/` with `*.test.js` names, mirroring the feature area when possible, for example `tests/sidebar.test.js` or `tests/print-preview.test.js`. Update or add regression tests for renderer behavior, preload APIs, and utility helpers when fixing bugs. Run `npm test` before opening a PR; use `npm run test:coverage` for larger refactors.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
Recent history follows Conventional Commit prefixes such as `feat:`, `fix:`, and `refactor:`. Keep subjects short and imperative, for example `fix: guard modal cleanup on close`. PRs should describe the user-visible change, note test coverage, link any related issue, and include screenshots or GIFs for UI changes.
|
||||
|
||||
## Security & Configuration Tips
|
||||
Do not bypass preload boundaries or introduce direct `eval`/dynamic code paths; ESLint already treats these as errors. Export and conversion features depend on external tools such as Pandoc, FFmpeg, ImageMagick, and LibreOffice, so document any new runtime dependency in `README.md` and packaging config.
|
||||
@@ -162,4 +162,4 @@ Amit Haridas (amit.wh@gmail.com)
|
||||
|
||||
## Version
|
||||
|
||||
v3.0.0
|
||||
v4.1.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MarkdownConverter - STRIDE Threat Model Analysis
|
||||
|
||||
**Version:** 4.0.0
|
||||
**Version:** 4.1.0
|
||||
**Date:** 2026-03-15
|
||||
**Methodology:** STRIDE + MITRE ATT&CK Mapping
|
||||
**Analyst:** Security Assessment Team
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
# Writer's Studio Feature Pack — Design Document
|
||||
|
||||
**Date**: 2026-04-06
|
||||
**Version**: 4.2.0 target
|
||||
**Status**: Approved
|
||||
**Scope**: Three cohesive features to transform MarkdownConverter into a writing environment
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Writer's Studio Feature Pack adds three interconnected features to MarkdownConverter:
|
||||
|
||||
1. **Zen Mode** — Distraction-free writing environment with typewriter scrolling
|
||||
2. **Document Outline** — Heading hierarchy sidebar panel for navigation
|
||||
3. **Writing Analytics** — Real-time readability and vocabulary analysis dashboard
|
||||
|
||||
These features work together: Zen Mode creates the environment, Outline provides navigation, Analytics gives insight.
|
||||
|
||||
---
|
||||
|
||||
## Feature 1: Zen Mode
|
||||
|
||||
### Purpose
|
||||
|
||||
Transform the app from a multi-tool into a focused writing environment. Inspired by iA Writer, Typora, and Bear.
|
||||
|
||||
### New Files
|
||||
|
||||
- `src/zen-mode.js` — ZenMode class (~150 lines)
|
||||
- `src/styles-zen.css` — Zen mode specific styles (~120 lines)
|
||||
|
||||
### Integration Points
|
||||
|
||||
- `src/renderer.js` — Initialize ZenMode, register F11 shortcut, add View > Zen Mode menu
|
||||
- `src/editor/codemirror-setup.js` — Export typewriter + dimming extensions
|
||||
|
||||
### Behavior
|
||||
|
||||
**Toggle**: F11, View > Zen Mode, command palette "Toggle Zen Mode"
|
||||
**Exit**: Escape key, F11 again
|
||||
|
||||
**What hides**:
|
||||
- Tab bar
|
||||
- Toolbar
|
||||
- Sidebar (collapsed)
|
||||
- Status bar
|
||||
- App header
|
||||
|
||||
**What shows**:
|
||||
- Editor (full viewport)
|
||||
- Floating HUD (bottom-center, semi-transparent)
|
||||
|
||||
### Floating HUD
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 847 words • ~4 min • 23:45 session │
|
||||
│ ████████████████░░░░ 85% of 1000 │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Word count (from existing status bar logic)
|
||||
- Estimated reading time (~200 wpm)
|
||||
- Session timer (starts when zen mode activates)
|
||||
- Optional progress bar toward word goal
|
||||
|
||||
### CodeMirror Extensions
|
||||
|
||||
**Typewriter Scroll** (`ViewPlugin`):
|
||||
- Listens to `EditorView.update` for selection changes
|
||||
- Calls `editor.dispatch({ effects: EditorView.scrollIntoView(pos, { y: 'center' }) })`
|
||||
- Smooth scrolling with `scrollBehavior: 'smooth'` in CSS
|
||||
|
||||
**Line Dimming** (`ViewPlugin` + `Decoration`):
|
||||
- Builds a `DecorationSet` mapping each line to an opacity value
|
||||
- Active line: opacity 1.0
|
||||
- 1-2 lines away: 0.7
|
||||
- 3-4 lines away: 0.5
|
||||
- 5+ lines away: 0.3
|
||||
- Uses `Decoration.line({ attributes: { style: 'opacity: X' } })`
|
||||
|
||||
### Centered Column
|
||||
|
||||
CSS applied to `.zen-mode .cm-content`:
|
||||
```css
|
||||
.zen-mode .cm-content {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
font-size: 18px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
- `this.previousState` stores which UI elements were visible before zen mode
|
||||
- On exit, restores all elements to their previous visibility
|
||||
- Editor content, cursor position, and scroll state are never modified
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: Document Outline Panel
|
||||
|
||||
### Purpose
|
||||
|
||||
Provide always-visible heading navigation for documents of any length. The single most-requested navigation feature for multi-section documents.
|
||||
|
||||
### New Files
|
||||
|
||||
- `src/sidebar/outline-panel.js` — `renderOutlinePanel` function (~100 lines)
|
||||
|
||||
### Modified Files
|
||||
|
||||
- `src/index.html` — Add outline icon button in sidebar icons strip
|
||||
- `src/renderer.js` — Register 'outline' panel, provide editor reference
|
||||
|
||||
### Sidebar Integration
|
||||
|
||||
Uses existing `SidebarManager.registerPanel()` API:
|
||||
```javascript
|
||||
sidebarManager.registerPanel('outline', {
|
||||
title: 'Outline',
|
||||
render: (container) => renderOutlinePanel(container, editor, editorContent)
|
||||
});
|
||||
```
|
||||
|
||||
New icon button in sidebar strip (after templates icon):
|
||||
```html
|
||||
<button class="sidebar-icon" data-panel="outline" title="Outline (Ctrl+Shift+O)">
|
||||
<!-- hierarchy/list icon SVG -->
|
||||
</button>
|
||||
```
|
||||
|
||||
### Parsing Logic
|
||||
|
||||
Parse headings from raw markdown content using regex:
|
||||
```javascript
|
||||
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
|
||||
```
|
||||
|
||||
Returns array of:
|
||||
```javascript
|
||||
{ level: 1-6, text: "Heading Text", line: 42 }
|
||||
```
|
||||
|
||||
Debounced at 300ms to avoid re-parsing on every keystroke.
|
||||
|
||||
### UI Structure
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ OUTLINE ☰ │
|
||||
├──────────────────────────────────────────┤
|
||||
│ ▸ Introduction (H1) │
|
||||
│ ▸ Getting Started (H2) │
|
||||
│ ▸ Prerequisites (H2) │
|
||||
│ ▸ Node.js (H3) ◄ │
|
||||
│ ▸ Installation (H2) │
|
||||
│ ▸ Features (H1) │
|
||||
│ ▸ Editor (H2) │
|
||||
│ ▸ Export (H2) │
|
||||
├──────────────────────────────────────────┤
|
||||
│ 9 headings • 2 H1 • 4 H2 • 3 H3 │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Indentation based on heading level (H1 = 0px, H2 = 16px, H3 = 32px, etc.)
|
||||
- Current heading highlighted with accent color (◄ indicator)
|
||||
- Hover shows full heading text if truncated
|
||||
|
||||
### Click-to-Navigate
|
||||
|
||||
```javascript
|
||||
editor.dispatch({
|
||||
effects: EditorView.scrollIntoView(linePos, { y: 'center' })
|
||||
});
|
||||
```
|
||||
|
||||
Brief highlight animation on the target line (fades out over 500ms).
|
||||
|
||||
### Current Heading Sync
|
||||
|
||||
On editor update (debounced 100ms):
|
||||
1. Get cursor line number
|
||||
2. Find the last heading whose line number <= cursor line
|
||||
3. Set that heading as active in the outline
|
||||
|
||||
### Empty State
|
||||
|
||||
When no headings found:
|
||||
```
|
||||
No headings found
|
||||
|
||||
Use # to create headings:
|
||||
# Heading 1
|
||||
## Heading 2
|
||||
### Heading 3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Feature 3: Writing Analytics
|
||||
|
||||
### Purpose
|
||||
|
||||
Give writers real-time insight into their document's readability, structure, and vocabulary. This is the "surprise" feature most Markdown editors lack.
|
||||
|
||||
### New Files
|
||||
|
||||
- `src/analytics/writing-analytics.js` — `WritingAnalytics` class (~180 lines)
|
||||
- `src/analytics/analytics-panel.js` — `renderAnalyticsPanel` function (~120 lines)
|
||||
|
||||
### Integration Points
|
||||
|
||||
- `src/renderer.js` — Register Ctrl+Shift+A shortcut, command palette entry, View menu item
|
||||
|
||||
### Trigger
|
||||
|
||||
- Keyboard: `Ctrl+Shift+A`
|
||||
- Command Palette: "Show Writing Analytics"
|
||||
- Menu: View > Writing Analytics
|
||||
|
||||
### Presentation
|
||||
|
||||
Uses existing `ModalManager` to show a modal overlay with analytics dashboard.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Writing Analytics ✕ │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─ Readability ──────────────────────────────────┐ │
|
||||
│ │ Flesch Reading Ease: 67.3 (Standard) ○ │ │
|
||||
│ │ Grade Level: 8.2 ○○○●○ │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Timing ───────────────────────────────────────┐ │
|
||||
│ │ Reading Time: ~4 min │ │
|
||||
│ │ Speaking Time: ~6 min │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Structure ────────────────────────────────────┐ │
|
||||
│ │ Sentences: 42 • Paragraphs: 8 │ │
|
||||
│ │ Avg Sentence: 14.2 words │ │
|
||||
│ │ Longest: 38 words ("The quick brown fox...") │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Vocabulary ───────────────────────────────────┐ │
|
||||
│ │ Unique: 312 / 847 words (36.8%) │ │
|
||||
│ │ Top: the(42) and(31) markdown(28) ... │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Word Goal ────────────────────────────────────┐ │
|
||||
│ │ Target: [1000] words │ │
|
||||
│ │ ████████████████░░░░ 847/1000 (85%) │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Metrics Implementation
|
||||
|
||||
**Readability (Flesch-Kincaid):**
|
||||
```javascript
|
||||
// Flesch Reading Ease
|
||||
ease = 206.835 - 1.015 * (words / sentences) - 84.6 * (syllables / words);
|
||||
|
||||
// Flesch-Kincaid Grade Level
|
||||
grade = 0.39 * (words / sentences) + 11.8 * (syllables / words) - 15.59;
|
||||
```
|
||||
|
||||
**Syllable Estimation:**
|
||||
```javascript
|
||||
function countSyllables(word) {
|
||||
word = word.toLowerCase().replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '');
|
||||
word = word.replace(/^y/, '');
|
||||
return word.match(/[aeiouy]{1,2}/g)?.length || 1;
|
||||
}
|
||||
```
|
||||
|
||||
**Reading/Speaking Time:**
|
||||
- Reading: 200 words/minute
|
||||
- Speaking: 130 words/minute
|
||||
|
||||
**Lexical Diversity:**
|
||||
- Ratio of unique words to total words (excluding stop words)
|
||||
|
||||
**Top Words:**
|
||||
- Frequency map, sorted descending, top 10
|
||||
- Excludes common stop words (the, a, an, is, are, etc.)
|
||||
|
||||
### Word Goal
|
||||
|
||||
- Persisted in `electron-store` per document (or global default)
|
||||
- Progress bar with percentage
|
||||
- Celebration effect when goal is reached (brief confetti animation or green flash)
|
||||
|
||||
### Update Cadence
|
||||
|
||||
- Re-analyzes on editor content change (debounced at 1000ms)
|
||||
- If modal is open, updates live
|
||||
- If modal is closed, no computation (zero overhead)
|
||||
|
||||
---
|
||||
|
||||
## File Summary
|
||||
|
||||
| File | Action | Purpose |
|
||||
|------|--------|---------|
|
||||
| `src/zen-mode.js` | Create | ZenMode class with CM6 extensions |
|
||||
| `src/styles-zen.css` | Create | Zen mode styling |
|
||||
| `src/sidebar/outline-panel.js` | Create | Outline sidebar panel |
|
||||
| `src/analytics/writing-analytics.js` | Create | Analytics computation engine |
|
||||
| `src/analytics/analytics-panel.js` | Create | Analytics modal UI |
|
||||
| `src/index.html` | Modify | Add outline icon, zen mode button |
|
||||
| `src/renderer.js` | Modify | Initialize all three features |
|
||||
| `src/editor/codemirror-setup.js` | Modify | Export typewriter + dimming extensions |
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Feature | Action |
|
||||
|----------|---------|--------|
|
||||
| F11 | Zen Mode | Toggle on/off |
|
||||
| Escape | Zen Mode | Exit (when active) |
|
||||
| Ctrl+Shift+O | Outline | Open outline sidebar panel |
|
||||
| Ctrl+Shift+A | Analytics | Open analytics modal |
|
||||
|
||||
## Dependencies
|
||||
|
||||
No new npm dependencies required. All features use:
|
||||
- Existing CodeMirror 6 APIs (ViewPlugin, Decoration, scrollIntoView)
|
||||
- Existing SidebarManager API
|
||||
- Existing ModalManager API
|
||||
- Pure JavaScript math for analytics
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,426 @@
|
||||
# MarkdownConverter v5.0 — Platform Design
|
||||
|
||||
**Date:** 2026-04-14
|
||||
**Status:** Approved
|
||||
**Author:** Amit Haridas
|
||||
|
||||
## Overview
|
||||
|
||||
Transform MarkdownConverter from a monolithic editor into an extensible platform with a plugin system and three feature packs, shipped together as v5.0.
|
||||
|
||||
## Subsystems
|
||||
|
||||
1. **Plugin System** — Lightweight plugin registry with extension points (sidebar, commands, settings, status bar, export hooks, event bus)
|
||||
2. **Writing Studio Plugin** — Manuscript manager, goal tracking, writing sprints, snapshots, smart proofreading
|
||||
3. **AI Assistant Plugin** — Multi-provider AI writing assistant (Ollama, LMStudio, GGUF direct with GPU, Anthropic, OpenAI)
|
||||
4. **Collaboration Plugin** — Git-based async collaboration, comments/annotations, review requests
|
||||
|
||||
## Core Principle
|
||||
|
||||
**Existing functionality is never replaced or broken.** The plugin system is additive. All existing keyboard shortcuts, features, and UI remain untouched. Plugin shortcuts use `Ctrl+Alt+` namespace.
|
||||
|
||||
---
|
||||
|
||||
## 1. Plugin System
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
plugins/
|
||||
plugin-registry.js # Load, register, lifecycle
|
||||
plugin-api.js # Base class plugins extend
|
||||
plugin-loader.js # Discovers and validates manifests
|
||||
built-in/
|
||||
writing-studio/
|
||||
manifest.json
|
||||
index.js
|
||||
panels/
|
||||
components/
|
||||
ai-assistant/
|
||||
manifest.json
|
||||
index.js
|
||||
providers/
|
||||
collaboration/
|
||||
manifest.json
|
||||
index.js
|
||||
```
|
||||
|
||||
### Manifest Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "writing-studio",
|
||||
"name": "Writing Studio",
|
||||
"version": "1.0.0",
|
||||
"description": "Manuscript management, goal tracking, writing sprints",
|
||||
"icon": "pen-tool",
|
||||
"extensionPoints": {
|
||||
"sidebar": { "panel": "panels/manuscript-panel.js", "order": 30 },
|
||||
"settings": { "section": "settings/index.js" },
|
||||
"statusBar": { "indicators": ["sprint-timer", "word-goal"] },
|
||||
"commands": [
|
||||
{ "id": "start-sprint", "label": "Start Writing Sprint", "shortcut": "Ctrl+Alt+S" },
|
||||
{ "id": "take-snapshot", "label": "Take Snapshot", "shortcut": "Ctrl+Alt+N" }
|
||||
],
|
||||
"exportHooks": {
|
||||
"preExport": "hooks/pre-export.js",
|
||||
"postExport": "hooks/post-export.js"
|
||||
}
|
||||
},
|
||||
"settings": [
|
||||
{ "key": "dailyGoal", "type": "number", "default": 1000, "label": "Daily word goal" },
|
||||
{ "key": "sprintDuration", "type": "number", "default": 25, "label": "Sprint duration (min)" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Plugin Lifecycle
|
||||
|
||||
1. PluginLoader discovers manifests in `built-in/` + user plugins directory
|
||||
2. PluginRegistry validates manifests
|
||||
3. Each plugin calls `Plugin.init(context)` receiving scoped API context
|
||||
4. Extension points registered (sidebar panels, commands, status bar items)
|
||||
5. Plugins activate lazily — sidebar panel loads JS when user clicks tab
|
||||
|
||||
### Plugin Context API
|
||||
|
||||
Each plugin's `init()` receives:
|
||||
|
||||
```javascript
|
||||
{
|
||||
sidebar: {
|
||||
registerPanel(id, { icon, title, component })
|
||||
},
|
||||
commands: {
|
||||
register(id, label, handler, shortcut?)
|
||||
},
|
||||
statusBar: {
|
||||
registerIndicator(id, { position, render })
|
||||
},
|
||||
settings: {
|
||||
get(key), // plugin-scoped
|
||||
set(key, value), // auto-persisted via electron-store
|
||||
onChanged(key, callback)
|
||||
},
|
||||
editor: {
|
||||
getContent(), // current document
|
||||
getSelection(), // selected text
|
||||
insertAtCursor(text), // requires opt-in
|
||||
onContentChanged(callback)
|
||||
},
|
||||
events: {
|
||||
on(event, handler),
|
||||
emit(event, data)
|
||||
},
|
||||
exports: {
|
||||
registerPreHook(handler),
|
||||
registerPostHook(handler)
|
||||
},
|
||||
ipc: {
|
||||
invoke(channel, ...args),
|
||||
on(channel, handler)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Event Bus Events
|
||||
|
||||
Each event has a versioned payload schema. Breaking changes increment the version suffix.
|
||||
|
||||
```
|
||||
document:opened → { filePath: string, tabId: string }
|
||||
document:saved → { filePath: string, tabId: string }
|
||||
document:changed → { tabId: string, content: string, wordCount: number }
|
||||
editor:selection-changed → { tabId: string, text: string, from: {line,ch}, to: {line,ch} }
|
||||
tab:switched → { tabId: string, filePath: string }
|
||||
tab:closed → { tabId: string, filePath: string }
|
||||
export:started → { format: string, filePath: string }
|
||||
export:completed → { format: string, filePath: string, outputPath: string }
|
||||
export:failed → { format: string, error: string }
|
||||
plugin:loaded → { pluginId: string, version: string }
|
||||
plugin:activated → { pluginId: string }
|
||||
plugin:deactivated → { pluginId: string }
|
||||
app:ready → {}
|
||||
app:before-quit → {}
|
||||
```
|
||||
|
||||
### Design Rules
|
||||
|
||||
- Built-in plugins use the same API as future third-party plugins
|
||||
- Lazy activation — sidebar panels don't load until clicked
|
||||
- Scoped settings: `plugins.<id>.<key>` in electron-store
|
||||
- Plugin commands globally unique — registry rejects duplicate command IDs at load time
|
||||
- **Plugin sandboxing**: each plugin handler is wrapped in try/catch. For CPU-intensive operations (AI inference, diff computation), plugins must delegate to main process via IPC. Handlers that block the renderer for >5s trigger a warning notification. Memory-hungry operations (GGUF inference) run in isolated child processes.
|
||||
- **Cross-plugin graceful degradation**: plugins check `context.events.hasHandler('ai:analyze')` before emitting cross-plugin requests. If no handler (AI plugin disabled), show a "this feature requires the AI plugin" prompt instead of failing silently. All cross-plugin calls have a 30s timeout with default fallback behavior.
|
||||
|
||||
---
|
||||
|
||||
## 2. Writing Studio Plugin
|
||||
|
||||
### 2A. Manuscript / Project Manager
|
||||
|
||||
Folder-based project structure:
|
||||
|
||||
```
|
||||
~/Manuscripts/
|
||||
my-novel/
|
||||
.project.json # { title, targets, metadata }
|
||||
01-chapter-one.md
|
||||
02-chapter-two.md
|
||||
characters/
|
||||
protagonist.md
|
||||
research/
|
||||
world-building.md
|
||||
.snapshots/
|
||||
2026-04-14T10-30.json
|
||||
```
|
||||
|
||||
`.project.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "My Novel",
|
||||
"type": "manuscript",
|
||||
"target": { "words": 80000, "deadline": "2026-09-01" },
|
||||
"chapters": [
|
||||
{ "file": "01-chapter-one.md", "title": "The Beginning", "status": "draft" }
|
||||
],
|
||||
"metadata": { "author": "", "genre": "", "synopsis": "" }
|
||||
}
|
||||
```
|
||||
|
||||
Sidebar panel shows project tree with drag-to-reorder, word counts per chapter, target progress bar. "Compile manuscript" exports all chapters as a single document.
|
||||
|
||||
### 2B. Goal Tracking & Writing Sprints
|
||||
|
||||
- **Status bar**: daily progress bar + sprint timer
|
||||
- **Writing sprint**: configurable duration (15/25/30/45/60 min), word count delta, WPM at end
|
||||
- **Goal tracking**: daily/weekly word goals, streak tracking, 30-day bar chart
|
||||
- **Enhanced analytics**: session tracking, readability scores, productive time-of-day heatmap
|
||||
- Data stored in `plugins.writing-studio.history` as date-keyed map
|
||||
|
||||
### 2C. Snapshot & Versioning
|
||||
|
||||
- `Ctrl+Alt+N` or toolbar button saves snapshot
|
||||
- Stored as JSON: `{ timestamp, content, wordCount, cursorPos, label }`
|
||||
- Snapshot panel in sidebar: Restore, Diff (side-by-side), auto-snapshot interval
|
||||
- Snapshots in `.snapshots/` inside project folder, or app data if no project
|
||||
|
||||
### 2D. Smart Proofreading
|
||||
|
||||
Delegates to AI plugin via event bus. Writing Studio provides:
|
||||
- Right-click context menu: "Check grammar", "Suggest alternatives", "Analyze readability"
|
||||
- Inline wavy underline decorations for issues
|
||||
- Proofread panel: issues categorized by type with Accept/Dismiss
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Shortcut | Action |
|
||||
|---------|----------|--------|
|
||||
| `start-sprint` | `Ctrl+Alt+S` | Start writing sprint |
|
||||
| `stop-sprint` | `Ctrl+Alt+Shift+S` | Stop sprint |
|
||||
| `take-snapshot` | `Ctrl+Alt+N` | Save snapshot |
|
||||
| `restore-last-snapshot` | `Ctrl+Alt+Z` | Restore latest snapshot |
|
||||
| `new-project` | — | Create manuscript project |
|
||||
| `compile-manuscript` | `Ctrl+Alt+E` | Export all chapters |
|
||||
| `proofread-document` | `Ctrl+Alt+G` | AI proofread |
|
||||
|
||||
---
|
||||
|
||||
## 3. AI Assistant Plugin
|
||||
|
||||
### Provider Architecture
|
||||
|
||||
```
|
||||
AI Plugin
|
||||
├── Provider Interface
|
||||
│ ├── complete(prompt, options) → string
|
||||
│ ├── stream(prompt, options) → AsyncIterable
|
||||
│ └── analyze(text, type) → AnalysisResult
|
||||
│
|
||||
├── Providers
|
||||
│ ├── OllamaProvider — localhost:11434
|
||||
│ ├── LMStudioProvider — localhost:1234/v1
|
||||
│ ├── GGUFProvider — direct llama.cpp with GPU support
|
||||
│ ├── AnthropicProvider — Claude API
|
||||
│ └── OpenAIProvider — GPT API
|
||||
│
|
||||
└── Features
|
||||
├── Grammar/style check
|
||||
├── Inline auto-complete
|
||||
├── AI chat panel (sidebar)
|
||||
├── Document analysis
|
||||
└── Smart commands (command palette)
|
||||
```
|
||||
|
||||
### Provider Details
|
||||
|
||||
**Ollama:** `GET /api/tags` for models, `POST /api/generate` and `POST /api/chat` for inference.
|
||||
|
||||
**LMStudio:** OpenAI-compatible API at `localhost:1234/v1`. `GET /v1/models`, standard chat completion format, SSE streaming.
|
||||
|
||||
**GGUF Direct (with GPU):**
|
||||
- Ships bundled llama.cpp binaries per platform (CUDA, Vulkan, Metal, CPU variants)
|
||||
- Auto-detects GPU: CUDA (nvidia-smi), Vulkan driver, Metal (macOS)
|
||||
- GPU layer offloading: configurable, auto-suggests based on VRAM vs model size
|
||||
- Settings: GPU backend selection, layer count, context length, thread count
|
||||
- "Keep model loaded" option for faster repeated requests
|
||||
- WASM fallback for sandboxed environments (CPU-only)
|
||||
- External binary path for advanced users with custom builds
|
||||
- **Process isolation**: llama.cpp runs as a spawned child process (not in main process). If it crashes, detected via exit handler, auto-restarted with notification. GPU memory freed on crash. App remains stable.
|
||||
- Process management: spawn in server mode on localhost ephemeral port, clean up on app quit or model unload
|
||||
|
||||
**Cloud (Anthropic/OpenAI):**
|
||||
- API key stored encrypted via electron safeStorage
|
||||
- Token usage tracking with estimated cost
|
||||
- Rate limit awareness with request queueing and backoff
|
||||
|
||||
### IPC Design
|
||||
|
||||
All provider HTTP requests go through main process:
|
||||
- No CORS issues
|
||||
- API keys never in renderer
|
||||
- Main process enforces rate limiting
|
||||
- GGUF inference in isolated child process
|
||||
|
||||
**Request/response lifecycle:**
|
||||
```
|
||||
Renderer → ipc.invoke('ai:complete') → Main → HTTP to provider → result → Renderer
|
||||
```
|
||||
|
||||
**Streaming lifecycle with error handling:**
|
||||
```
|
||||
Renderer → ipc.invoke('ai:stream', { requestId, prompt })
|
||||
← Main assigns requestId, returns { requestId }
|
||||
← ipc.on('ai:chunk', { requestId, text }) — repeated
|
||||
← ipc.on('ai:done', { requestId }) — success
|
||||
← ipc.on('ai:error', { requestId, error }) — failure
|
||||
|
||||
// Cancellation
|
||||
Renderer → ipc.invoke('ai:cancel', { requestId })
|
||||
← Main aborts HTTP request, emits 'ai:done'
|
||||
|
||||
// Orphan cleanup: if renderer disconnects (crash/close),
|
||||
// main process detects via 'render-view-deleted' and aborts all active streams.
|
||||
// Heartbeat: if no chunk received in 30s, main emits 'ai:error' with timeout.
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
1. **Inline suggestions**: ghost text after configurable delay, Tab to accept, Esc to dismiss
|
||||
2. **AI chat panel**: sidebar conversation, "Insert" / "Replace selection" buttons
|
||||
3. **Document analysis**: grammar, style, tone, with accept/reject per suggestion
|
||||
4. **Smart commands**: summarize, generate outline, find inconsistencies, translate, explain code
|
||||
|
||||
### Privacy
|
||||
|
||||
- Local-first: default provider is Ollama
|
||||
- No telemetry: requests go direct to provider
|
||||
- Content gating: exclude file types from AI
|
||||
- Status bar shows "AI: processing..." with cancel option
|
||||
- Cloud usage stats in settings (tokens, cost)
|
||||
|
||||
### Cross-Plugin Integration
|
||||
|
||||
```javascript
|
||||
// Writing Studio calls AI Plugin
|
||||
context.events.emit('ai:analyze', { text, type: 'grammar', callback });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Collaboration Plugin
|
||||
|
||||
### 4A. Enhanced Git Panel
|
||||
|
||||
Upgrades to existing git panel:
|
||||
- Remote management (add/remove remotes, push/pull)
|
||||
- Branch list and switching
|
||||
- Commit history with diff viewer (side-by-side or unified)
|
||||
- Conflict resolution UI (accept-ours/accept-theirs/per-edit)
|
||||
|
||||
### 4B. Shared Repository Workflow
|
||||
|
||||
1. Writer A creates project + initializes git + pushes to shared repo
|
||||
2. Writer B clones repo from within MarkdownConverter
|
||||
3. Both write on their own branches
|
||||
4. Writer A creates review request (simplified PR)
|
||||
|
||||
Review request: changed files, word count diff, commit messages. Reviewer can approve, request changes, leave inline comments. Reviews are git branches + comments as git notes.
|
||||
|
||||
### 4C. Comments & Annotations
|
||||
|
||||
Inline comments stored as JSON in `.comments/` directory (git-tracked):
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"file": "03-chapter-three.md",
|
||||
"anchor": {
|
||||
"contextBefore": "The hero looked at the horizon and said,",
|
||||
"selectedText": "I will not go quietly into that dark night",
|
||||
"contextAfter": "He turned to face the army alone."
|
||||
},
|
||||
"line": 142,
|
||||
"text": "This dialogue feels unnatural",
|
||||
"author": "amit",
|
||||
"timestamp": "2026-04-14T14:30:00Z",
|
||||
"replies": [],
|
||||
"resolved": false
|
||||
}
|
||||
```
|
||||
|
||||
- **Anchor-based positioning**: comments store `contextBefore`, `selectedText`, and `contextAfter` (not absolute byte offsets). On file change, re-anchor by searching for the context text. If context no longer matches, mark comment as "detached" and show a warning. Falls back to `line` number as rough position.
|
||||
- Highlighted text in editor with tooltip on hover
|
||||
- Comment panel in sidebar: all unresolved comments across files
|
||||
- Resolution workflow: add → address → reply → resolve
|
||||
- Resolved comments dim but stay visible
|
||||
|
||||
### 4D. Change Notifications
|
||||
|
||||
- Status bar indicator: `↓ 3 new commits`
|
||||
- Click to see changes, one-click pull
|
||||
- Conflicts trigger resolution UI
|
||||
- Push button only when local commits ahead of remote
|
||||
|
||||
### 4E. Offline-First
|
||||
|
||||
All writing happens locally. Git is the sync mechanism. No internet required for writing, commenting, snapshots, or sprints. Push/pull on user action or auto-sync setting.
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Shortcut | Action |
|
||||
|---------|----------|--------|
|
||||
| `collab:commit` | `Ctrl+Shift+G` | Commit with message |
|
||||
| `collab:push` | — | Push current branch |
|
||||
| `collab:pull` | — | Pull from remote |
|
||||
| `collab:add-comment` | `Ctrl+Alt+C` | Comment on selection |
|
||||
| `collab:next-comment` | `F8` | Next unresolved comment |
|
||||
| `collab:prev-comment` | `Shift+F8` | Previous comment |
|
||||
| `collab:create-review` | — | Create review request |
|
||||
|
||||
### Cross-Plugin Integration
|
||||
|
||||
```javascript
|
||||
context.events.emit('snapshot:created', { file, snapshotId });
|
||||
context.events.on('project:chapter-opened', (chapter) => { /* load comments */ });
|
||||
context.events.on('comment:added', (comment) => { /* AI could suggest fix */ });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bundle Size Impact
|
||||
|
||||
- llama.cpp binaries: ~15MB per GPU variant. Only target platform shipped. GPU variants (CUDA/Vulkan/Metal) downloaded on demand if user enables GGUF direct loading — not bundled by default. Only CPU fallback bundled (~15MB).
|
||||
- Plugin system core: ~30KB
|
||||
- Each built-in plugin: ~50-100KB
|
||||
- Diff library (jsdiff): ~15KB
|
||||
- Total estimated increase: ~20-30MB (core), additional ~30-50MB per GPU variant (lazy download)
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Plugin system: unit tests for registry, loader, context API mocking
|
||||
- Each plugin: isolated unit tests, integration tests via plugin context
|
||||
- AI provider tests: mock HTTP responses, test streaming parsing
|
||||
- Git tests: use test repository fixture
|
||||
- E2E: verify plugin loading doesn't break existing features
|
||||
Generated
+13895
File diff suppressed because it is too large
Load Diff
+26
-18
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "markdown-converter",
|
||||
"version": "4.1.0",
|
||||
"version": "4.3.0",
|
||||
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
|
||||
"main": "src/main.js",
|
||||
"scripts": {
|
||||
@@ -14,13 +14,16 @@
|
||||
"format:check": "prettier --check src tests",
|
||||
"build": "electron-builder",
|
||||
"build:win": "electron-builder --win",
|
||||
"build:win-signed": "cross-env CSC_LINK=code-signing-cert.pfx CSC_KEY_PASSWORD=%CSC_KEY_PASSWORD% electron-builder --win",
|
||||
"build:win-signed": "cross-env CSC_LINK=code-signing-cert.pfx electron-builder --win",
|
||||
"build:win-unsigned": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --win",
|
||||
"create-cert": "powershell -ExecutionPolicy Bypass -File scripts/create-selfsigned-cert.ps1",
|
||||
"build:mac": "electron-builder --mac",
|
||||
"build:linux": "electron-builder --linux",
|
||||
"build:linux-ci": "electron-builder --linux deb AppImage",
|
||||
"build:local": "electron-builder --linux --win",
|
||||
"dist": "electron-builder --publish=never",
|
||||
"dist:all": "electron-builder -mwl",
|
||||
"download-tools": "node scripts/download-tools.js",
|
||||
"generate-icons": "node scripts/generate-icons.js"
|
||||
},
|
||||
"keywords": [
|
||||
@@ -42,7 +45,7 @@
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"cross-env": "^10.0.0",
|
||||
"electron": "^37.4.0",
|
||||
"electron": "^41.1.1",
|
||||
"electron-builder": "^26.0.12",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
@@ -73,6 +76,7 @@
|
||||
"docx4js": "^3.3.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"electron-store": "^10.1.0",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"html2pdf.js": "^0.14.0",
|
||||
"marked": "^17.0.3",
|
||||
@@ -89,11 +93,14 @@
|
||||
"overrides": {
|
||||
"jszip": "^3.10.1",
|
||||
"nth-check": "^2.1.1",
|
||||
"lodash.pick": "npm:lodash@^4.17.21"
|
||||
"lodash.pick": "npm:lodash@^4.17.21",
|
||||
"lodash-es": "^4.18.1",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.concreteinfo.markdownconverter",
|
||||
"productName": "MarkdownConverter",
|
||||
"copyright": "Copyright (C) 2024-2025 ConcreteInfo",
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
@@ -105,6 +112,10 @@
|
||||
"node_modules/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"asarUnpack": [
|
||||
"node_modules/ffmpeg-static/**"
|
||||
],
|
||||
"extraFiles": [],
|
||||
"fileAssociations": [
|
||||
{
|
||||
"ext": "md",
|
||||
@@ -155,7 +166,12 @@
|
||||
],
|
||||
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
||||
"requestedExecutionLevel": "asInvoker",
|
||||
"signAndEditExecutable": false
|
||||
"legalTrademarks": "Copyright (C) 2024-2025 ConcreteInfo",
|
||||
"verifyUpdateCodeSignature": false,
|
||||
"signAndEditExecutable": false,
|
||||
"extraFiles": [
|
||||
{ "from": "bin/win32/pandoc.exe", "to": "bin/pandoc.exe" }
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
@@ -177,29 +193,21 @@
|
||||
"target": [
|
||||
"deb",
|
||||
"AppImage",
|
||||
"snap",
|
||||
"rpm"
|
||||
"snap"
|
||||
],
|
||||
"category": "Utility",
|
||||
"maintainer": "ConcreteInfo <amit.wh@gmail.com>"
|
||||
"maintainer": "ConcreteInfo <amit.wh@gmail.com>",
|
||||
"extraFiles": [
|
||||
{ "from": "bin/linux/pandoc", "to": "bin/pandoc" }
|
||||
]
|
||||
},
|
||||
"deb": {
|
||||
"depends": [
|
||||
"pandoc",
|
||||
"ffmpeg",
|
||||
"imagemagick",
|
||||
"libreoffice-common"
|
||||
],
|
||||
"description": "Professional Markdown editor and universal file converter",
|
||||
"maintainer": "ConcreteInfo <amit.wh@gmail.com>"
|
||||
},
|
||||
"rpm": {
|
||||
"depends": [
|
||||
"pandoc",
|
||||
"ffmpeg",
|
||||
"ImageMagick",
|
||||
"libreoffice-core"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# create-selfsigned-cert.ps1
|
||||
# Generates a self-signed code-signing certificate for local/development builds.
|
||||
#
|
||||
# Usage:
|
||||
# powershell -ExecutionPolicy Bypass -File scripts/create-selfsigned-cert.ps1
|
||||
# npm run create-cert
|
||||
#
|
||||
# Then build with:
|
||||
# $env:CSC_LINK="code-signing-cert.pfx"; $env:CSC_KEY_PASSWORD="YourPassword"; npm run build:win-signed
|
||||
#
|
||||
# NOTE: Self-signed certificates will still show a SmartScreen warning for end users.
|
||||
# For production releases, obtain an OV or EV certificate from a trusted CA
|
||||
# (DigiCert, Sectigo, Certum, etc.). EV certificates bypass SmartScreen immediately.
|
||||
# Open-source projects can apply for free signing at https://signpath.io/
|
||||
|
||||
param(
|
||||
[string]$CertPassword = "MarkdownConverter2025",
|
||||
[string]$OutputFile = "code-signing-cert.pfx",
|
||||
[string]$Subject = "CN=ConcreteInfo, O=ConcreteInfo, L=India, C=IN"
|
||||
)
|
||||
|
||||
Write-Host "Creating self-signed code-signing certificate..." -ForegroundColor Cyan
|
||||
|
||||
# Create the certificate in the current user's certificate store
|
||||
$cert = New-SelfSignedCertificate `
|
||||
-Type CodeSigningCert `
|
||||
-Subject $Subject `
|
||||
-CertStoreLocation "Cert:\CurrentUser\My" `
|
||||
-NotAfter (Get-Date).AddYears(3) `
|
||||
-HashAlgorithm SHA256 `
|
||||
-KeyLength 4096 `
|
||||
-KeyUsage DigitalSignature
|
||||
|
||||
if (-not $cert) {
|
||||
Write-Error "Failed to create certificate."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Certificate created: $($cert.Thumbprint)" -ForegroundColor Green
|
||||
|
||||
# Export to PFX
|
||||
$securePassword = ConvertTo-SecureString -String $CertPassword -Force -AsPlainText
|
||||
$exportPath = Join-Path (Get-Location) $OutputFile
|
||||
|
||||
Export-PfxCertificate -Cert $cert -FilePath $exportPath -Password $securePassword | Out-Null
|
||||
|
||||
if (Test-Path $exportPath) {
|
||||
Write-Host "Certificate exported to: $exportPath" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "To build a signed release:" -ForegroundColor Yellow
|
||||
Write-Host ' $env:CSC_LINK="code-signing-cert.pfx"' -ForegroundColor White
|
||||
Write-Host " `$env:CSC_KEY_PASSWORD=`"$CertPassword`"" -ForegroundColor White
|
||||
Write-Host " npm run build:win-signed" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host "IMPORTANT: Add code-signing-cert.pfx to .gitignore!" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Error "Export failed."
|
||||
exit 1
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
+20
-16
@@ -18,7 +18,7 @@ const electronFsAdapter = {
|
||||
* @returns {Promise<string>} File content
|
||||
*/
|
||||
async readFile(path) {
|
||||
return await window.electronAPI.readFile(path);
|
||||
return await window.electronAPI.file.read(path);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -28,7 +28,7 @@ const electronFsAdapter = {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async writeFile(path, content) {
|
||||
return await window.electronAPI.writeFile(path, content);
|
||||
return await window.electronAPI.file.write(path, content);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -37,8 +37,7 @@ const electronFsAdapter = {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async deleteFile(path) {
|
||||
// TODO: Add IPC channel for delete
|
||||
throw new Error('deleteFile not implemented');
|
||||
return await window.electronAPI.file.delete(path);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -47,8 +46,7 @@ const electronFsAdapter = {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async ensureDir(path) {
|
||||
// TODO: Add IPC channel for ensureDir
|
||||
throw new Error('ensureDir not implemented');
|
||||
return await window.electronAPI.file.ensureDir(path);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -57,8 +55,18 @@ const electronFsAdapter = {
|
||||
* @returns {Promise<Array<import('../types').FileInfo>>}
|
||||
*/
|
||||
async listDirectory(path) {
|
||||
// TODO: Add IPC channel for listDirectory
|
||||
throw new Error('listDirectory not implemented');
|
||||
const result = await window.electronAPI.invoke('list-directory', path);
|
||||
if (!result?.entries) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
isDir: entry.isDirectory,
|
||||
size: entry.size ?? 0,
|
||||
modified: entry.modified ?? 0,
|
||||
path: entry.path
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -67,8 +75,7 @@ const electronFsAdapter = {
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async exists(path) {
|
||||
// TODO: Add IPC channel for exists
|
||||
throw new Error('exists not implemented');
|
||||
return await window.electronAPI.file.exists(path);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -77,8 +84,7 @@ const electronFsAdapter = {
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async isDirectory(path) {
|
||||
// TODO: Add IPC channel for isDirectory
|
||||
throw new Error('isDirectory not implemented');
|
||||
return await window.electronAPI.file.isDirectory(path);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -88,8 +94,7 @@ const electronFsAdapter = {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async copy(source, dest) {
|
||||
// TODO: Add IPC channel for copy
|
||||
throw new Error('copy not implemented');
|
||||
return await window.electronAPI.file.copy(source, dest);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -99,8 +104,7 @@ const electronFsAdapter = {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async move(source, dest) {
|
||||
// TODO: Add IPC channel for move
|
||||
throw new Error('move not implemented');
|
||||
return await window.electronAPI.file.move(source, dest);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Writing Analytics Panel — modal overlay displaying analytics dashboard
|
||||
*/
|
||||
|
||||
const { analyze } = require('./writing-analytics');
|
||||
|
||||
function showAnalyticsModal(tabManager) {
|
||||
const existing = document.getElementById('analytics-modal');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const content = tabManager.getEditorContent();
|
||||
const metrics = analyze(content);
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'analytics-modal';
|
||||
overlay.className = 'analytics-overlay';
|
||||
|
||||
const maxCount = metrics.topWords.length > 0 ? metrics.topWords[0].count : 1;
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="analytics-modal">
|
||||
<div class="analytics-header">
|
||||
<h2>Writing Analytics</h2>
|
||||
<button class="analytics-close" title="Close">×</button>
|
||||
</div>
|
||||
<div class="analytics-body">
|
||||
<div class="analytics-section">
|
||||
<h3>Readability</h3>
|
||||
<div class="analytics-row">
|
||||
<span class="analytics-label">Flesch Reading Ease</span>
|
||||
<span class="analytics-value">${metrics.fleschEase}<small>${metrics.readabilityLabel}</small></span>
|
||||
</div>
|
||||
<div class="analytics-row">
|
||||
<span class="analytics-label">Grade Level</span>
|
||||
<span class="analytics-value">${metrics.fleschGrade}</span>
|
||||
</div>
|
||||
<div class="readability-meter">
|
||||
<div class="readability-fill" style="width: ${Math.max(0, Math.min(100, metrics.fleschEase))}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="analytics-section">
|
||||
<h3>Timing</h3>
|
||||
<div class="analytics-row">
|
||||
<span class="analytics-label">Reading Time</span>
|
||||
<span class="analytics-value">~${metrics.readingTime} min</span>
|
||||
</div>
|
||||
<div class="analytics-row">
|
||||
<span class="analytics-label">Speaking Time</span>
|
||||
<span class="analytics-value">~${metrics.speakingTime} min</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="analytics-section">
|
||||
<h3>Structure</h3>
|
||||
<div class="analytics-row">
|
||||
<span class="analytics-label">Sentences</span>
|
||||
<span class="analytics-value">${metrics.sentenceCount} • Paragraphs: ${metrics.paragraphCount}</span>
|
||||
</div>
|
||||
<div class="analytics-row">
|
||||
<span class="analytics-label">Avg Sentence</span>
|
||||
<span class="analytics-value">${metrics.avgSentenceLength} words</span>
|
||||
</div>
|
||||
${metrics.longestSentenceLength > 0 ? `
|
||||
<div class="analytics-row analytics-longest">
|
||||
<span class="analytics-label">Longest (${metrics.longestSentenceLength} words)</span>
|
||||
<span class="analytics-value analytics-sentence-preview">${escapeHtml(metrics.longestSentence)}</span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="analytics-section">
|
||||
<h3>Vocabulary</h3>
|
||||
<div class="analytics-row">
|
||||
<span class="analytics-label">Unique</span>
|
||||
<span class="analytics-value">${metrics.uniqueWordCount} / ${metrics.wordCount}<small>${metrics.lexicalDiversity}%</small></span>
|
||||
</div>
|
||||
${metrics.topWords.length > 0 ? `
|
||||
<div class="word-cloud">
|
||||
${metrics.topWords.map(w => {
|
||||
const scale = 13 + Math.round((w.count / maxCount) * 3);
|
||||
return `<span class="word-tag" style="font-size:${scale}px">${escapeHtml(w.word)}<small>${w.count}</small></span>`;
|
||||
}).join('')}
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const closeBtn = overlay.querySelector('.analytics-close');
|
||||
closeBtn.addEventListener('click', () => overlay.remove());
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) overlay.remove();
|
||||
});
|
||||
|
||||
const escHandler = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
overlay.remove();
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
module.exports = { showAnalyticsModal };
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Writing Analytics — pure computation engine
|
||||
* No DOM dependencies. Exported analyze(text) returns a metrics object.
|
||||
*/
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been',
|
||||
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would',
|
||||
'could', 'should', 'to', 'of', 'in', 'for', 'on', 'with',
|
||||
'at', 'by', 'from', 'as', 'and', 'or', 'but', 'if', 'it',
|
||||
'its', 'this', 'that', 'these', 'those', 'i', 'me', 'my',
|
||||
'we', 'our', 'you', 'your', 'he', 'him', 'his', 'she', 'her',
|
||||
'they', 'them', 'their', 'not', 'no', 'so', 'than', 'too',
|
||||
'very', 'also', 'just', 'about', 'up', 'out', 'what', 'which', 'who'
|
||||
]);
|
||||
|
||||
function countSyllables(word) {
|
||||
word = word.toLowerCase().replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '');
|
||||
word = word.replace(/^y/, '');
|
||||
return word.match(/[aeiouy]{1,2}/gi)?.length || 1;
|
||||
}
|
||||
|
||||
function extractWords(text) {
|
||||
return text.match(/[a-zA-Z]+(?:['-][a-zA-Z]+)*/g) || [];
|
||||
}
|
||||
|
||||
function getReadabilityLabel(score) {
|
||||
if (score >= 90) return 'Very Easy';
|
||||
if (score >= 70) return 'Easy';
|
||||
if (score >= 50) return 'Standard';
|
||||
if (score >= 30) return 'Difficult';
|
||||
return 'Very Difficult';
|
||||
}
|
||||
|
||||
function analyze(text) {
|
||||
if (!text || !text.trim()) {
|
||||
return {
|
||||
wordCount: 0,
|
||||
sentenceCount: 0,
|
||||
paragraphCount: 0,
|
||||
fleschEase: 0,
|
||||
fleschGrade: 0,
|
||||
readabilityLabel: 'N/A',
|
||||
readingTime: 0,
|
||||
speakingTime: 0,
|
||||
uniqueWordCount: 0,
|
||||
lexicalDiversity: 0,
|
||||
avgSentenceLength: 0,
|
||||
longestSentence: '',
|
||||
longestSentenceLength: 0,
|
||||
topWords: []
|
||||
};
|
||||
}
|
||||
|
||||
const words = extractWords(text);
|
||||
const wordCount = words.length;
|
||||
|
||||
const sentences = text.split(/[.!?]+/).map(s => s.trim()).filter(Boolean);
|
||||
const sentenceCount = Math.max(sentences.length, 1);
|
||||
|
||||
const paragraphs = text.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean);
|
||||
const paragraphCount = Math.max(paragraphs.length, 1);
|
||||
|
||||
let totalSyllables = 0;
|
||||
for (const w of words) {
|
||||
totalSyllables += countSyllables(w);
|
||||
}
|
||||
|
||||
const fleschEase = Math.round((206.835 - 1.015 * (wordCount / sentenceCount) - 84.6 * (totalSyllables / wordCount)) * 10) / 10;
|
||||
const fleschGrade = Math.round((0.39 * (wordCount / sentenceCount) + 11.8 * (totalSyllables / wordCount) - 15.59) * 10) / 10;
|
||||
const readabilityLabel = getReadabilityLabel(fleschEase);
|
||||
|
||||
const readingTime = Math.ceil(wordCount / 200);
|
||||
const speakingTime = Math.ceil(wordCount / 130);
|
||||
|
||||
const uniqueWords = new Set(words.map(w => w.toLowerCase()));
|
||||
const uniqueWordCount = uniqueWords.size;
|
||||
const lexicalDiversity = wordCount > 0 ? Math.round((uniqueWordCount / wordCount) * 1000) / 10 : 0;
|
||||
|
||||
const avgSentenceLength = Math.round((wordCount / sentenceCount) * 10) / 10;
|
||||
|
||||
let longestSentence = '';
|
||||
let longestSentenceLength = 0;
|
||||
for (const s of sentences) {
|
||||
const sWords = extractWords(s);
|
||||
if (sWords.length > longestSentenceLength) {
|
||||
longestSentenceLength = sWords.length;
|
||||
longestSentence = s.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (longestSentence.length > 80) {
|
||||
longestSentence = longestSentence.substring(0, 80) + '...';
|
||||
}
|
||||
|
||||
const wordFreq = {};
|
||||
for (const w of words) {
|
||||
const lower = w.toLowerCase();
|
||||
if (!STOP_WORDS.has(lower) && lower.length > 1) {
|
||||
wordFreq[lower] = (wordFreq[lower] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const topWords = Object.entries(wordFreq)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([word, count]) => ({ word, count }));
|
||||
|
||||
return {
|
||||
wordCount,
|
||||
sentenceCount,
|
||||
paragraphCount,
|
||||
fleschEase,
|
||||
fleschGrade,
|
||||
readabilityLabel,
|
||||
readingTime,
|
||||
speakingTime,
|
||||
uniqueWordCount,
|
||||
lexicalDiversity,
|
||||
avgSentenceLength,
|
||||
longestSentence,
|
||||
longestSentenceLength,
|
||||
topWords
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { analyze };
|
||||
+16
-2
@@ -15,7 +15,9 @@
|
||||
<link rel="stylesheet" href="styles-modern.css">
|
||||
<link rel="stylesheet" href="styles-concreteinfo.css">
|
||||
<link rel="stylesheet" href="styles-sidebar.css">
|
||||
<link rel="stylesheet" href="styles-zen.css">
|
||||
<link rel="stylesheet" href="styles-welcome.css">
|
||||
<link rel="stylesheet" href="styles-zen.css">
|
||||
<link rel="stylesheet" href="../node_modules/highlight.js/styles/default.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -29,7 +31,7 @@
|
||||
<span class="app-title">MarkdownConverter</span>
|
||||
</div>
|
||||
<div class="app-header-right">
|
||||
<span class="app-version">v4.1.0</span>
|
||||
<span class="app-version">v4.2.0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-bar" id="tab-bar" role="tablist" aria-label="Document tabs">
|
||||
@@ -1273,6 +1275,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pdf-status-message" class="info-message hidden" aria-live="polite"></div>
|
||||
|
||||
<!-- Progress indicator -->
|
||||
<div id="pdf-progress" class="batch-progress hidden">
|
||||
<div class="progress-bar">
|
||||
@@ -1437,6 +1441,16 @@
|
||||
<line x1="9" y1="21" x2="9" y2="9"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="sidebar-icon" data-panel="outline" title="Outline (Ctrl+Shift+O)">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="8" y1="6" x2="21" y2="6"/>
|
||||
<line x1="8" y1="12" x2="21" y2="12"/>
|
||||
<line x1="8" y1="18" x2="21" y2="18"/>
|
||||
<line x1="3" y1="6" x2="3.01" y2="6"/>
|
||||
<line x1="3" y1="12" x2="3.01" y2="12"/>
|
||||
<line x1="3" y1="18" x2="3.01" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sidebar-panel" id="sidebar-panel">
|
||||
<div class="sidebar-panel-header">
|
||||
@@ -1593,4 +1607,4 @@
|
||||
<script src="utils/ModalManager.js"></script>
|
||||
<script src="renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
+195
-513
@@ -2,8 +2,9 @@ const { app, BrowserWindow, Menu, dialog, ipcMain, shell } = require('electron')
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { exec, execFile, spawn } = require('child_process');
|
||||
const { PDFDocument, rgb, degrees, StandardFonts } = require('pdf-lib');
|
||||
const WordTemplateExporter = require('./wordTemplateExporter');
|
||||
const PDFOperations = require('./main/PDFOperations');
|
||||
const GitOperations = require('./main/GitOperations');
|
||||
|
||||
// Add MiKTeX to PATH for LaTeX support
|
||||
if (process.platform === 'win32') {
|
||||
@@ -14,13 +15,39 @@ if (process.platform === 'win32') {
|
||||
}
|
||||
}
|
||||
|
||||
// Get the system Pandoc path
|
||||
// Returns path to pandoc: bundled binary when packaged, dev bin or system fallback otherwise.
|
||||
function getPandocPath() {
|
||||
// Pandoc is expected to be in the system's PATH.
|
||||
// The command will be executed directly. Quoting is handled by exec.
|
||||
if (app.isPackaged) {
|
||||
const ext = process.platform === 'win32' ? '.exe' : '';
|
||||
return path.join(process.resourcesPath, 'bin', `pandoc${ext}`);
|
||||
}
|
||||
// Development: prefer locally-downloaded binary in bin/<platform>/
|
||||
const devBin = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'bin',
|
||||
process.platform,
|
||||
process.platform === 'win32' ? 'pandoc.exe' : 'pandoc',
|
||||
);
|
||||
if (fs.existsSync(devBin)) return devBin;
|
||||
return 'pandoc';
|
||||
}
|
||||
|
||||
// Returns path to ffmpeg: asar-unpacked bundled binary when packaged, system fallback otherwise.
|
||||
function getFFmpegPath() {
|
||||
try {
|
||||
let ffmpegPath = require('ffmpeg-static');
|
||||
if (app.isPackaged) {
|
||||
// ffmpeg-static is in asarUnpack — rewrite the path to the unpacked location
|
||||
ffmpegPath = ffmpegPath.replace('app.asar' + path.sep, 'app.asar.unpacked' + path.sep);
|
||||
}
|
||||
if (fs.existsSync(ffmpegPath)) return ffmpegPath;
|
||||
} catch (_) {
|
||||
/* ffmpeg-static not available */
|
||||
}
|
||||
return process.platform === 'win32' ? 'ffmpeg' : 'ffmpeg';
|
||||
}
|
||||
|
||||
// Check if Pandoc is available
|
||||
function checkPandocAvailable() {
|
||||
return new Promise((resolve) => {
|
||||
@@ -124,6 +151,35 @@ function validatePath(filePath) {
|
||||
return { valid: true, resolved };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a path for operations where the target may not exist yet.
|
||||
* Validates string shape and blocks obviously sensitive locations.
|
||||
* @param {string} filePath
|
||||
* @returns {{ valid: boolean, resolved: string, error?: string }}
|
||||
*/
|
||||
function resolveWritablePath(filePath) {
|
||||
if (!filePath || typeof filePath !== 'string') {
|
||||
return { valid: false, resolved: '', error: 'Invalid path' };
|
||||
}
|
||||
|
||||
let resolved;
|
||||
try {
|
||||
resolved = path.normalize(path.resolve(filePath));
|
||||
} catch (err) {
|
||||
return { valid: false, resolved: '', error: 'Invalid path format' };
|
||||
}
|
||||
|
||||
if (resolved.includes('\0')) {
|
||||
return { valid: false, resolved: '', error: 'Null byte in path' };
|
||||
}
|
||||
|
||||
if (!isPathAccessible(resolved)) {
|
||||
return { valid: false, resolved, error: 'Path is not accessible' };
|
||||
}
|
||||
|
||||
return { valid: true, resolved };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a resolved path is within allowed directories
|
||||
* For an editor app, we allow access to all user-accessible paths
|
||||
@@ -254,6 +310,14 @@ const store = {
|
||||
}
|
||||
};
|
||||
|
||||
// Plugin settings IPC handlers
|
||||
ipcMain.handle('plugin-settings:get', (_event, key) => {
|
||||
return store.get(key);
|
||||
});
|
||||
ipcMain.handle('plugin-settings:set', (_event, { key, value }) => {
|
||||
store.set(key, value);
|
||||
});
|
||||
|
||||
let mainWindow;
|
||||
let currentFile = null; // This will now represent the active tab's file
|
||||
let pandocAvailable = null; // Cache pandoc availability check
|
||||
@@ -1000,7 +1064,7 @@ function showAboutDialog() {
|
||||
<body>
|
||||
<img src="${iconBase64}" class="logo" alt="MarkdownConverter">
|
||||
<h1>MarkdownConverter</h1>
|
||||
<div class="version">Version 4.0.0</div>
|
||||
<div class="version">Version 4.1.0</div>
|
||||
|
||||
<div class="company">
|
||||
<span>by</span>
|
||||
@@ -1804,9 +1868,12 @@ function checkConverterAvailable(tool) {
|
||||
case 'imagemagick':
|
||||
toolName = isWin ? 'magick' : 'convert';
|
||||
break;
|
||||
case 'ffmpeg':
|
||||
toolName = 'ffmpeg';
|
||||
break;
|
||||
case 'ffmpeg': {
|
||||
// ffmpeg-static is always bundled — check it directly
|
||||
const ffmpegBin = getFFmpegPath();
|
||||
resolve(fs.existsSync(ffmpegBin) || ffmpegBin === 'ffmpeg');
|
||||
return;
|
||||
}
|
||||
default:
|
||||
resolve(false);
|
||||
return;
|
||||
@@ -2033,8 +2100,8 @@ function convertWithImageMagick(inputFile, outputPath) {
|
||||
// FFmpeg conversion - returns {command, args} for execFile
|
||||
function convertWithFFmpeg(inputFile, outputPath) {
|
||||
return {
|
||||
command: 'ffmpeg',
|
||||
args: ['-i', inputFile, outputPath, '-y']
|
||||
command: getFFmpegPath(),
|
||||
args: ['-i', inputFile, outputPath, '-y'],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2793,9 +2860,13 @@ ipcMain.on('save-file', (event, { path, content }) => {
|
||||
currentFile = path;
|
||||
});
|
||||
|
||||
ipcMain.on('save-current-file', (event, content) => {
|
||||
if (currentFile) {
|
||||
fs.writeFileSync(currentFile, content, 'utf-8');
|
||||
ipcMain.on('save-current-file', (event, payload) => {
|
||||
const content = typeof payload === 'string' ? payload : payload?.content;
|
||||
const targetFile = typeof payload === 'string' ? currentFile : payload?.filePath || currentFile;
|
||||
|
||||
if (targetFile) {
|
||||
fs.writeFileSync(targetFile, content, 'utf-8');
|
||||
currentFile = targetFile;
|
||||
} else {
|
||||
saveAsFile();
|
||||
}
|
||||
@@ -3686,433 +3757,9 @@ function openFileFromPath(filePath) {
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// PDF EDITOR OPERATIONS (using pdf-lib)
|
||||
// PDF OPERATIONS — delegates to main/PDFOperations.js
|
||||
// ========================================
|
||||
|
||||
// Helper function to parse page ranges (e.g., "1-5, 7, 9-12")
|
||||
function parsePageRanges(rangeString, totalPages) {
|
||||
const pages = [];
|
||||
const ranges = rangeString.split(',').map(r => r.trim());
|
||||
|
||||
for (const range of ranges) {
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||||
for (let i = start; i <= end && i <= totalPages; i++) {
|
||||
if (i > 0 && !pages.includes(i - 1)) { // Convert to 0-indexed
|
||||
pages.push(i - 1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const page = parseInt(range);
|
||||
if (page > 0 && page <= totalPages && !pages.includes(page - 1)) {
|
||||
pages.push(page - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pages.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
// Helper function to convert hex color to RGB
|
||||
function hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16) / 255,
|
||||
g: parseInt(result[2], 16) / 255,
|
||||
b: parseInt(result[3], 16) / 255
|
||||
} : { r: 0, g: 0, b: 0 };
|
||||
}
|
||||
|
||||
// PDF Merge Operation
|
||||
async function pdfMerge(data) {
|
||||
try {
|
||||
const mergedPdf = await PDFDocument.create();
|
||||
|
||||
for (const filePath of data.inputFiles) {
|
||||
const pdfBytes = fs.readFileSync(filePath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
|
||||
copiedPages.forEach(page => mergedPdf.addPage(page));
|
||||
}
|
||||
|
||||
const pdfBytes = await mergedPdf.save();
|
||||
fs.writeFileSync(data.outputPath, pdfBytes);
|
||||
|
||||
return { success: true, message: `Successfully merged ${data.inputFiles.length} PDFs` };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// PDF Split Operation
|
||||
async function pdfSplit(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const totalPages = pdf.getPageCount();
|
||||
|
||||
let splits = [];
|
||||
|
||||
if (data.splitMode === 'pages') {
|
||||
// Split by page ranges
|
||||
const ranges = data.pageRanges.split(',').map(r => r.trim());
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
const range = ranges[i];
|
||||
let pages = [];
|
||||
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||||
for (let p = start; p <= end && p <= totalPages; p++) {
|
||||
pages.push(p - 1);
|
||||
}
|
||||
} else {
|
||||
const page = parseInt(range);
|
||||
if (page > 0 && page <= totalPages) {
|
||||
pages.push(page - 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (pages.length > 0) {
|
||||
splits.push({ pages, name: `part_${i + 1}` });
|
||||
}
|
||||
}
|
||||
} else if (data.splitMode === 'interval') {
|
||||
// Split every N pages
|
||||
const interval = data.interval;
|
||||
for (let i = 0; i < totalPages; i += interval) {
|
||||
const pages = [];
|
||||
for (let j = i; j < i + interval && j < totalPages; j++) {
|
||||
pages.push(j);
|
||||
}
|
||||
splits.push({ pages, name: `part_${Math.floor(i / interval) + 1}` });
|
||||
}
|
||||
} else if (data.splitMode === 'size') {
|
||||
// Split by size (approximate - we'll split evenly)
|
||||
// This is complex, so we'll do a simple even split for now
|
||||
const maxSize = data.maxSize * 1024 * 1024; // Convert MB to bytes
|
||||
// For simplicity, split into fixed page chunks
|
||||
const chunkSize = Math.max(1, Math.floor(totalPages / 5)); // Split into ~5 parts
|
||||
for (let i = 0; i < totalPages; i += chunkSize) {
|
||||
const pages = [];
|
||||
for (let j = i; j < i + chunkSize && j < totalPages; j++) {
|
||||
pages.push(j);
|
||||
}
|
||||
splits.push({ pages, name: `part_${Math.floor(i / chunkSize) + 1}` });
|
||||
}
|
||||
}
|
||||
|
||||
// Create split PDFs
|
||||
const baseName = path.basename(data.inputPath, '.pdf');
|
||||
for (const split of splits) {
|
||||
const newPdf = await PDFDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(pdf, split.pages);
|
||||
copiedPages.forEach(page => newPdf.addPage(page));
|
||||
|
||||
const outputPath = path.join(data.outputFolder, `${baseName}_${split.name}.pdf`);
|
||||
const newPdfBytes = await newPdf.save();
|
||||
fs.writeFileSync(outputPath, newPdfBytes);
|
||||
}
|
||||
|
||||
return { success: true, message: `Successfully split PDF into ${splits.length} files` };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// PDF Compress Operation
|
||||
async function pdfCompress(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
|
||||
// pdf-lib doesn't have built-in compression, but we can save with default compression
|
||||
// For actual compression, we would need additional libraries like Ghostscript
|
||||
// For now, we'll save with standard options which provides some compression
|
||||
const compressedPdfBytes = await pdf.save({
|
||||
useObjectStreams: true,
|
||||
addDefaultPage: false,
|
||||
objectsPerTick: 50
|
||||
});
|
||||
|
||||
fs.writeFileSync(data.outputPath, compressedPdfBytes);
|
||||
|
||||
const originalSize = fs.statSync(data.inputPath).size;
|
||||
const compressedSize = fs.statSync(data.outputPath).size;
|
||||
const savings = ((originalSize - compressedSize) / originalSize * 100).toFixed(1);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `PDF compressed. Size reduced by ${savings}% (${(originalSize / 1024).toFixed(1)}KB → ${(compressedSize / 1024).toFixed(1)}KB)`
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// PDF Rotate Pages Operation
|
||||
async function pdfRotate(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const totalPages = pdf.getPageCount();
|
||||
|
||||
let pagesToRotate = [];
|
||||
if (data.pages && data.pages.trim()) {
|
||||
pagesToRotate = parsePageRanges(data.pages, totalPages);
|
||||
} else {
|
||||
// Rotate all pages
|
||||
pagesToRotate = Array.from({ length: totalPages }, (_, i) => i);
|
||||
}
|
||||
|
||||
pagesToRotate.forEach(pageIndex => {
|
||||
const page = pdf.getPage(pageIndex);
|
||||
page.setRotation(degrees(data.angle));
|
||||
});
|
||||
|
||||
const rotatedPdfBytes = await pdf.save();
|
||||
fs.writeFileSync(data.outputPath, rotatedPdfBytes);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully rotated ${pagesToRotate.length} page(s) by ${data.angle}°`
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// PDF Delete Pages Operation
|
||||
async function pdfDeletePages(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const totalPages = pdf.getPageCount();
|
||||
|
||||
const pagesToDelete = parsePageRanges(data.pages, totalPages);
|
||||
|
||||
// Remove pages in reverse order to maintain indices
|
||||
pagesToDelete.sort((a, b) => b - a).forEach(pageIndex => {
|
||||
pdf.removePage(pageIndex);
|
||||
});
|
||||
|
||||
const newPdfBytes = await pdf.save();
|
||||
fs.writeFileSync(data.outputPath, newPdfBytes);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully deleted ${pagesToDelete.length} page(s). New PDF has ${totalPages - pagesToDelete.length} pages`
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// PDF Reorder Pages Operation
|
||||
async function pdfReorder(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const totalPages = pdf.getPageCount();
|
||||
|
||||
// Parse new page order
|
||||
const newOrder = data.newOrder.split(',').map(n => parseInt(n.trim()) - 1); // Convert to 0-indexed
|
||||
|
||||
// Validate new order
|
||||
if (newOrder.length !== totalPages) {
|
||||
return { success: false, error: `New order must include all ${totalPages} pages` };
|
||||
}
|
||||
|
||||
const newPdf = await PDFDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(pdf, newOrder);
|
||||
copiedPages.forEach(page => newPdf.addPage(page));
|
||||
|
||||
const reorderedPdfBytes = await newPdf.save();
|
||||
fs.writeFileSync(data.outputPath, reorderedPdfBytes);
|
||||
|
||||
return { success: true, message: 'Successfully reordered PDF pages' };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// PDF Watermark Operation
|
||||
async function pdfWatermark(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const totalPages = pdf.getPageCount();
|
||||
|
||||
// Determine which pages to watermark
|
||||
let pagesToWatermark = [];
|
||||
if (data.pages === 'all') {
|
||||
pagesToWatermark = Array.from({ length: totalPages }, (_, i) => i);
|
||||
} else if (data.pages === 'custom' && data.customPages) {
|
||||
pagesToWatermark = parsePageRanges(data.customPages, totalPages);
|
||||
}
|
||||
|
||||
const font = await pdf.embedFont(StandardFonts.Helvetica);
|
||||
const color = hexToRgb(data.color);
|
||||
|
||||
for (const pageIndex of pagesToWatermark) {
|
||||
const page = pdf.getPage(pageIndex);
|
||||
const { width, height } = page.getSize();
|
||||
|
||||
let x, y, rotation = 0;
|
||||
|
||||
// Calculate position based on selected position
|
||||
switch (data.position) {
|
||||
case 'center':
|
||||
x = width / 2;
|
||||
y = height / 2;
|
||||
break;
|
||||
case 'diagonal':
|
||||
x = width / 2;
|
||||
y = height / 2;
|
||||
rotation = 45;
|
||||
break;
|
||||
case 'top-left':
|
||||
x = 50;
|
||||
y = height - 50;
|
||||
break;
|
||||
case 'top-center':
|
||||
x = width / 2;
|
||||
y = height - 50;
|
||||
break;
|
||||
case 'top-right':
|
||||
x = width - 50;
|
||||
y = height - 50;
|
||||
break;
|
||||
case 'bottom-left':
|
||||
x = 50;
|
||||
y = 50;
|
||||
break;
|
||||
case 'bottom-center':
|
||||
x = width / 2;
|
||||
y = 50;
|
||||
break;
|
||||
case 'bottom-right':
|
||||
x = width - 50;
|
||||
y = 50;
|
||||
break;
|
||||
default:
|
||||
x = width / 2;
|
||||
y = height / 2;
|
||||
}
|
||||
|
||||
page.drawText(data.text, {
|
||||
x,
|
||||
y,
|
||||
size: data.fontSize,
|
||||
font,
|
||||
color: rgb(color.r, color.g, color.b),
|
||||
opacity: data.opacity,
|
||||
rotate: degrees(rotation)
|
||||
});
|
||||
}
|
||||
|
||||
const watermarkedPdfBytes = await pdf.save();
|
||||
fs.writeFileSync(data.outputPath, watermarkedPdfBytes);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully added watermark to ${pagesToWatermark.length} page(s)`
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// PDF Encrypt (Password Protection) Operation
|
||||
async function pdfEncrypt(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
|
||||
// pdf-lib has limited encryption support in v1.17.1
|
||||
// We'll save with password (basic encryption)
|
||||
const encryptedPdfBytes = await pdf.save({
|
||||
userPassword: data.userPassword,
|
||||
ownerPassword: data.ownerPassword || data.userPassword,
|
||||
permissions: {
|
||||
printing: data.permissions.printing ? 'highResolution' : 'lowResolution',
|
||||
modifying: data.permissions.modifying,
|
||||
copying: data.permissions.copying,
|
||||
annotating: data.permissions.annotating,
|
||||
fillingForms: data.permissions.fillingForms,
|
||||
contentAccessibility: data.permissions.contentAccessibility,
|
||||
documentAssembly: data.permissions.documentAssembly
|
||||
}
|
||||
});
|
||||
|
||||
fs.writeFileSync(data.outputPath, encryptedPdfBytes);
|
||||
|
||||
return { success: true, message: 'Successfully added password protection to PDF' };
|
||||
} catch (error) {
|
||||
// If pdf-lib doesn't support encryption in this version, provide a helpful error
|
||||
if (error.message.includes('encrypt') || error.message.includes('password')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'PDF encryption requires pdf-lib with encryption support. This feature may not be available in the current version.'
|
||||
};
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// PDF Decrypt (Remove Password) Operation
|
||||
async function pdfDecrypt(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes, { password: data.password });
|
||||
|
||||
// Save without password
|
||||
const decryptedPdfBytes = await pdf.save();
|
||||
fs.writeFileSync(data.outputPath, decryptedPdfBytes);
|
||||
|
||||
return { success: true, message: 'Successfully removed password protection from PDF' };
|
||||
} catch (error) {
|
||||
if (error.message.includes('password') || error.message.includes('encrypted')) {
|
||||
return { success: false, error: 'Incorrect password or PDF is not encrypted' };
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// PDF Permissions Operation
|
||||
async function pdfSetPermissions(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const loadOptions = data.currentPassword ? { password: data.currentPassword } : {};
|
||||
const pdf = await PDFDocument.load(pdfBytes, loadOptions);
|
||||
|
||||
const newPdfBytes = await pdf.save({
|
||||
ownerPassword: data.ownerPassword,
|
||||
permissions: {
|
||||
printing: data.permissions.printing ? 'highResolution' : 'lowResolution',
|
||||
modifying: data.permissions.modifying,
|
||||
copying: data.permissions.copying,
|
||||
annotating: data.permissions.annotating,
|
||||
fillingForms: data.permissions.fillingForms,
|
||||
contentAccessibility: data.permissions.contentAccessibility,
|
||||
documentAssembly: data.permissions.documentAssembly
|
||||
}
|
||||
});
|
||||
|
||||
fs.writeFileSync(data.outputPath, newPdfBytes);
|
||||
|
||||
return { success: true, message: 'Successfully updated PDF permissions' };
|
||||
} catch (error) {
|
||||
if (error.message.includes('encrypt') || error.message.includes('permission')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'PDF permissions require pdf-lib with encryption support. This feature may not be available in the current version.'
|
||||
};
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// IPC Handler for PDF Operations
|
||||
ipcMain.on('process-pdf-operation', async (event, data) => {
|
||||
try {
|
||||
mainWindow.webContents.send('pdf-operation-progress', {
|
||||
@@ -4120,43 +3767,7 @@ ipcMain.on('process-pdf-operation', async (event, data) => {
|
||||
progress: 10
|
||||
});
|
||||
|
||||
let result;
|
||||
|
||||
switch (data.operation) {
|
||||
case 'merge':
|
||||
result = await pdfMerge(data);
|
||||
break;
|
||||
case 'split':
|
||||
result = await pdfSplit(data);
|
||||
break;
|
||||
case 'compress':
|
||||
result = await pdfCompress(data);
|
||||
break;
|
||||
case 'rotate':
|
||||
result = await pdfRotate(data);
|
||||
break;
|
||||
case 'delete':
|
||||
result = await pdfDeletePages(data);
|
||||
break;
|
||||
case 'reorder':
|
||||
result = await pdfReorder(data);
|
||||
break;
|
||||
case 'watermark':
|
||||
result = await pdfWatermark(data);
|
||||
break;
|
||||
case 'encrypt':
|
||||
result = await pdfEncrypt(data);
|
||||
break;
|
||||
case 'decrypt':
|
||||
result = await pdfDecrypt(data);
|
||||
break;
|
||||
case 'permissions':
|
||||
result = await pdfSetPermissions(data);
|
||||
break;
|
||||
default:
|
||||
result = { success: false, error: `Unknown operation: ${data.operation}` };
|
||||
}
|
||||
|
||||
const result = await PDFOperations.executeOperation(data.operation, data);
|
||||
mainWindow.webContents.send('pdf-operation-complete', result);
|
||||
} catch (error) {
|
||||
mainWindow.webContents.send('pdf-operation-complete', {
|
||||
@@ -4166,12 +3777,9 @@ ipcMain.on('process-pdf-operation', async (event, data) => {
|
||||
}
|
||||
});
|
||||
|
||||
// IPC Handler for getting PDF page count
|
||||
ipcMain.on('get-pdf-page-count', async (event, filePath) => {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(filePath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const count = pdf.getPageCount();
|
||||
const count = await PDFOperations.getPageCount(filePath);
|
||||
event.reply('pdf-page-count', { count });
|
||||
} catch (error) {
|
||||
event.reply('pdf-page-count', { error: error.message });
|
||||
@@ -4344,6 +3952,8 @@ ipcMain.handle('list-directory', async (event, dirPath) => {
|
||||
.map(e => ({
|
||||
name: e.name,
|
||||
isDirectory: e.isDirectory(),
|
||||
size: e.isDirectory() ? 0 : fs.statSync(path.join(validation.resolved, e.name)).size,
|
||||
modified: fs.statSync(path.join(validation.resolved, e.name)).mtimeMs,
|
||||
path: path.join(validation.resolved, e.name)
|
||||
}));
|
||||
return { path: validation.resolved, entries };
|
||||
@@ -4353,6 +3963,103 @@ ipcMain.handle('list-directory', async (event, dirPath) => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('read-file', async (event, filePath) => {
|
||||
const validation = validatePath(filePath);
|
||||
if (!validation.valid || !isPathAccessible(validation.resolved)) {
|
||||
throw new Error(validation.error || 'Invalid file path');
|
||||
}
|
||||
|
||||
return fs.readFileSync(validation.resolved, 'utf-8');
|
||||
});
|
||||
|
||||
ipcMain.handle('write-file', async (event, payload) => {
|
||||
const validation = resolveWritablePath(payload?.path);
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.error || 'Invalid file path');
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(validation.resolved), { recursive: true });
|
||||
fs.writeFileSync(validation.resolved, payload?.content ?? '', 'utf-8');
|
||||
return { path: validation.resolved };
|
||||
});
|
||||
|
||||
ipcMain.handle('delete-file', async (event, filePath) => {
|
||||
const validation = validatePath(filePath);
|
||||
if (!validation.valid || !isPathAccessible(validation.resolved)) {
|
||||
throw new Error(validation.error || 'Invalid file path');
|
||||
}
|
||||
|
||||
fs.rmSync(validation.resolved, { recursive: true, force: false });
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('ensure-directory', async (event, dirPath) => {
|
||||
const validation = resolveWritablePath(dirPath);
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.error || 'Invalid directory path');
|
||||
}
|
||||
|
||||
fs.mkdirSync(validation.resolved, { recursive: true });
|
||||
return validation.resolved;
|
||||
});
|
||||
|
||||
ipcMain.handle('path-exists', async (event, filePath) => {
|
||||
const validation = resolveWritablePath(filePath);
|
||||
return validation.valid ? fs.existsSync(validation.resolved) : false;
|
||||
});
|
||||
|
||||
ipcMain.handle('is-directory', async (event, filePath) => {
|
||||
const validation = validatePath(filePath);
|
||||
if (!validation.valid || !isPathAccessible(validation.resolved)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return fs.statSync(validation.resolved).isDirectory();
|
||||
});
|
||||
|
||||
ipcMain.handle('copy-path', async (event, payload) => {
|
||||
const sourceValidation = validatePath(payload?.source);
|
||||
const destinationValidation = resolveWritablePath(payload?.destination);
|
||||
|
||||
if (!sourceValidation.valid || !isPathAccessible(sourceValidation.resolved)) {
|
||||
throw new Error(sourceValidation.error || 'Invalid source path');
|
||||
}
|
||||
if (!destinationValidation.valid) {
|
||||
throw new Error(destinationValidation.error || 'Invalid destination path');
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(destinationValidation.resolved), { recursive: true });
|
||||
fs.cpSync(sourceValidation.resolved, destinationValidation.resolved, { recursive: true });
|
||||
return { source: sourceValidation.resolved, destination: destinationValidation.resolved };
|
||||
});
|
||||
|
||||
ipcMain.handle('move-path', async (event, payload) => {
|
||||
const sourceValidation = validatePath(payload?.source);
|
||||
const destinationValidation = resolveWritablePath(payload?.destination);
|
||||
|
||||
if (!sourceValidation.valid || !isPathAccessible(sourceValidation.resolved)) {
|
||||
throw new Error(sourceValidation.error || 'Invalid source path');
|
||||
}
|
||||
if (!destinationValidation.valid) {
|
||||
throw new Error(destinationValidation.error || 'Invalid destination path');
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(destinationValidation.resolved), { recursive: true });
|
||||
|
||||
try {
|
||||
fs.renameSync(sourceValidation.resolved, destinationValidation.resolved);
|
||||
} catch (error) {
|
||||
if (error.code !== 'EXDEV') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
fs.cpSync(sourceValidation.resolved, destinationValidation.resolved, { recursive: true });
|
||||
fs.rmSync(sourceValidation.resolved, { recursive: true, force: false });
|
||||
}
|
||||
|
||||
return { source: sourceValidation.resolved, destination: destinationValidation.resolved };
|
||||
});
|
||||
|
||||
// Open a file by path (from explorer panel)
|
||||
ipcMain.on('open-file-path', (event, filePath) => {
|
||||
try {
|
||||
@@ -4380,49 +4087,24 @@ ipcMain.on('open-file-path', (event, filePath) => {
|
||||
// ============================================
|
||||
// Git IPC Handlers
|
||||
// ============================================
|
||||
const simpleGit = require('simple-git');
|
||||
|
||||
function getGitRepo() {
|
||||
// Use the directory of the current file, or the app's working directory
|
||||
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
|
||||
return simpleGit(dir);
|
||||
}
|
||||
|
||||
ipcMain.handle('git-status', async () => {
|
||||
try {
|
||||
const git = getGitRepo();
|
||||
return await git.status();
|
||||
} catch (err) {
|
||||
return { error: 'Not a git repository' };
|
||||
}
|
||||
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
|
||||
return GitOperations.getStatus(dir);
|
||||
});
|
||||
|
||||
ipcMain.handle('git-stage', async (event, { files }) => {
|
||||
try {
|
||||
const git = getGitRepo();
|
||||
await git.add(files);
|
||||
return await git.status();
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
}
|
||||
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
|
||||
return GitOperations.stage(dir, files);
|
||||
});
|
||||
|
||||
ipcMain.handle('git-commit', async (event, { message }) => {
|
||||
try {
|
||||
const git = getGitRepo();
|
||||
return await git.commit(message);
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
}
|
||||
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
|
||||
return GitOperations.commit(dir, message);
|
||||
});
|
||||
|
||||
ipcMain.handle('git-log', async () => {
|
||||
try {
|
||||
const git = getGitRepo();
|
||||
return await git.log({ maxCount: 20 });
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
}
|
||||
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
|
||||
return GitOperations.log(dir);
|
||||
});
|
||||
|
||||
// ============================================
|
||||
@@ -4490,4 +4172,4 @@ ipcMain.handle('execute-code', async (event, { code, language }) => {
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
const simpleGit = require('simple-git');
|
||||
|
||||
function getGitInstance(dir) {
|
||||
return simpleGit(dir);
|
||||
}
|
||||
|
||||
async function getStatus(dir) {
|
||||
try {
|
||||
const git = getGitInstance(dir);
|
||||
return await git.status();
|
||||
} catch (err) {
|
||||
return { error: 'Not a git repository' };
|
||||
}
|
||||
}
|
||||
|
||||
async function stage(dir, files) {
|
||||
try {
|
||||
const git = getGitInstance(dir);
|
||||
await git.add(files);
|
||||
return await git.status();
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function commit(dir, message) {
|
||||
try {
|
||||
const git = getGitInstance(dir);
|
||||
return await git.commit(message);
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function log(dir, maxCount = 20) {
|
||||
try {
|
||||
const git = getGitInstance(dir);
|
||||
return await git.log({ maxCount });
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getStatus, stage, commit, log };
|
||||
@@ -0,0 +1,433 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { PDFDocument, rgb, degrees, StandardFonts } = require('pdf-lib');
|
||||
|
||||
function parsePageRanges(rangeString, totalPages) {
|
||||
const pages = [];
|
||||
const ranges = rangeString.split(',').map(r => r.trim());
|
||||
|
||||
for (const range of ranges) {
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||||
for (let i = start; i <= end && i <= totalPages; i++) {
|
||||
if (i > 0 && !pages.includes(i - 1)) {
|
||||
pages.push(i - 1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const page = parseInt(range);
|
||||
if (page > 0 && page <= totalPages && !pages.includes(page - 1)) {
|
||||
pages.push(page - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pages.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16) / 255,
|
||||
g: parseInt(result[2], 16) / 255,
|
||||
b: parseInt(result[3], 16) / 255
|
||||
} : { r: 0, g: 0, b: 0 };
|
||||
}
|
||||
|
||||
async function pdfMerge(data) {
|
||||
try {
|
||||
const mergedPdf = await PDFDocument.create();
|
||||
|
||||
for (const filePath of data.inputFiles) {
|
||||
const pdfBytes = fs.readFileSync(filePath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
|
||||
copiedPages.forEach(page => mergedPdf.addPage(page));
|
||||
}
|
||||
|
||||
const pdfBytes = await mergedPdf.save();
|
||||
fs.writeFileSync(data.outputPath, pdfBytes);
|
||||
|
||||
return { success: true, message: `Successfully merged ${data.inputFiles.length} PDFs` };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function pdfSplit(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const totalPages = pdf.getPageCount();
|
||||
|
||||
let splits = [];
|
||||
|
||||
if (data.splitMode === 'pages') {
|
||||
const ranges = data.pageRanges.split(',').map(r => r.trim());
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
const range = ranges[i];
|
||||
let pages = [];
|
||||
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||||
for (let p = start; p <= end && p <= totalPages; p++) {
|
||||
pages.push(p - 1);
|
||||
}
|
||||
} else {
|
||||
const page = parseInt(range);
|
||||
if (page > 0 && page <= totalPages) {
|
||||
pages.push(page - 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (pages.length > 0) {
|
||||
splits.push({ pages, name: `part_${i + 1}` });
|
||||
}
|
||||
}
|
||||
} else if (data.splitMode === 'interval') {
|
||||
const interval = data.interval;
|
||||
for (let i = 0; i < totalPages; i += interval) {
|
||||
const pages = [];
|
||||
for (let j = i; j < i + interval && j < totalPages; j++) {
|
||||
pages.push(j);
|
||||
}
|
||||
splits.push({ pages, name: `part_${Math.floor(i / interval) + 1}` });
|
||||
}
|
||||
} else if (data.splitMode === 'size') {
|
||||
const chunkSize = Math.max(1, Math.floor(totalPages / 5));
|
||||
for (let i = 0; i < totalPages; i += chunkSize) {
|
||||
const pages = [];
|
||||
for (let j = i; j < i + chunkSize && j < totalPages; j++) {
|
||||
pages.push(j);
|
||||
}
|
||||
splits.push({ pages, name: `part_${Math.floor(i / chunkSize) + 1}` });
|
||||
}
|
||||
}
|
||||
|
||||
const baseName = path.basename(data.inputPath, '.pdf');
|
||||
for (const split of splits) {
|
||||
const newPdf = await PDFDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(pdf, split.pages);
|
||||
copiedPages.forEach(page => newPdf.addPage(page));
|
||||
|
||||
const outputPath = path.join(data.outputFolder, `${baseName}_${split.name}.pdf`);
|
||||
const newPdfBytes = await newPdf.save();
|
||||
fs.writeFileSync(outputPath, newPdfBytes);
|
||||
}
|
||||
|
||||
return { success: true, message: `Successfully split PDF into ${splits.length} files` };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function pdfCompress(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
|
||||
const compressedPdfBytes = await pdf.save({
|
||||
useObjectStreams: true,
|
||||
addDefaultPage: false,
|
||||
objectsPerTick: 50
|
||||
});
|
||||
|
||||
fs.writeFileSync(data.outputPath, compressedPdfBytes);
|
||||
|
||||
const originalSize = fs.statSync(data.inputPath).size;
|
||||
const compressedSize = fs.statSync(data.outputPath).size;
|
||||
const savings = ((originalSize - compressedSize) / originalSize * 100).toFixed(1);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `PDF compressed. Size reduced by ${savings}% (${(originalSize / 1024).toFixed(1)}KB → ${(compressedSize / 1024).toFixed(1)}KB)`
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function pdfRotate(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const totalPages = pdf.getPageCount();
|
||||
|
||||
let pagesToRotate = [];
|
||||
if (data.pages && data.pages.trim()) {
|
||||
pagesToRotate = parsePageRanges(data.pages, totalPages);
|
||||
} else {
|
||||
pagesToRotate = Array.from({ length: totalPages }, (_, i) => i);
|
||||
}
|
||||
|
||||
pagesToRotate.forEach(pageIndex => {
|
||||
const page = pdf.getPage(pageIndex);
|
||||
page.setRotation(degrees(data.angle));
|
||||
});
|
||||
|
||||
const rotatedPdfBytes = await pdf.save();
|
||||
fs.writeFileSync(data.outputPath, rotatedPdfBytes);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully rotated ${pagesToRotate.length} page(s) by ${data.angle}\u00B0`
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function pdfDeletePages(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const totalPages = pdf.getPageCount();
|
||||
|
||||
const pagesToDelete = parsePageRanges(data.pages, totalPages);
|
||||
|
||||
pagesToDelete.sort((a, b) => b - a).forEach(pageIndex => {
|
||||
pdf.removePage(pageIndex);
|
||||
});
|
||||
|
||||
const newPdfBytes = await pdf.save();
|
||||
fs.writeFileSync(data.outputPath, newPdfBytes);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully deleted ${pagesToDelete.length} page(s). New PDF has ${totalPages - pagesToDelete.length} pages`
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function pdfReorder(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const totalPages = pdf.getPageCount();
|
||||
|
||||
const newOrder = data.newOrder.split(',').map(n => parseInt(n.trim()) - 1);
|
||||
|
||||
if (newOrder.length !== totalPages) {
|
||||
return { success: false, error: `New order must include all ${totalPages} pages` };
|
||||
}
|
||||
|
||||
const newPdf = await PDFDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(pdf, newOrder);
|
||||
copiedPages.forEach(page => newPdf.addPage(page));
|
||||
|
||||
const reorderedPdfBytes = await newPdf.save();
|
||||
fs.writeFileSync(data.outputPath, reorderedPdfBytes);
|
||||
|
||||
return { success: true, message: 'Successfully reordered PDF pages' };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function pdfWatermark(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const totalPages = pdf.getPageCount();
|
||||
|
||||
let pagesToWatermark = [];
|
||||
if (data.pages === 'all') {
|
||||
pagesToWatermark = Array.from({ length: totalPages }, (_, i) => i);
|
||||
} else if (data.pages === 'custom' && data.customPages) {
|
||||
pagesToWatermark = parsePageRanges(data.customPages, totalPages);
|
||||
}
|
||||
|
||||
const font = await pdf.embedFont(StandardFonts.Helvetica);
|
||||
const color = hexToRgb(data.color);
|
||||
|
||||
for (const pageIndex of pagesToWatermark) {
|
||||
const page = pdf.getPage(pageIndex);
|
||||
const { width, height } = page.getSize();
|
||||
|
||||
let x, y, rotation = 0;
|
||||
|
||||
switch (data.position) {
|
||||
case 'center':
|
||||
x = width / 2;
|
||||
y = height / 2;
|
||||
break;
|
||||
case 'diagonal':
|
||||
x = width / 2;
|
||||
y = height / 2;
|
||||
rotation = 45;
|
||||
break;
|
||||
case 'top-left':
|
||||
x = 50;
|
||||
y = height - 50;
|
||||
break;
|
||||
case 'top-center':
|
||||
x = width / 2;
|
||||
y = height - 50;
|
||||
break;
|
||||
case 'top-right':
|
||||
x = width - 50;
|
||||
y = height - 50;
|
||||
break;
|
||||
case 'bottom-left':
|
||||
x = 50;
|
||||
y = 50;
|
||||
break;
|
||||
case 'bottom-center':
|
||||
x = width / 2;
|
||||
y = 50;
|
||||
break;
|
||||
case 'bottom-right':
|
||||
x = width - 50;
|
||||
y = 50;
|
||||
break;
|
||||
default:
|
||||
x = width / 2;
|
||||
y = height / 2;
|
||||
}
|
||||
|
||||
page.drawText(data.text, {
|
||||
x,
|
||||
y,
|
||||
size: data.fontSize,
|
||||
font,
|
||||
color: rgb(color.r, color.g, color.b),
|
||||
opacity: data.opacity,
|
||||
rotate: degrees(rotation)
|
||||
});
|
||||
}
|
||||
|
||||
const watermarkedPdfBytes = await pdf.save();
|
||||
fs.writeFileSync(data.outputPath, watermarkedPdfBytes);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully added watermark to ${pagesToWatermark.length} page(s)`
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function pdfEncrypt(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
|
||||
const encryptedPdfBytes = await pdf.save({
|
||||
userPassword: data.userPassword,
|
||||
ownerPassword: data.ownerPassword || data.userPassword,
|
||||
permissions: {
|
||||
printing: data.permissions.printing ? 'highResolution' : 'lowResolution',
|
||||
modifying: data.permissions.modifying,
|
||||
copying: data.permissions.copying,
|
||||
annotating: data.permissions.annotating,
|
||||
fillingForms: data.permissions.fillingForms,
|
||||
contentAccessibility: data.permissions.contentAccessibility,
|
||||
documentAssembly: data.permissions.documentAssembly
|
||||
}
|
||||
});
|
||||
|
||||
fs.writeFileSync(data.outputPath, encryptedPdfBytes);
|
||||
|
||||
return { success: true, message: 'Successfully added password protection to PDF' };
|
||||
} catch (error) {
|
||||
if (error.message.includes('encrypt') || error.message.includes('password')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'PDF encryption requires pdf-lib with encryption support. This feature may not be available in the current version.'
|
||||
};
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function pdfDecrypt(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const pdf = await PDFDocument.load(pdfBytes, { password: data.password });
|
||||
|
||||
const decryptedPdfBytes = await pdf.save();
|
||||
fs.writeFileSync(data.outputPath, decryptedPdfBytes);
|
||||
|
||||
return { success: true, message: 'Successfully removed password protection from PDF' };
|
||||
} catch (error) {
|
||||
if (error.message.includes('password') || error.message.includes('encrypted')) {
|
||||
return { success: false, error: 'Incorrect password or PDF is not encrypted' };
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function pdfSetPermissions(data) {
|
||||
try {
|
||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||
const loadOptions = data.currentPassword ? { password: data.currentPassword } : {};
|
||||
const pdf = await PDFDocument.load(pdfBytes, loadOptions);
|
||||
|
||||
const newPdfBytes = await pdf.save({
|
||||
ownerPassword: data.ownerPassword,
|
||||
permissions: {
|
||||
printing: data.permissions.printing ? 'highResolution' : 'lowResolution',
|
||||
modifying: data.permissions.modifying,
|
||||
copying: data.permissions.copying,
|
||||
annotating: data.permissions.annotating,
|
||||
fillingForms: data.permissions.fillingForms,
|
||||
contentAccessibility: data.permissions.contentAccessibility,
|
||||
documentAssembly: data.permissions.documentAssembly
|
||||
}
|
||||
});
|
||||
|
||||
fs.writeFileSync(data.outputPath, newPdfBytes);
|
||||
|
||||
return { success: true, message: 'Successfully updated PDF permissions' };
|
||||
} catch (error) {
|
||||
if (error.message.includes('encrypt') || error.message.includes('permission')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'PDF permissions require pdf-lib with encryption support. This feature may not be available in the current version.'
|
||||
};
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
function executeOperation(operation, data) {
|
||||
switch (operation) {
|
||||
case 'merge': return pdfMerge(data);
|
||||
case 'split': return pdfSplit(data);
|
||||
case 'compress': return pdfCompress(data);
|
||||
case 'rotate': return pdfRotate(data);
|
||||
case 'delete': return pdfDeletePages(data);
|
||||
case 'reorder': return pdfReorder(data);
|
||||
case 'watermark': return pdfWatermark(data);
|
||||
case 'encrypt': return pdfEncrypt(data);
|
||||
case 'decrypt': return pdfDecrypt(data);
|
||||
case 'permissions': return pdfSetPermissions(data);
|
||||
default: return Promise.resolve({ success: false, error: `Unknown operation: ${operation}` });
|
||||
}
|
||||
}
|
||||
|
||||
async function getPageCount(filePath) {
|
||||
const pdfBytes = fs.readFileSync(filePath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
return pdf.getPageCount();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parsePageRanges,
|
||||
hexToRgb,
|
||||
pdfMerge,
|
||||
pdfSplit,
|
||||
pdfCompress,
|
||||
pdfRotate,
|
||||
pdfDeletePages,
|
||||
pdfReorder,
|
||||
pdfWatermark,
|
||||
pdfEncrypt,
|
||||
pdfDecrypt,
|
||||
pdfSetPermissions,
|
||||
executeOperation,
|
||||
getPageCount
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
const { PluginAPI } = require('../../plugin-api');
|
||||
|
||||
class SamplePlugin extends PluginAPI {
|
||||
init(context) {
|
||||
this.context = context;
|
||||
context.commands.register('hello', 'Sample: Hello World', () => {
|
||||
console.log('[SamplePlugin] Hello from the plugin system!');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Plugin: SamplePlugin };
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"id": "_sample",
|
||||
"name": "Sample Plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "Demonstrates the plugin system. Safe to delete.",
|
||||
"icon": "puzzle",
|
||||
"extensionPoints": {
|
||||
"commands": [
|
||||
{ "id": "hello", "label": "Sample: Hello World", "shortcut": "" }
|
||||
]
|
||||
},
|
||||
"settings": []
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
const HISTORY_KEY = 'plugins.writing-studio.history';
|
||||
|
||||
class GoalTracker {
|
||||
/**
|
||||
* @param {object} store - { get(key), set(key, value) } settings backend
|
||||
*/
|
||||
constructor(store) {
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
_getHistory() {
|
||||
const raw = this.store.get(HISTORY_KEY);
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
}
|
||||
|
||||
_setHistory(history) {
|
||||
this.store.set(HISTORY_KEY, JSON.stringify(history));
|
||||
}
|
||||
|
||||
_setHistoryDay(dateStr, data) {
|
||||
const history = this._getHistory();
|
||||
history[dateStr] = data;
|
||||
this._setHistory(history);
|
||||
}
|
||||
|
||||
addWords(count) {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const history = this._getHistory();
|
||||
if (!history[today]) {
|
||||
history[today] = { words: 0, sessions: 0 };
|
||||
}
|
||||
history[today].words += count;
|
||||
history[today].sessions += 1;
|
||||
this._setHistory(history);
|
||||
}
|
||||
|
||||
getDailyProgress(goal) {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const history = this._getHistory();
|
||||
const written = history[today]?.words || 0;
|
||||
return { written, goal, pct: goal > 0 ? Math.min(100, Math.round((written / goal) * 100)) : 0 };
|
||||
}
|
||||
|
||||
getStreak(goal) {
|
||||
const history = this._getHistory();
|
||||
let streak = 0;
|
||||
const d = new Date();
|
||||
for (let i = 0; i < 365; i++) {
|
||||
const key = d.toISOString().split('T')[0];
|
||||
const day = history[key];
|
||||
if (day && day.words >= goal) {
|
||||
streak++;
|
||||
d.setDate(d.getDate() - 1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return streak;
|
||||
}
|
||||
|
||||
getLast30Days() {
|
||||
const history = this._getHistory();
|
||||
const days = [];
|
||||
const d = new Date();
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const key = d.toISOString().split('T')[0];
|
||||
const day = history[key];
|
||||
days.push({ date: key, words: day?.words || 0 });
|
||||
d.setDate(d.getDate() - 1);
|
||||
}
|
||||
return days.reverse();
|
||||
}
|
||||
|
||||
getWeeklyTotal() {
|
||||
const history = this._getHistory();
|
||||
let total = 0;
|
||||
const d = new Date();
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const key = d.toISOString().split('T')[0];
|
||||
if (history[key]) total += history[key].words || 0;
|
||||
d.setDate(d.getDate() - 1);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { GoalTracker };
|
||||
@@ -0,0 +1,102 @@
|
||||
const { PluginAPI } = require('../../../plugins/plugin-api');
|
||||
const { SprintEngine } = require('./sprint-engine');
|
||||
const { GoalTracker } = require('./goal-tracker');
|
||||
const { SnapshotManager } = require('./snapshot-manager');
|
||||
const { ProjectManager } = require('./project-manager');
|
||||
|
||||
class WritingStudioPlugin extends PluginAPI {
|
||||
init(context) {
|
||||
this.context = context;
|
||||
|
||||
this.sprintEngine = new SprintEngine({
|
||||
onEvent: (name, data) => context.events.emit(name, data)
|
||||
});
|
||||
this.goalTracker = new GoalTracker(context.settings);
|
||||
this.snapshotManager = new SnapshotManager(context.settings);
|
||||
this.projectManager = new ProjectManager({
|
||||
readFile: (p) => context.ipc.invoke('read-file', p),
|
||||
writeFile: (p, c) => context.ipc.invoke('write-file', p, c),
|
||||
fileExists: (p) => context.ipc.invoke('path-exists', p),
|
||||
listDir: (p) => context.ipc.invoke('list-directory', p)
|
||||
});
|
||||
|
||||
this._engines = {
|
||||
sprint: this.sprintEngine,
|
||||
goals: this.goalTracker,
|
||||
snapshots: this.snapshotManager,
|
||||
projects: this.projectManager
|
||||
};
|
||||
|
||||
this._registerCommands(context);
|
||||
this._registerStatusBar(context);
|
||||
}
|
||||
|
||||
_registerCommands(context) {
|
||||
const { sprintEngine, snapshotManager, goalTracker } = this;
|
||||
|
||||
context.commands.register('start-sprint', 'Studio: Start Sprint', () => {
|
||||
const duration = context.settings.get('sprintDuration') || 25;
|
||||
const content = context.editor.getContent() || '';
|
||||
const words = content.split(/\s+/).filter(Boolean).length;
|
||||
sprintEngine.start(duration, words);
|
||||
}, 'Ctrl+Alt+S');
|
||||
|
||||
context.commands.register('stop-sprint', 'Studio: Stop Sprint', () => {
|
||||
if (!sprintEngine.isActive()) return;
|
||||
const content = context.editor.getContent() || '';
|
||||
const words = content.split(/\s+/).filter(Boolean).length;
|
||||
const result = sprintEngine.stop(words);
|
||||
goalTracker.addWords(result.wordDelta);
|
||||
context.events.emit('sprint:stopped', result);
|
||||
}, 'Ctrl+Alt+Shift+S');
|
||||
|
||||
context.commands.register('take-snapshot', 'Studio: Take Snapshot', () => {
|
||||
const content = context.editor.getContent() || '';
|
||||
snapshotManager.create(content, 'manual');
|
||||
context.events.emit('snapshot:created', {});
|
||||
}, 'Ctrl+Alt+N');
|
||||
|
||||
context.commands.register('restore-last-snapshot', 'Studio: Restore Last Snapshot', () => {
|
||||
const snaps = snapshotManager.list();
|
||||
if (snaps.length === 0) return;
|
||||
const content = snapshotManager.restore(snaps[0].id);
|
||||
context.editor.insertAtCursor(content);
|
||||
}, 'Ctrl+Alt+Z');
|
||||
|
||||
context.commands.register('new-project', 'Studio: New Project', () => {
|
||||
context.events.emit('studio:new-project', {});
|
||||
});
|
||||
|
||||
context.commands.register('compile-manuscript', 'Studio: Compile Manuscript', () => {
|
||||
context.events.emit('studio:compile', {});
|
||||
}, 'Ctrl+Alt+E');
|
||||
|
||||
context.commands.register('proofread-document', 'Studio: Proofread Document', () => {
|
||||
if (context.events.hasHandler('ai:analyze')) {
|
||||
const content = context.editor.getContent() || '';
|
||||
context.events.emit('ai:analyze', { text: content, type: 'grammar' });
|
||||
}
|
||||
}, 'Ctrl+Alt+G');
|
||||
}
|
||||
|
||||
_registerStatusBar(context) {
|
||||
context.statusBar.registerIndicator('word-goal', {
|
||||
text: '0/1000',
|
||||
tooltip: 'Daily word goal progress'
|
||||
});
|
||||
context.statusBar.registerIndicator('sprint-timer', {
|
||||
text: '',
|
||||
tooltip: 'Writing sprint timer'
|
||||
});
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
if (this._sprintInterval) clearInterval(this._sprintInterval);
|
||||
}
|
||||
|
||||
getEngines() {
|
||||
return this._engines;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Plugin: WritingStudioPlugin };
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"id": "writing-studio",
|
||||
"name": "Writing Studio",
|
||||
"version": "1.0.0",
|
||||
"description": "Manuscript management, writing sprints, goal tracking, snapshots, and smart proofreading",
|
||||
"icon": "pen-tool",
|
||||
"extensionPoints": {
|
||||
"sidebar": [
|
||||
{ "id": "manuscript", "title": "Manuscript", "order": 30 },
|
||||
{ "id": "goals", "title": "Goals", "order": 31 },
|
||||
{ "id": "snapshots", "title": "Snapshots", "order": 32 },
|
||||
{ "id": "proofread", "title": "Proofread", "order": 33 }
|
||||
],
|
||||
"commands": [
|
||||
{ "id": "start-sprint", "label": "Studio: Start Sprint", "shortcut": "Ctrl+Alt+S" },
|
||||
{ "id": "stop-sprint", "label": "Studio: Stop Sprint", "shortcut": "Ctrl+Alt+Shift+S" },
|
||||
{ "id": "take-snapshot", "label": "Studio: Take Snapshot", "shortcut": "Ctrl+Alt+N" },
|
||||
{ "id": "restore-last-snapshot", "label": "Studio: Restore Last Snapshot", "shortcut": "Ctrl+Alt+Z" },
|
||||
{ "id": "new-project", "label": "Studio: New Project", "shortcut": "" },
|
||||
{ "id": "compile-manuscript", "label": "Studio: Compile Manuscript", "shortcut": "Ctrl+Alt+E" },
|
||||
{ "id": "proofread-document", "label": "Studio: Proofread Document", "shortcut": "Ctrl+Alt+G" }
|
||||
],
|
||||
"statusBar": { "indicators": ["sprint-timer", "word-goal"] }
|
||||
},
|
||||
"settings": [
|
||||
{ "key": "dailyGoal", "type": "number", "default": 1000, "label": "Daily word goal" },
|
||||
{ "key": "sprintDuration", "type": "number", "default": 25, "label": "Sprint duration (min)" },
|
||||
{ "key": "autoSnapshotInterval", "type": "number", "default": 0, "label": "Auto-snapshot interval (min, 0=off)" },
|
||||
{ "key": "maxSnapshots", "type": "number", "default": 50, "label": "Max snapshots to keep" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
function renderGoalsPanel(container, { engines, settings }) {
|
||||
const dailyGoal = settings.get('dailyGoal') || 1000;
|
||||
const progress = engines.goals.getDailyProgress(dailyGoal);
|
||||
const streak = engines.goals.getStreak(dailyGoal);
|
||||
const weekly = engines.goals.getWeeklyTotal();
|
||||
const last30 = engines.goals.getLast30Days();
|
||||
|
||||
container.replaceChildren();
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'ws-panel';
|
||||
|
||||
// Daily progress section
|
||||
const section1 = document.createElement('div');
|
||||
section1.className = 'ws-section';
|
||||
const heading1 = document.createElement('h3');
|
||||
heading1.className = 'ws-heading';
|
||||
heading1.textContent = 'Daily Progress';
|
||||
section1.appendChild(heading1);
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'ws-progress-bar';
|
||||
const fill = document.createElement('div');
|
||||
fill.className = 'ws-progress-fill';
|
||||
fill.style.width = progress.pct + '%';
|
||||
bar.appendChild(fill);
|
||||
section1.appendChild(bar);
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'ws-stat-row';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = progress.written.toLocaleString() + ' / ' + dailyGoal.toLocaleString() + ' words';
|
||||
const pct = document.createElement('span');
|
||||
pct.className = 'ws-pct';
|
||||
pct.textContent = progress.pct + '%';
|
||||
row.appendChild(label);
|
||||
row.appendChild(pct);
|
||||
section1.appendChild(row);
|
||||
panel.appendChild(section1);
|
||||
|
||||
// Stats cards
|
||||
const section2 = document.createElement('div');
|
||||
section2.className = 'ws-section';
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'ws-stat-grid';
|
||||
|
||||
const streakCard = document.createElement('div');
|
||||
streakCard.className = 'ws-stat-card';
|
||||
const streakVal = document.createElement('span');
|
||||
streakVal.className = 'ws-stat-value';
|
||||
streakVal.textContent = String(streak);
|
||||
const streakLbl = document.createElement('span');
|
||||
streakLbl.className = 'ws-stat-label';
|
||||
streakLbl.textContent = 'Day Streak';
|
||||
streakCard.appendChild(streakVal);
|
||||
streakCard.appendChild(streakLbl);
|
||||
|
||||
const weekCard = document.createElement('div');
|
||||
weekCard.className = 'ws-stat-card';
|
||||
const weekVal = document.createElement('span');
|
||||
weekVal.className = 'ws-stat-value';
|
||||
weekVal.textContent = weekly.toLocaleString();
|
||||
const weekLbl = document.createElement('span');
|
||||
weekLbl.className = 'ws-stat-label';
|
||||
weekLbl.textContent = 'This Week';
|
||||
weekCard.appendChild(weekVal);
|
||||
weekCard.appendChild(weekLbl);
|
||||
|
||||
grid.appendChild(streakCard);
|
||||
grid.appendChild(weekCard);
|
||||
section2.appendChild(grid);
|
||||
panel.appendChild(section2);
|
||||
|
||||
// 30-day chart
|
||||
const section3 = document.createElement('div');
|
||||
section3.className = 'ws-section';
|
||||
const heading3 = document.createElement('h3');
|
||||
heading3.className = 'ws-heading';
|
||||
heading3.textContent = 'Last 30 Days';
|
||||
section3.appendChild(heading3);
|
||||
|
||||
const chart = document.createElement('div');
|
||||
chart.className = 'ws-chart';
|
||||
const maxWords = Math.max(...last30.map(d => d.words), 1);
|
||||
for (const day of last30) {
|
||||
const barEl = document.createElement('div');
|
||||
const height = Math.max(2, (day.words / maxWords) * 60);
|
||||
barEl.className = 'ws-bar' + (day.words >= dailyGoal ? ' ws-bar-met' : '');
|
||||
barEl.style.height = height + 'px';
|
||||
barEl.title = day.date + ': ' + day.words + ' words';
|
||||
chart.appendChild(barEl);
|
||||
}
|
||||
section3.appendChild(chart);
|
||||
panel.appendChild(section3);
|
||||
|
||||
container.appendChild(panel);
|
||||
}
|
||||
|
||||
module.exports = { renderGoalsPanel };
|
||||
@@ -0,0 +1,124 @@
|
||||
function renderManuscriptPanel(container, { engines, editor, settings }) {
|
||||
const projectDir = settings.get('projectDir');
|
||||
|
||||
container.replaceChildren();
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'ws-panel';
|
||||
|
||||
if (!projectDir) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'ws-empty';
|
||||
const p = document.createElement('p');
|
||||
p.textContent = 'No manuscript project open';
|
||||
empty.appendChild(p);
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'ws-btn ws-btn-primary';
|
||||
btn.id = 'ws-new-project';
|
||||
btn.textContent = 'New Project';
|
||||
btn.addEventListener('click', () => {
|
||||
const name = prompt('Project name:');
|
||||
if (!name) return;
|
||||
settings.set('projectDir', name);
|
||||
renderManuscriptPanel(container, { engines, editor, settings });
|
||||
});
|
||||
empty.appendChild(btn);
|
||||
panel.appendChild(empty);
|
||||
container.appendChild(panel);
|
||||
return;
|
||||
}
|
||||
|
||||
const project = engines.projects.loadProject(projectDir);
|
||||
if (!project) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'ws-empty';
|
||||
const p = document.createElement('p');
|
||||
p.textContent = 'Project not found at ' + projectDir;
|
||||
empty.appendChild(p);
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'ws-btn';
|
||||
btn.id = 'ws-clear-project';
|
||||
btn.textContent = 'Clear Project';
|
||||
btn.addEventListener('click', () => {
|
||||
settings.set('projectDir', null);
|
||||
renderManuscriptPanel(container, { engines, editor, settings });
|
||||
});
|
||||
empty.appendChild(btn);
|
||||
panel.appendChild(empty);
|
||||
container.appendChild(panel);
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = engines.projects.getStats(projectDir);
|
||||
|
||||
// Project title + progress
|
||||
const section1 = document.createElement('div');
|
||||
section1.className = 'ws-section';
|
||||
const heading = document.createElement('h3');
|
||||
heading.className = 'ws-heading';
|
||||
heading.textContent = project.title;
|
||||
section1.appendChild(heading);
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'ws-progress-bar';
|
||||
const fill = document.createElement('div');
|
||||
fill.className = 'ws-progress-fill';
|
||||
fill.style.width = stats.pctComplete + '%';
|
||||
bar.appendChild(fill);
|
||||
section1.appendChild(bar);
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'ws-stat-row';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = stats.totalWords.toLocaleString() + ' / ' + stats.targetWords.toLocaleString() + ' words';
|
||||
const pct = document.createElement('span');
|
||||
pct.className = 'ws-pct';
|
||||
pct.textContent = stats.pctComplete + '%';
|
||||
row.appendChild(label);
|
||||
row.appendChild(pct);
|
||||
section1.appendChild(row);
|
||||
panel.appendChild(section1);
|
||||
|
||||
// Chapters list
|
||||
const section2 = document.createElement('div');
|
||||
section2.className = 'ws-section';
|
||||
const heading2 = document.createElement('h3');
|
||||
heading2.className = 'ws-heading';
|
||||
heading2.textContent = 'Chapters (' + project.chapters.length + ')';
|
||||
section2.appendChild(heading2);
|
||||
|
||||
const chList = document.createElement('div');
|
||||
chList.className = 'ws-chapter-list';
|
||||
for (const ch of project.chapters) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'ws-chapter-item';
|
||||
const title = document.createElement('span');
|
||||
title.className = 'ws-chapter-title';
|
||||
title.textContent = ch.title || ch.file;
|
||||
const status = document.createElement('span');
|
||||
status.className = 'ws-chapter-status ws-status-' + (ch.status || 'draft');
|
||||
status.textContent = ch.status || 'draft';
|
||||
item.appendChild(title);
|
||||
item.appendChild(status);
|
||||
chList.appendChild(item);
|
||||
}
|
||||
section2.appendChild(chList);
|
||||
panel.appendChild(section2);
|
||||
|
||||
// Compile button
|
||||
const section3 = document.createElement('div');
|
||||
section3.className = 'ws-section';
|
||||
const compileBtn = document.createElement('button');
|
||||
compileBtn.className = 'ws-btn ws-btn-primary';
|
||||
compileBtn.id = 'ws-compile';
|
||||
compileBtn.textContent = 'Compile Manuscript';
|
||||
compileBtn.addEventListener('click', () => {
|
||||
const compiled = engines.projects.compileManuscript(projectDir);
|
||||
editor.insertAtCursor(compiled);
|
||||
});
|
||||
section3.appendChild(compileBtn);
|
||||
panel.appendChild(section3);
|
||||
|
||||
container.appendChild(panel);
|
||||
}
|
||||
|
||||
module.exports = { renderManuscriptPanel };
|
||||
@@ -0,0 +1,97 @@
|
||||
function renderProofreadPanel(container, { events, editor }) {
|
||||
const hasAI = events.hasHandler('ai:analyze');
|
||||
|
||||
container.replaceChildren();
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'ws-panel';
|
||||
|
||||
const section = document.createElement('div');
|
||||
section.className = 'ws-section';
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'ws-btn ws-btn-primary';
|
||||
btn.id = 'ws-proofread';
|
||||
btn.textContent = hasAI ? 'Check Document' : 'AI Plugin Required';
|
||||
btn.disabled = !hasAI;
|
||||
section.appendChild(btn);
|
||||
|
||||
if (!hasAI) {
|
||||
const note = document.createElement('p');
|
||||
note.className = 'ws-muted';
|
||||
note.textContent = 'Install the AI Assistant plugin to enable proofreading.';
|
||||
section.appendChild(note);
|
||||
}
|
||||
panel.appendChild(section);
|
||||
|
||||
const issuesList = document.createElement('div');
|
||||
issuesList.className = 'ws-issues-list';
|
||||
issuesList.id = 'ws-issues';
|
||||
panel.appendChild(issuesList);
|
||||
|
||||
container.appendChild(panel);
|
||||
|
||||
if (!hasAI) return;
|
||||
|
||||
container.querySelector('#ws-proofread').addEventListener('click', () => {
|
||||
const content = editor.getContent() || '';
|
||||
events.emit('ai:analyze', {
|
||||
text: content,
|
||||
type: 'grammar',
|
||||
callback: (result) => {
|
||||
if (result && result.issues) {
|
||||
renderIssues(issuesList, result.issues);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderIssues(container, issues) {
|
||||
container.replaceChildren();
|
||||
if (!issues || issues.length === 0) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'ws-muted';
|
||||
p.textContent = 'No issues found.';
|
||||
container.appendChild(p);
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < issues.length; i++) {
|
||||
const issue = issues[i];
|
||||
const item = document.createElement('div');
|
||||
item.className = 'ws-issue-item';
|
||||
|
||||
const type = document.createElement('div');
|
||||
type.className = 'ws-issue-type';
|
||||
type.textContent = (issue.type || 'grammar').toUpperCase();
|
||||
item.appendChild(type);
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.className = 'ws-issue-text';
|
||||
text.textContent = issue.message || issue.text || '';
|
||||
item.appendChild(text);
|
||||
|
||||
if (issue.suggestion) {
|
||||
const sug = document.createElement('div');
|
||||
sug.className = 'ws-issue-suggestion';
|
||||
sug.textContent = 'Suggestion: ' + issue.suggestion;
|
||||
item.appendChild(sug);
|
||||
}
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'ws-issue-actions';
|
||||
for (const [action, label] of [['accept', 'Accept'], ['dismiss', 'Dismiss']]) {
|
||||
const actionBtn = document.createElement('button');
|
||||
actionBtn.className = 'ws-btn ws-btn-sm';
|
||||
actionBtn.textContent = label;
|
||||
actionBtn.addEventListener('click', () => {
|
||||
item.remove();
|
||||
});
|
||||
actions.appendChild(actionBtn);
|
||||
}
|
||||
item.appendChild(actions);
|
||||
container.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { renderProofreadPanel };
|
||||
@@ -0,0 +1,81 @@
|
||||
function renderSnapshotsPanel(container, { engines, editor }) {
|
||||
const snapshots = engines.snapshots.list();
|
||||
|
||||
container.replaceChildren();
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'ws-panel';
|
||||
|
||||
// Header with take snapshot button
|
||||
const section = document.createElement('div');
|
||||
section.className = 'ws-section';
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'ws-btn ws-btn-primary';
|
||||
btn.id = 'ws-take-snapshot';
|
||||
btn.textContent = 'Take Snapshot';
|
||||
section.appendChild(btn);
|
||||
const count = document.createElement('span');
|
||||
count.className = 'ws-muted';
|
||||
count.textContent = snapshots.length + ' snapshots';
|
||||
section.appendChild(count);
|
||||
panel.appendChild(section);
|
||||
|
||||
// Snapshot list
|
||||
const list = document.createElement('div');
|
||||
list.className = 'ws-snapshot-list';
|
||||
for (const s of snapshots) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'ws-snapshot-item';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'ws-snapshot-header';
|
||||
const sLabel = document.createElement('span');
|
||||
sLabel.className = 'ws-snapshot-label';
|
||||
sLabel.textContent = s.label;
|
||||
const sWords = document.createElement('span');
|
||||
sWords.textContent = s.wordCount + ' words';
|
||||
header.appendChild(sLabel);
|
||||
header.appendChild(sWords);
|
||||
item.appendChild(header);
|
||||
|
||||
const time = document.createElement('div');
|
||||
time.className = 'ws-snapshot-time';
|
||||
time.textContent = new Date(s.timestamp).toLocaleString();
|
||||
item.appendChild(time);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'ws-snapshot-actions';
|
||||
for (const [action, text, cls] of [['restore', 'Restore', ''], ['diff', 'Diff', ''], ['delete', 'Delete', 'ws-btn-danger']]) {
|
||||
const actionBtn = document.createElement('button');
|
||||
actionBtn.className = 'ws-btn ws-btn-sm' + (cls ? ' ' + cls : '');
|
||||
actionBtn.textContent = text;
|
||||
actionBtn.addEventListener('click', () => {
|
||||
if (action === 'restore') {
|
||||
const content = engines.snapshots.restore(s.id);
|
||||
editor.insertAtCursor(content);
|
||||
} else if (action === 'delete') {
|
||||
engines.snapshots.delete(s.id);
|
||||
renderSnapshotsPanel(container, { engines, editor });
|
||||
} else if (action === 'diff') {
|
||||
const current = editor.getContent() || '';
|
||||
const result = engines.snapshots.diff(s.id, current);
|
||||
alert('+' + result.added + ' lines added, -' + result.removed + ' lines removed');
|
||||
}
|
||||
});
|
||||
actions.appendChild(actionBtn);
|
||||
}
|
||||
item.appendChild(actions);
|
||||
list.appendChild(item);
|
||||
}
|
||||
panel.appendChild(list);
|
||||
container.appendChild(panel);
|
||||
|
||||
// Take snapshot button handler
|
||||
container.querySelector('#ws-take-snapshot').addEventListener('click', () => {
|
||||
const content = editor.getContent() || '';
|
||||
engines.snapshots.create(content, 'manual');
|
||||
renderSnapshotsPanel(container, { engines, editor });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { renderSnapshotsPanel };
|
||||
@@ -0,0 +1,74 @@
|
||||
class ProjectManager {
|
||||
/**
|
||||
* @param {object} fs - { readFile(path), writeFile(path, content), fileExists(path), listDir(path) }
|
||||
*/
|
||||
constructor(fs) {
|
||||
this.fs = fs;
|
||||
}
|
||||
|
||||
createProject(dir, opts) {
|
||||
const project = {
|
||||
title: opts.title,
|
||||
type: opts.type || 'manuscript',
|
||||
target: { words: opts.targetWords || 0, deadline: opts.deadline || null },
|
||||
chapters: [],
|
||||
metadata: opts.metadata || {}
|
||||
};
|
||||
this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
|
||||
return project;
|
||||
}
|
||||
|
||||
loadProject(dir) {
|
||||
const raw = this.fs.readFile(dir + '/.project.json');
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
_saveProject(dir, project) {
|
||||
this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
|
||||
}
|
||||
|
||||
addChapter(dir, chapter) {
|
||||
const project = this.loadProject(dir);
|
||||
if (!project) throw new Error('Project not found');
|
||||
project.chapters.push(chapter);
|
||||
this._saveProject(dir, project);
|
||||
}
|
||||
|
||||
updateChapter(dir, index, updates) {
|
||||
const project = this.loadProject(dir);
|
||||
if (!project) throw new Error('Project not found');
|
||||
Object.assign(project.chapters[index], updates);
|
||||
this._saveProject(dir, project);
|
||||
}
|
||||
|
||||
compileManuscript(dir) {
|
||||
const project = this.loadProject(dir);
|
||||
if (!project) throw new Error('Project not found');
|
||||
const parts = [];
|
||||
for (const ch of project.chapters) {
|
||||
const content = this.fs.readFile(dir + '/' + ch.file);
|
||||
if (content) parts.push(content);
|
||||
}
|
||||
return parts.join('\n\n---\n\n');
|
||||
}
|
||||
|
||||
getStats(dir) {
|
||||
const project = this.loadProject(dir);
|
||||
if (!project) throw new Error('Project not found');
|
||||
let totalWords = 0;
|
||||
for (const ch of project.chapters) {
|
||||
const content = this.fs.readFile(dir + '/' + ch.file);
|
||||
if (content) totalWords += content.split(/\s+/).filter(Boolean).length;
|
||||
}
|
||||
const target = project.target.words || 0;
|
||||
return {
|
||||
totalWords,
|
||||
chapterCount: project.chapters.length,
|
||||
targetWords: target,
|
||||
pctComplete: target > 0 ? Math.min(100, Math.round((totalWords / target) * 100)) : 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ProjectManager };
|
||||
@@ -0,0 +1,73 @@
|
||||
class SnapshotManager {
|
||||
/**
|
||||
* @param {object} store - { get(key), set(key, value) }
|
||||
* @param {string} storeKey - settings key for snapshots
|
||||
*/
|
||||
constructor(store, storeKey = 'plugins.writing-studio.snapshots') {
|
||||
this.store = store;
|
||||
this.storeKey = storeKey;
|
||||
}
|
||||
|
||||
_getAll() {
|
||||
const raw = this.store.get(this.storeKey);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
}
|
||||
|
||||
_saveAll(snaps) {
|
||||
this.store.set(this.storeKey, JSON.stringify(snaps));
|
||||
}
|
||||
|
||||
create(content, label = 'manual') {
|
||||
const snaps = this._getAll();
|
||||
const snap = {
|
||||
id: 'snap-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8),
|
||||
timestamp: new Date().toISOString(),
|
||||
content,
|
||||
wordCount: content.split(/\s+/).filter(Boolean).length,
|
||||
label
|
||||
};
|
||||
snaps.unshift(snap);
|
||||
this._saveAll(snaps);
|
||||
return snap;
|
||||
}
|
||||
|
||||
list() {
|
||||
return this._getAll();
|
||||
}
|
||||
|
||||
getById(id) {
|
||||
return this._getAll().find(s => s.id === id) || null;
|
||||
}
|
||||
|
||||
restore(id) {
|
||||
const snap = this.getById(id);
|
||||
if (!snap) throw new Error('Snapshot not found');
|
||||
return snap.content;
|
||||
}
|
||||
|
||||
delete(id) {
|
||||
const snaps = this._getAll().filter(s => s.id !== id);
|
||||
this._saveAll(snaps);
|
||||
}
|
||||
|
||||
diff(id, currentContent) {
|
||||
const snap = this.getById(id);
|
||||
if (!snap) throw new Error('Snapshot not found');
|
||||
const oldLines = snap.content.split('\n');
|
||||
const newLines = currentContent.split('\n');
|
||||
const oldSet = new Set(oldLines);
|
||||
const newSet = new Set(newLines);
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const line of newLines) { if (!oldSet.has(line)) added++; }
|
||||
for (const line of oldLines) { if (!newSet.has(line)) removed++; }
|
||||
return { added, removed };
|
||||
}
|
||||
|
||||
prune(keepCount) {
|
||||
const snaps = this._getAll();
|
||||
this._saveAll(snaps.slice(0, keepCount));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SnapshotManager };
|
||||
@@ -0,0 +1,51 @@
|
||||
class SprintEngine {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {function} opts.onEvent - callback(event_name, data)
|
||||
*/
|
||||
constructor(opts = {}) {
|
||||
this.onEvent = opts.onEvent || (() => {});
|
||||
this._active = false;
|
||||
this._startTime = null;
|
||||
this._duration = 0;
|
||||
this._initialWords = 0;
|
||||
}
|
||||
|
||||
start(durationMinutes, currentWordCount) {
|
||||
if (this._active) throw new Error('Sprint already active');
|
||||
this._active = true;
|
||||
this._startTime = Date.now();
|
||||
this._duration = durationMinutes * 60 * 1000;
|
||||
this._initialWords = currentWordCount;
|
||||
}
|
||||
|
||||
stop(currentWordCount) {
|
||||
if (!this._active) throw new Error('No active sprint');
|
||||
const elapsed = Date.now() - this._startTime;
|
||||
const wordDelta = Math.max(0, currentWordCount - this._initialWords);
|
||||
const elapsedMinutes = elapsed / 60000;
|
||||
const wpm = elapsedMinutes > 0 ? Math.round(wordDelta / elapsedMinutes) : 0;
|
||||
this._active = false;
|
||||
this._startTime = null;
|
||||
return { wordDelta, elapsed, wpm };
|
||||
}
|
||||
|
||||
tick(elapsedMs) {
|
||||
if (!this._active) return;
|
||||
const remaining = Math.max(0, this._duration - elapsedMs);
|
||||
this.onEvent('sprint:tick', { remaining, elapsed: elapsedMs });
|
||||
if (remaining <= 0) {
|
||||
this._active = false;
|
||||
this.onEvent('sprint:complete', { expired: true });
|
||||
}
|
||||
}
|
||||
|
||||
isActive() { return this._active; }
|
||||
|
||||
getRemaining() {
|
||||
if (!this._active) return 0;
|
||||
return Math.max(0, this._duration - (Date.now() - this._startTime));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SprintEngine };
|
||||
@@ -0,0 +1,43 @@
|
||||
class EventBus {
|
||||
constructor() {
|
||||
this.listeners = new Map();
|
||||
}
|
||||
|
||||
on(event, handler) {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, []);
|
||||
}
|
||||
this.listeners.get(event).push(handler);
|
||||
}
|
||||
|
||||
off(event, handler) {
|
||||
if (!handler) {
|
||||
this.listeners.delete(event);
|
||||
return;
|
||||
}
|
||||
const handlers = this.listeners.get(event);
|
||||
if (handlers) {
|
||||
const idx = handlers.indexOf(handler);
|
||||
if (idx !== -1) handlers.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
emit(event, payload) {
|
||||
const handlers = this.listeners.get(event);
|
||||
if (!handlers) return;
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
handler(payload);
|
||||
} catch (err) {
|
||||
console.error(`[EventBus] Error in handler for "${event}":`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasHandler(event) {
|
||||
const handlers = this.listeners.get(event);
|
||||
return handlers !== undefined && handlers !== null && handlers.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { EventBus };
|
||||
@@ -0,0 +1,23 @@
|
||||
class PluginAPI {
|
||||
/**
|
||||
* Called when the plugin is discovered and loaded.
|
||||
* Receives a scoped context object with APIs.
|
||||
* @param {object} context - Plugin context (sidebar, commands, settings, etc.)
|
||||
*/
|
||||
init(context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
/** Called when the plugin is activated (e.g., sidebar panel clicked). */
|
||||
activate() {}
|
||||
|
||||
/** Called when the plugin is deactivated. */
|
||||
deactivate() {}
|
||||
|
||||
/** Returns the parsed manifest.json for this plugin. */
|
||||
getManifest() {
|
||||
return this._manifest || null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginAPI };
|
||||
@@ -0,0 +1,70 @@
|
||||
class PluginContext {
|
||||
/**
|
||||
* @param {object} deps - Injected dependencies
|
||||
* @param {string} deps.pluginId - Plugin unique ID (for namespacing)
|
||||
* @param {object} deps.sidebar - SidebarManager.registerPanel
|
||||
* @param {object} deps.commands - CommandPalette.register
|
||||
* @param {object} deps.statusBar - StatusBar.registerIndicator
|
||||
* @param {object} deps.eventBus - EventBus instance
|
||||
* @param {object} deps.settings - { get, set, onChanged }
|
||||
* @param {object} deps.editor - { getContent, getSelection, insertAtCursor, onContentChanged }
|
||||
* @param {object} deps.ipc - { invoke, on }
|
||||
* @param {object} deps.exportHooks - { preHooks: [], postHooks: [] }
|
||||
*/
|
||||
constructor(deps) {
|
||||
const { pluginId, sidebar, commands, statusBar, eventBus, settings, editor, ipc, exportHooks } = deps;
|
||||
|
||||
this.sidebar = {
|
||||
registerPanel: (id, opts) => sidebar.registerPanel(`${pluginId}:${id}`, opts)
|
||||
};
|
||||
|
||||
this.commands = {
|
||||
register: (id, label, handler, shortcut) => {
|
||||
const safeHandler = (...args) => {
|
||||
try {
|
||||
handler(...args);
|
||||
} catch (err) {
|
||||
console.error(`[Plugin:${pluginId}] Command "${id}" error:`, err);
|
||||
}
|
||||
};
|
||||
commands.register(`${pluginId}:${id}`, label, safeHandler, shortcut);
|
||||
}
|
||||
};
|
||||
|
||||
this.statusBar = {
|
||||
registerIndicator: (id, opts) => statusBar.registerIndicator(`${pluginId}:${id}`, opts)
|
||||
};
|
||||
|
||||
this.settings = {
|
||||
get: (key) => settings.get(`plugins.${pluginId}.${key}`),
|
||||
set: (key, value) => settings.set(`plugins.${pluginId}.${key}`, value),
|
||||
onChanged: (key, cb) => settings.onChanged(`plugins.${pluginId}.${key}`, cb)
|
||||
};
|
||||
|
||||
this.editor = {
|
||||
getContent: () => editor.getContent(),
|
||||
getSelection: () => editor.getSelection(),
|
||||
insertAtCursor: (text) => editor.insertAtCursor(text),
|
||||
onContentChanged: (cb) => editor.onContentChanged(cb)
|
||||
};
|
||||
|
||||
this.events = {
|
||||
on: (event, handler) => eventBus.on(event, handler),
|
||||
off: (event, handler) => eventBus.off(event, handler),
|
||||
emit: (event, payload) => eventBus.emit(event, payload),
|
||||
hasHandler: (event) => eventBus.hasHandler(event)
|
||||
};
|
||||
|
||||
this.ipc = {
|
||||
invoke: (channel, ...args) => ipc.invoke(channel, ...args),
|
||||
on: (channel, handler) => ipc.on(channel, handler)
|
||||
};
|
||||
|
||||
this.exports = {
|
||||
registerPreHook: (handler) => { if (exportHooks) exportHooks.preHooks.push(handler); },
|
||||
registerPostHook: (handler) => { if (exportHooks) exportHooks.postHooks.push(handler); }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginContext };
|
||||
@@ -0,0 +1,75 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
class PluginLoader {
|
||||
/**
|
||||
* @param {string[]} searchDirs - Directories to scan for plugin folders
|
||||
*/
|
||||
constructor(searchDirs = []) {
|
||||
this.searchDirs = searchDirs;
|
||||
this.loadedIds = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover plugins by scanning searchDirs for manifest.json files.
|
||||
* Returns array of { id, name, version, description, manifest, PluginClass, dir }
|
||||
*/
|
||||
discoverPlugins() {
|
||||
const plugins = [];
|
||||
for (const dir of this.searchDirs) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const pluginDir = path.join(dir, entry.name);
|
||||
const manifestPath = path.join(pluginDir, 'manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) continue;
|
||||
try {
|
||||
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
||||
const manifest = JSON.parse(raw);
|
||||
this.validateManifest(manifest);
|
||||
let PluginClass = null;
|
||||
const indexPath = path.join(pluginDir, 'index.js');
|
||||
if (fs.existsSync(indexPath)) {
|
||||
try {
|
||||
const loaded = require(indexPath);
|
||||
PluginClass = loaded.Plugin || loaded.default || null;
|
||||
} catch (err) {
|
||||
console.error(`[PluginLoader] Failed to load index.js for "${manifest.id}":`, err.message);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
plugins.push({
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
description: manifest.description,
|
||||
manifest,
|
||||
PluginClass,
|
||||
dir: pluginDir
|
||||
});
|
||||
this.loadedIds.add(manifest.id);
|
||||
} catch (err) {
|
||||
console.error(`[PluginLoader] Skipping plugin in ${pluginDir}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
return plugins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a manifest object. Throws on invalid.
|
||||
*/
|
||||
validateManifest(manifest) {
|
||||
if (!manifest.id) throw new Error('Manifest missing required field: id');
|
||||
if (!manifest.name) throw new Error('Manifest missing required field: name');
|
||||
if (!manifest.version) throw new Error('Manifest missing required field: version');
|
||||
if (!manifest.description) throw new Error('Manifest missing required field: description');
|
||||
if (this.loadedIds.has(manifest.id)) {
|
||||
throw new Error(`Duplicate plugin id: "${manifest.id}"`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginLoader };
|
||||
@@ -0,0 +1,80 @@
|
||||
const { PluginContext } = require('./plugin-context');
|
||||
const { PluginAPI } = require('./plugin-api');
|
||||
|
||||
class PluginRegistry {
|
||||
constructor(deps) {
|
||||
this.deps = deps;
|
||||
this.plugins = new Map();
|
||||
this.exportHooks = { preHooks: [], postHooks: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a discovered plugin. Creates instance, builds context, calls init.
|
||||
* If init throws, plugin is NOT registered.
|
||||
*/
|
||||
register(pluginInfo) {
|
||||
const { id, name, version, description, manifest, PluginClass, dir } = pluginInfo;
|
||||
|
||||
let instance;
|
||||
if (PluginClass) {
|
||||
instance = new PluginClass();
|
||||
} else {
|
||||
instance = new PluginAPI();
|
||||
}
|
||||
instance._manifest = manifest;
|
||||
|
||||
const context = new PluginContext({
|
||||
pluginId: id,
|
||||
sidebar: this.deps.sidebar,
|
||||
commands: this.deps.commands,
|
||||
statusBar: this.deps.statusBar,
|
||||
eventBus: this.deps.eventBus,
|
||||
settings: this.deps.settings,
|
||||
editor: this.deps.editor,
|
||||
ipc: this.deps.ipc,
|
||||
exportHooks: this.exportHooks
|
||||
});
|
||||
|
||||
try {
|
||||
instance.init(context);
|
||||
} catch (err) {
|
||||
console.error(`[PluginRegistry] Plugin "${id}" init failed:`, err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
this.plugins.set(id, { id, name, version, description, manifest, instance, dir, context });
|
||||
console.log(`[PluginRegistry] Registered plugin: ${name} v${version}`);
|
||||
}
|
||||
|
||||
getPlugin(id) {
|
||||
return this.plugins.get(id);
|
||||
}
|
||||
|
||||
getAll() {
|
||||
return Array.from(this.plugins.values());
|
||||
}
|
||||
|
||||
activate(id) {
|
||||
const plugin = this.plugins.get(id);
|
||||
if (plugin?.instance) {
|
||||
try {
|
||||
plugin.instance.activate();
|
||||
} catch (err) {
|
||||
console.error(`[PluginRegistry] Plugin "${id}" activate error:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deactivate(id) {
|
||||
const plugin = this.plugins.get(id);
|
||||
if (plugin?.instance) {
|
||||
try {
|
||||
plugin.instance.deactivate();
|
||||
} catch (err) {
|
||||
console.error(`[PluginRegistry] Plugin "${id}" deactivate error:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginRegistry };
|
||||
@@ -0,0 +1,23 @@
|
||||
class SettingsStore {
|
||||
/**
|
||||
* @param {object} backend - { get(key), set(key, value) } backed by main process store
|
||||
*/
|
||||
constructor(backend) {
|
||||
this.backend = backend;
|
||||
}
|
||||
|
||||
get(key) {
|
||||
return this.backend.get(key);
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
this.backend.set(key, value);
|
||||
}
|
||||
|
||||
onChanged(key, callback) {
|
||||
// Deferred: plugins read settings on init/activate for MVP.
|
||||
// Full change notification requires IPC watcher in main process.
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SettingsStore };
|
||||
+23
-3
@@ -9,7 +9,7 @@
|
||||
* - All IPC channels are explicitly whitelisted
|
||||
* - Prevents XSS from escalating to full system access
|
||||
*
|
||||
* @version 4.0.0
|
||||
* @version 4.1.0
|
||||
*/
|
||||
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
@@ -101,6 +101,14 @@ const ALLOWED_SEND_CHANNELS = [
|
||||
|
||||
// File Explorer
|
||||
'list-directory',
|
||||
'read-file',
|
||||
'write-file',
|
||||
'delete-file',
|
||||
'ensure-directory',
|
||||
'path-exists',
|
||||
'is-directory',
|
||||
'copy-path',
|
||||
'move-path',
|
||||
|
||||
// Git
|
||||
'git-status',
|
||||
@@ -127,7 +135,11 @@ const ALLOWED_SEND_CHANNELS = [
|
||||
'export',
|
||||
|
||||
// Git diff
|
||||
'git-diff'
|
||||
'git-diff',
|
||||
|
||||
// Plugin settings
|
||||
'plugin-settings:get',
|
||||
'plugin-settings:set'
|
||||
];
|
||||
|
||||
const ALLOWED_RECEIVE_CHANNELS = [
|
||||
@@ -321,7 +333,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
setCurrent: (filePath) => ipcRenderer.send('set-current-file', filePath),
|
||||
saveRecent: (recentFiles) => ipcRenderer.send('save-recent-files', recentFiles),
|
||||
clearRecent: () => ipcRenderer.send('clear-recent-files'),
|
||||
rendererReady: () => ipcRenderer.send('renderer-ready')
|
||||
rendererReady: () => ipcRenderer.send('renderer-ready'),
|
||||
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 })
|
||||
},
|
||||
|
||||
// Theme Operations
|
||||
|
||||
+263
-39
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* MarkdownConverter Renderer Process
|
||||
* @version 3.0.0
|
||||
* @version 4.1.0
|
||||
*/
|
||||
|
||||
const { ipcRenderer } = require('electron');
|
||||
@@ -20,10 +20,55 @@ function getRenderTemplatesPanel() { if (!_renderTemplatesPanel) _renderTemplate
|
||||
function getRenderExplorerPanel() { if (!_renderExplorerPanel) _renderExplorerPanel = require('./sidebar/explorer-panel').renderExplorerPanel; return _renderExplorerPanel; }
|
||||
function getRenderGitPanel() { if (!_renderGitPanel) _renderGitPanel = require('./sidebar/git-panel').renderGitPanel; return _renderGitPanel; }
|
||||
function getRenderSnippetsPanel() { if (!_renderSnippetsPanel) _renderSnippetsPanel = require('./sidebar/snippets-panel').renderSnippetsPanel; return _renderSnippetsPanel; }
|
||||
let _renderOutlinePanel;
|
||||
function getRenderOutlinePanel() { if (!_renderOutlinePanel) _renderOutlinePanel = require('./sidebar/outline-panel').renderOutlinePanel; return _renderOutlinePanel; }
|
||||
function getReplPanel() { if (!_ReplPanel) _ReplPanel = require('./repl/repl-panel').ReplPanel; return _ReplPanel; }
|
||||
function getCommandPalette() { if (!_CommandPalette) _CommandPalette = require('./command-palette').CommandPalette; return _CommandPalette; }
|
||||
function getPrintPreview() { if (!_PrintPreview) _PrintPreview = require('./print-preview').PrintPreview; return _PrintPreview; }
|
||||
function getCreateWelcomeContent() { if (!_createWelcomeContent) _createWelcomeContent = require('./welcome').createWelcomeContent; return _createWelcomeContent; }
|
||||
let _ZenMode;
|
||||
function getZenMode() { if (!_ZenMode) _ZenMode = require('./zen-mode').ZenMode; return _ZenMode; }
|
||||
let _showAnalyticsModal;
|
||||
function getShowAnalyticsModal() { if (!_showAnalyticsModal) _showAnalyticsModal = require('./analytics/analytics-panel').showAnalyticsModal; return _showAnalyticsModal; }
|
||||
|
||||
function ensureToastContainer() {
|
||||
let container = document.getElementById('app-toast-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'app-toast-container';
|
||||
container.className = 'app-toast-container';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
function notifyUser(message, type = 'info', options = {}) {
|
||||
if (!message) return;
|
||||
|
||||
const { duration = 3500 } = options;
|
||||
const container = ensureToastContainer();
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `app-toast app-toast-${type}`;
|
||||
toast.setAttribute('role', type === 'warning' ? 'alert' : 'status');
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.add('visible');
|
||||
});
|
||||
|
||||
const dismiss = () => {
|
||||
toast.classList.remove('visible');
|
||||
setTimeout(() => toast.remove(), 180);
|
||||
};
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(dismiss, duration);
|
||||
}
|
||||
|
||||
toast.addEventListener('click', dismiss);
|
||||
return toast;
|
||||
}
|
||||
|
||||
// Configure marked with highlight extension
|
||||
marked.use(markedHighlight({
|
||||
@@ -85,6 +130,9 @@ function plantumlEncode(text) {
|
||||
return '~h' + hex;
|
||||
}
|
||||
|
||||
// Module-level reference set by DOMContentLoaded after sidebar registration
|
||||
let outlinePanelContainer = null;
|
||||
|
||||
// Tab Management
|
||||
class TabManager {
|
||||
constructor() {
|
||||
@@ -351,7 +399,7 @@ class TabManager {
|
||||
} catch (error) {
|
||||
console.error('Error loading PDF:', error);
|
||||
document.getElementById('status-text').textContent = 'Error loading PDF';
|
||||
alert('Error loading PDF: ' + error.message);
|
||||
notifyUser(`Error loading PDF: ${error.message}`, 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,9 +464,11 @@ class TabManager {
|
||||
this.updatePreview(tab.id);
|
||||
this.updateWordCount();
|
||||
this.updateTabBar();
|
||||
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
|
||||
},
|
||||
onUpdate: (view) => {
|
||||
this.updateCursorPosition(view);
|
||||
if (outlinePanelContainer?._setActiveHeading) outlinePanelContainer._setActiveHeading(view.state.doc.lineAt(view.state.selection.main.head).number);
|
||||
},
|
||||
isDark,
|
||||
showLineNumbers: this.showLineNumbers,
|
||||
@@ -449,9 +499,10 @@ class TabManager {
|
||||
|
||||
// Notify main process about current file for exports
|
||||
const tab = this.tabs.get(tabId);
|
||||
if (tab?.filePath) {
|
||||
ipcRenderer.send('set-current-file', tab.filePath);
|
||||
}
|
||||
ipcRenderer.send('set-current-file', tab?.filePath || null);
|
||||
|
||||
// Refresh outline panel for new tab content
|
||||
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
|
||||
}
|
||||
|
||||
switchToNextTab() {
|
||||
@@ -1467,6 +1518,92 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
})
|
||||
});
|
||||
|
||||
let outlinePanelContainer = null;
|
||||
sidebarManager.registerPanel('outline', {
|
||||
title: 'Outline',
|
||||
render: (container) => {
|
||||
outlinePanelContainer = container;
|
||||
getRenderOutlinePanel()(container, {
|
||||
getEditorContent: () => tabManager.getEditorContent(),
|
||||
getActiveLine: () => {
|
||||
const tab = tabManager.tabs.get(tabManager.activeTabId);
|
||||
if (tab?.editorView) {
|
||||
const pos = tab.editorView.state.selection.main.head;
|
||||
return tab.editorView.state.doc.lineAt(pos).number;
|
||||
}
|
||||
return 1;
|
||||
},
|
||||
onHeadingClick: (line) => {
|
||||
const tab = tabManager.tabs.get(tabManager.activeTabId);
|
||||
if (tab?.editorView) {
|
||||
const pos = tab.editorView.state.doc.line(line).from;
|
||||
tab.editorView.dispatch({
|
||||
selection: { anchor: pos },
|
||||
scrollIntoView: true
|
||||
});
|
||||
tab.editorView.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// --- Plugin System ---
|
||||
const { PluginLoader } = require('./plugins/plugin-loader');
|
||||
const { PluginRegistry } = require('./plugins/plugin-registry');
|
||||
const { EventBus } = require('./plugins/event-bus');
|
||||
const { SettingsStore } = require('./plugins/settings-store');
|
||||
const pluginPath = require('path');
|
||||
|
||||
const pluginEventBus = new EventBus();
|
||||
const pluginSettings = new SettingsStore({
|
||||
get: (key) => window.electronAPI.invoke('plugin-settings:get', key),
|
||||
set: (key, value) => window.electronAPI.invoke('plugin-settings:set', { key, value })
|
||||
});
|
||||
|
||||
const statusBarRight = document.querySelector('.status-bar-right');
|
||||
const pluginRegistry = new PluginRegistry({
|
||||
sidebar: sidebarManager,
|
||||
commands: commandPalette,
|
||||
statusBar: {
|
||||
registerIndicator: (id, opts) => {
|
||||
const el = document.createElement('span');
|
||||
el.className = 'status-item';
|
||||
el.id = `plugin-status-${id}`;
|
||||
if (opts.tooltip) el.title = opts.tooltip;
|
||||
el.textContent = opts.text || '';
|
||||
const sep = document.createElement('span');
|
||||
sep.className = 'status-separator';
|
||||
sep.textContent = '|';
|
||||
if (statusBarRight) {
|
||||
statusBarRight.appendChild(sep);
|
||||
statusBarRight.appendChild(el);
|
||||
}
|
||||
}
|
||||
},
|
||||
eventBus: pluginEventBus,
|
||||
settings: pluginSettings,
|
||||
editor: {
|
||||
getContent: () => tabManager.getCurrentContent(),
|
||||
getSelection: () => tabManager.getSelection(),
|
||||
insertAtCursor: (text) => tabManager.insertAtCursor(text),
|
||||
onContentChanged: (cb) => pluginEventBus.on('document:changed', cb)
|
||||
},
|
||||
ipc: {
|
||||
invoke: (ch, data) => window.electronAPI.invoke(ch, data),
|
||||
on: (ch, cb) => window.electronAPI.on(ch, cb)
|
||||
}
|
||||
});
|
||||
|
||||
const builtInDir = pluginPath.join(__dirname, 'plugins', 'built-in');
|
||||
const loader = new PluginLoader([builtInDir]);
|
||||
const discovered = loader.discoverPlugins();
|
||||
discovered.forEach(p => pluginRegistry.register(p));
|
||||
|
||||
// Initialize Zen Mode
|
||||
const ZenModeClass = getZenMode();
|
||||
const zenMode = new ZenModeClass(tabManager);
|
||||
|
||||
// Welcome tab on startup
|
||||
const hasLaunched = localStorage.getItem('hasLaunchedBefore');
|
||||
const showWelcome = localStorage.getItem('showWelcomeOnStartup') !== 'false';
|
||||
@@ -1601,6 +1738,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
commandPalette.register('Toggle Sidebar: Git', 'Ctrl+Shift+G', () => sidebarManager.togglePanel('git'));
|
||||
commandPalette.register('Toggle Sidebar: Snippets', '', () => sidebarManager.togglePanel('snippets'));
|
||||
commandPalette.register('Toggle Sidebar: Templates', '', () => sidebarManager.togglePanel('templates'));
|
||||
commandPalette.register('Toggle Sidebar: Outline', 'Ctrl+Shift+O', () => sidebarManager.togglePanel('outline'));
|
||||
commandPalette.register('Print Preview', 'Ctrl+P', () => {
|
||||
const tab = tabManager.tabs.get(tabManager.activeTabId);
|
||||
const preview = document.getElementById(`preview-${tab.id}`);
|
||||
@@ -1618,13 +1756,30 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
commandPalette.register('Heading 3', '', () => tabManager.insertAtLineStart('### '));
|
||||
commandPalette.register('Insert Link', '', () => tabManager.wrapSelection('[', '](url)'));
|
||||
commandPalette.register('Insert Image', '', () => tabManager.wrapSelection(''));
|
||||
commandPalette.register('Toggle Zen Mode', 'F11', () => zenMode.toggle());
|
||||
commandPalette.register('Writing Analytics', 'Ctrl+Shift+A', () => getShowAnalyticsModal()(tabManager));
|
||||
|
||||
// Ctrl+Shift+P keyboard shortcut for command palette
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Ctrl+Shift+P — Command Palette
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'P') {
|
||||
e.preventDefault();
|
||||
commandPalette.open();
|
||||
}
|
||||
// F11 — Zen Mode
|
||||
if (e.key === 'F11') {
|
||||
e.preventDefault();
|
||||
zenMode.toggle();
|
||||
}
|
||||
// Ctrl+Shift+A — Writing Analytics
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'A') {
|
||||
e.preventDefault();
|
||||
getShowAnalyticsModal()(tabManager);
|
||||
}
|
||||
// Escape — Exit Zen Mode
|
||||
if (e.key === 'Escape' && zenMode.active) {
|
||||
zenMode.deactivate();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize CodeMirror for the initial tab (tab 1)
|
||||
@@ -1640,9 +1795,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
tabManager.updatePreview(tab.id);
|
||||
tabManager.updateWordCount();
|
||||
tabManager.updateTabBar();
|
||||
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
|
||||
},
|
||||
onUpdate: (view) => {
|
||||
tabManager.updateCursorPosition(view);
|
||||
if (outlinePanelContainer?._setActiveHeading) outlinePanelContainer._setActiveHeading(view.state.doc.lineAt(view.state.selection.main.head).number);
|
||||
},
|
||||
isDark,
|
||||
showLineNumbers: tabManager.showLineNumbers,
|
||||
@@ -1664,7 +1821,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Auto-save logic for all tabs
|
||||
tabManager.tabs.forEach(tab => {
|
||||
if (tab.isDirty && tab.filePath) {
|
||||
ipcRenderer.send('save-current-file', tab.content);
|
||||
ipcRenderer.send('save-current-file', { filePath: tab.filePath, content: tab.content });
|
||||
}
|
||||
});
|
||||
}, 30000);
|
||||
@@ -1688,7 +1845,7 @@ ipcRenderer.on('file-save', () => {
|
||||
const currentContent = tabManager.getCurrentContent();
|
||||
const currentFilePath = tabManager.getCurrentFilePath();
|
||||
// send to main process which will save or trigger save-as dialog
|
||||
ipcRenderer.send('save-current-file', currentContent);
|
||||
ipcRenderer.send('save-current-file', { filePath: currentFilePath, content: currentContent });
|
||||
});
|
||||
|
||||
ipcRenderer.on('get-content-for-save', (event, filePath) => {
|
||||
@@ -1805,7 +1962,7 @@ function openPrintPreviewDialog() {
|
||||
const previewContent = document.getElementById(`preview-${activeTabId}`);
|
||||
|
||||
if (!previewContent || !previewContent.innerHTML.trim()) {
|
||||
alert('Nothing to print. Please create or open a document and ensure the preview is visible.');
|
||||
notifyUser('Nothing to print. Create or open a document and keep the preview visible.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2047,7 +2204,7 @@ function saveCurrentProfile() {
|
||||
// Select the newly created profile
|
||||
document.getElementById('export-profile-select').value = profileName;
|
||||
|
||||
alert(`Profile "${profileName}" saved successfully!`);
|
||||
notifyUser(`Profile "${profileName}" saved successfully.`, 'success');
|
||||
}
|
||||
|
||||
function loadProfile(profileName) {
|
||||
@@ -2087,7 +2244,7 @@ function deleteSelectedProfile() {
|
||||
const profileName = select.value;
|
||||
|
||||
if (!profileName) {
|
||||
alert('Please select a profile to delete.');
|
||||
notifyUser('Select a profile to delete.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2096,7 +2253,7 @@ function deleteSelectedProfile() {
|
||||
saveExportProfiles();
|
||||
populateProfileDropdown();
|
||||
select.value = '';
|
||||
alert(`Profile "${profileName}" deleted successfully!`);
|
||||
notifyUser(`Profile "${profileName}" deleted successfully.`, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2790,7 +2947,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const includeSubfolders = document.getElementById('converter-batch-subfolders').checked;
|
||||
|
||||
if (!inputFolder || !outputFolder) {
|
||||
alert('Please select both input and output folders for batch conversion');
|
||||
document.getElementById('converter-progress').classList.remove('hidden');
|
||||
document.getElementById('converter-status').textContent = 'Select both input and output folders for batch conversion.';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2812,7 +2970,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const filePath = converterFilePath;
|
||||
|
||||
if (!filePath) {
|
||||
alert('Please select a file to convert');
|
||||
document.getElementById('converter-progress').classList.remove('hidden');
|
||||
document.getElementById('converter-status').textContent = 'Select a file to convert.';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2960,7 +3119,13 @@ function showPDFEditorDialog(operation, openedFilePath = null) {
|
||||
|
||||
function hidePDFEditorDialog() {
|
||||
window.modals.pdfEditorModal.close();
|
||||
clearPDFStatus();
|
||||
document.getElementById('pdf-progress').classList.add('hidden');
|
||||
document.getElementById('pdf-progress-text').textContent = 'Processing...';
|
||||
const progressFill = document.getElementById('pdf-progress-fill');
|
||||
if (progressFill) {
|
||||
progressFill.style.width = '0%';
|
||||
}
|
||||
currentPDFOperation = null;
|
||||
}
|
||||
|
||||
@@ -3161,7 +3326,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
loadCurrentOrder.addEventListener('click', () => {
|
||||
const inputPath = document.getElementById('reorder-input-path').value;
|
||||
if (!inputPath) {
|
||||
alert('Please select a PDF file first');
|
||||
showPDFValidationMessage('Select a PDF file before loading the current page order.', '#reorder-input-path');
|
||||
return;
|
||||
}
|
||||
// Request page count from main process
|
||||
@@ -3178,7 +3343,7 @@ ipcRenderer.on('pdf-folder-selected', (event, { inputId, path }) => {
|
||||
// Handle PDF page count response
|
||||
ipcRenderer.on('pdf-page-count', (event, { count, error }) => {
|
||||
if (error) {
|
||||
alert('Error reading PDF: ' + error);
|
||||
showPDFStatus(`Error reading PDF: ${error}`, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3188,6 +3353,39 @@ ipcRenderer.on('pdf-page-count', (event, { count, error }) => {
|
||||
document.getElementById('reorder-pages').value = currentOrder;
|
||||
});
|
||||
|
||||
function getPDFStatusElement() {
|
||||
return document.getElementById('pdf-status-message');
|
||||
}
|
||||
|
||||
function showPDFStatus(message, type = 'info') {
|
||||
const status = getPDFStatusElement();
|
||||
if (!status) return;
|
||||
|
||||
status.textContent = message;
|
||||
status.classList.remove('hidden', 'info-message', 'warning-message', 'success-message');
|
||||
status.classList.add(`${type}-message`);
|
||||
}
|
||||
|
||||
function clearPDFStatus() {
|
||||
const status = getPDFStatusElement();
|
||||
if (!status) return;
|
||||
|
||||
status.textContent = '';
|
||||
status.classList.remove('info-message', 'warning-message', 'success-message');
|
||||
status.classList.add('hidden');
|
||||
}
|
||||
|
||||
function showPDFValidationMessage(message, focusSelector = null) {
|
||||
showPDFStatus(message, 'warning');
|
||||
|
||||
if (focusSelector) {
|
||||
const field = document.querySelector(focusSelector);
|
||||
if (field && typeof field.focus === 'function') {
|
||||
field.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process PDF Operation
|
||||
function processPDFOperation() {
|
||||
const operation = currentPDFOperation;
|
||||
@@ -3197,13 +3395,13 @@ function processPDFOperation() {
|
||||
switch (operation) {
|
||||
case 'merge':
|
||||
if (mergeFilePaths.length < 2) {
|
||||
alert('Please add at least 2 PDF files to merge');
|
||||
showPDFValidationMessage('Add at least 2 PDF files to merge first.', '#add-merge-file');
|
||||
return;
|
||||
}
|
||||
operationData.inputFiles = mergeFilePaths;
|
||||
operationData.outputPath = document.getElementById('merge-output-path').value.trim();
|
||||
if (!operationData.outputPath) {
|
||||
alert('Please select an output file path');
|
||||
showPDFValidationMessage('Select an output file path.', '#merge-output-path');
|
||||
return;
|
||||
}
|
||||
break;
|
||||
@@ -3214,7 +3412,7 @@ function processPDFOperation() {
|
||||
operationData.splitMode = document.getElementById('split-mode').value;
|
||||
|
||||
if (!operationData.inputPath || !operationData.outputFolder) {
|
||||
alert('Please select input file and output folder');
|
||||
showPDFValidationMessage('Select both an input PDF and an output folder.', '#split-input-path');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3237,7 +3435,10 @@ function processPDFOperation() {
|
||||
operationData.optimizeFonts = document.getElementById('compress-optimize-fonts').checked;
|
||||
|
||||
if (!operationData.inputPath || !operationData.outputPath) {
|
||||
alert('Please select input file' + (operationData.overwrite ? '' : ' and output file paths'));
|
||||
showPDFValidationMessage(
|
||||
'Select an input PDF' + (operationData.overwrite ? '.' : ' and an output file path.'),
|
||||
'#compress-input-path'
|
||||
);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
@@ -3250,7 +3451,10 @@ function processPDFOperation() {
|
||||
operationData.angle = parseInt(document.getElementById('rotate-angle').value);
|
||||
|
||||
if (!operationData.inputPath || !operationData.outputPath) {
|
||||
alert('Please select input file' + (operationData.overwrite ? '' : ' and output file'));
|
||||
showPDFValidationMessage(
|
||||
'Select an input PDF' + (operationData.overwrite ? '.' : ' and an output file path.'),
|
||||
'#rotate-input-path'
|
||||
);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
@@ -3262,7 +3466,7 @@ function processPDFOperation() {
|
||||
operationData.pages = document.getElementById('delete-pages').value.trim();
|
||||
|
||||
if (!operationData.inputPath || !operationData.outputPath || !operationData.pages) {
|
||||
alert('Please fill in all required fields');
|
||||
showPDFValidationMessage('Fill in the input file, output path, and pages to delete.', '#delete-input-path');
|
||||
return;
|
||||
}
|
||||
break;
|
||||
@@ -3274,7 +3478,7 @@ function processPDFOperation() {
|
||||
operationData.newOrder = document.getElementById('reorder-pages').value.trim();
|
||||
|
||||
if (!operationData.inputPath || !operationData.outputPath || !operationData.newOrder) {
|
||||
alert('Please fill in all required fields');
|
||||
showPDFValidationMessage('Fill in the input file, output path, and new page order.', '#reorder-input-path');
|
||||
return;
|
||||
}
|
||||
break;
|
||||
@@ -3295,7 +3499,7 @@ function processPDFOperation() {
|
||||
}
|
||||
|
||||
if (!operationData.inputPath || !operationData.outputPath || !operationData.text) {
|
||||
alert('Please fill in all required fields');
|
||||
showPDFValidationMessage('Fill in the input file, output path, and watermark text.', '#watermark-input-path');
|
||||
return;
|
||||
}
|
||||
break;
|
||||
@@ -3320,7 +3524,7 @@ function processPDFOperation() {
|
||||
};
|
||||
|
||||
if (!operationData.inputPath || !operationData.outputPath || !operationData.userPassword) {
|
||||
alert('Please select file and enter a user password');
|
||||
showPDFValidationMessage('Select a file, output path, and user password.', '#encrypt-input-path');
|
||||
return;
|
||||
}
|
||||
break;
|
||||
@@ -3332,7 +3536,7 @@ function processPDFOperation() {
|
||||
operationData.password = document.getElementById('decrypt-password').value;
|
||||
|
||||
if (!operationData.inputPath || !operationData.outputPath || !operationData.password) {
|
||||
alert('Please fill in all required fields');
|
||||
showPDFValidationMessage('Fill in the input file, output path, and password.', '#decrypt-input-path');
|
||||
return;
|
||||
}
|
||||
break;
|
||||
@@ -3356,21 +3560,26 @@ function processPDFOperation() {
|
||||
};
|
||||
|
||||
if (!operationData.inputPath || !operationData.outputPath || !operationData.ownerPassword) {
|
||||
alert('Please fill in all required fields');
|
||||
showPDFValidationMessage('Fill in the input file, output path, and owner password.', '#permissions-input-path');
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
clearPDFStatus();
|
||||
// Show progress
|
||||
document.getElementById('pdf-progress').classList.remove('hidden');
|
||||
document.getElementById('pdf-progress-text').textContent = 'Processing PDF...';
|
||||
const progressFill = document.getElementById('pdf-progress-fill');
|
||||
if (progressFill) {
|
||||
progressFill.style.width = '10%';
|
||||
}
|
||||
|
||||
// Send to main process
|
||||
ipcRenderer.send('process-pdf-operation', operationData);
|
||||
|
||||
} catch (error) {
|
||||
alert('Error: ' + error.message);
|
||||
showPDFStatus(`Error: ${error.message}`, 'warning');
|
||||
console.error('PDF operation error:', error);
|
||||
}
|
||||
}
|
||||
@@ -3380,10 +3589,14 @@ ipcRenderer.on('pdf-operation-complete', (event, { success, error, message }) =>
|
||||
document.getElementById('pdf-progress').classList.add('hidden');
|
||||
|
||||
if (success) {
|
||||
alert(message || 'PDF operation completed successfully!');
|
||||
hidePDFEditorDialog();
|
||||
showPDFStatus(message || 'PDF operation completed successfully.', 'success');
|
||||
setTimeout(() => {
|
||||
if (window.modals?.pdfEditorModal?.isOpen()) {
|
||||
hidePDFEditorDialog();
|
||||
}
|
||||
}, 800);
|
||||
} else {
|
||||
alert('Error: ' + (error || 'PDF operation failed'));
|
||||
showPDFStatus(`Error: ${error || 'PDF operation failed'}`, 'warning');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3715,13 +3928,13 @@ function insertGeneratedTable() {
|
||||
const table = document.getElementById('table-preview').textContent;
|
||||
|
||||
if (!table) {
|
||||
alert('Please generate a table preview first');
|
||||
notifyUser('Generate a table preview first.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert table using CodeMirror
|
||||
if (!tabManager) {
|
||||
alert('No active editor found');
|
||||
notifyUser('No active editor found.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4189,13 +4402,13 @@ function insertASCIIArt() {
|
||||
const asciiArt = document.getElementById('ascii-preview').textContent;
|
||||
|
||||
if (!asciiArt || asciiArt === 'Select a template from the buttons above') {
|
||||
alert('Please generate ASCII art first');
|
||||
notifyUser('Generate ASCII art first.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert using CodeMirror
|
||||
if (!tabManager) {
|
||||
alert('No active editor found');
|
||||
notifyUser('No active editor found.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4618,14 +4831,25 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// ============================================
|
||||
|
||||
ipcRenderer.on('show-image-tool', (event, tool) => {
|
||||
alert(`Image ${tool} tool requires ImageMagick to be installed.\n\nPlease install ImageMagick from: https://imagemagick.org/\n\nThis feature will be available in a future update with built-in support.`);
|
||||
notifyUser(
|
||||
`Image ${tool} requires ImageMagick. Install it from imagemagick.org to enable this tool.`,
|
||||
'info',
|
||||
{ duration: 5000 }
|
||||
);
|
||||
});
|
||||
|
||||
ipcRenderer.on('show-audio-tool', (event, tool) => {
|
||||
alert(`Audio ${tool} tool requires FFmpeg to be installed.\n\nPlease install FFmpeg from: https://ffmpeg.org/\n\nThis feature will be available in a future update with built-in support.`);
|
||||
notifyUser(
|
||||
`Audio ${tool} requires FFmpeg. Install it from ffmpeg.org to enable this tool.`,
|
||||
'info',
|
||||
{ duration: 5000 }
|
||||
);
|
||||
});
|
||||
|
||||
ipcRenderer.on('show-video-tool', (event, tool) => {
|
||||
alert(`Video ${tool} tool requires FFmpeg to be installed.\n\nPlease install FFmpeg from: https://ffmpeg.org/\n\nThis feature will be available in a future update with built-in support.`);
|
||||
notifyUser(
|
||||
`Video ${tool} requires FFmpeg. Install it from ffmpeg.org to enable this tool.`,
|
||||
'info',
|
||||
{ duration: 5000 }
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Document Outline Panel
|
||||
* Parses markdown headings and renders a navigable tree in the sidebar.
|
||||
*/
|
||||
|
||||
function renderOutlinePanel(container, { getEditorContent, getActiveLine, onHeadingClick }) {
|
||||
container.innerHTML = `
|
||||
<div class="outline-panel">
|
||||
<div class="outline-list" id="outline-list"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const listEl = document.getElementById('outline-list');
|
||||
let headings = [];
|
||||
let activeLine = 1;
|
||||
let debounceTimer = null;
|
||||
|
||||
function parseHeadings(content) {
|
||||
const result = [];
|
||||
if (!content) return result;
|
||||
const lines = content.split('\n');
|
||||
const regex = /^(#{1,6})\s+(.+)$/;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = regex.exec(lines[i]);
|
||||
if (match) {
|
||||
result.push({
|
||||
level: match[1].length,
|
||||
text: match[2].trim(),
|
||||
line: i + 1
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function findActiveHeading(currentLine) {
|
||||
let active = null;
|
||||
for (const h of headings) {
|
||||
if (h.line <= currentLine) {
|
||||
active = h;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
function renderHeadings() {
|
||||
const content = getEditorContent();
|
||||
headings = parseHeadings(content);
|
||||
activeLine = getActiveLine();
|
||||
|
||||
if (headings.length === 0) {
|
||||
listEl.innerHTML = `
|
||||
<div class="outline-empty">
|
||||
<p>No headings found</p>
|
||||
<p class="outline-hint"># Heading 1</p>
|
||||
<p class="outline-hint">## Heading 2</p>
|
||||
<p class="outline-hint">### Heading 3</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const activeHeading = findActiveHeading(activeLine);
|
||||
|
||||
listEl.innerHTML = headings.map((h, idx) => `
|
||||
<div class="outline-item outline-level-${h.level}${activeHeading && h.line === activeHeading.line ? ' active' : ''}"
|
||||
data-line="${h.line}" data-index="${idx}">
|
||||
<span class="outline-text">${escapeHtml(h.text)}</span>
|
||||
<span class="outline-badge">H${h.level}</span>
|
||||
</div>
|
||||
`).join('') + `<div class="outline-footer">${headings.length} heading${headings.length !== 1 ? 's' : ''}</div>`;
|
||||
|
||||
listEl.querySelectorAll('.outline-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const line = parseInt(item.dataset.line, 10);
|
||||
onHeadingClick(line);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(renderHeadings, 300);
|
||||
}
|
||||
|
||||
function setActiveHeading(line) {
|
||||
activeLine = line;
|
||||
const activeHeading = findActiveHeading(line);
|
||||
|
||||
listEl.querySelectorAll('.outline-item').forEach(item => {
|
||||
const itemLine = parseInt(item.dataset.line, 10);
|
||||
if (activeHeading && itemLine === activeHeading.line) {
|
||||
item.classList.add('active');
|
||||
} else {
|
||||
item.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
container._refreshOutline = refresh;
|
||||
container._setActiveHeading = setActiveHeading;
|
||||
|
||||
renderHeadings();
|
||||
}
|
||||
|
||||
module.exports = { renderOutlinePanel };
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ConcreteInfo Theme for MarkdownConverter
|
||||
* Based on logo palette: #464646, #9a9696, #e5461f, #e3e3e3, #0d0b09
|
||||
* Version: 3.0.0
|
||||
* Version: 4.1.0
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
|
||||
@@ -2346,3 +2346,29 @@ body[class*="dark"] .breadcrumb-bar {
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Writing Analytics Modal */
|
||||
.analytics-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.5); backdrop-filter:blur(4px); display:flex; align-items:center; justify-content:center; z-index:10000; animation:analyticsFadeIn 0.2s ease; }
|
||||
.analytics-modal { background:var(--bg-primary,#fff); border-radius:12px; width:520px; max-height:80vh; overflow-y:auto; box-shadow:0 20px 60px rgba(0,0,0,0.3); animation:analyticsSlideUp 0.3s ease; }
|
||||
.analytics-header { display:flex; justify-content:space-between; align-items:center; padding:20px 24px; border-bottom:1px solid var(--border-color,#e5e7eb); }
|
||||
.analytics-header h2 { margin:0; font-size:18px; font-weight:600; color:var(--text-primary); }
|
||||
.analytics-close { background:none; border:none; font-size:24px; cursor:pointer; color:var(--text-muted); padding:4px 8px; border-radius:4px; }
|
||||
.analytics-close:hover { background:var(--bg-tertiary); color:var(--text-primary); }
|
||||
.analytics-body { padding:16px 24px 24px; }
|
||||
.analytics-section { margin-bottom:20px; }
|
||||
.analytics-section h3 { font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:0.05em; color:var(--text-muted,#9ca3af); margin:0 0 10px; }
|
||||
.analytics-row { display:flex; justify-content:space-between; align-items:baseline; padding:6px 0; font-size:14px; }
|
||||
.analytics-label { color:var(--text-secondary,#6b7280); }
|
||||
.analytics-value { font-weight:500; color:var(--text-primary); }
|
||||
.analytics-value small { color:var(--text-muted); font-weight:400; margin-left:6px; }
|
||||
.readability-meter { height:4px; background:var(--bg-tertiary,#f3f4f6); border-radius:2px; margin-top:8px; overflow:hidden; }
|
||||
.readability-fill { height:100%; background:linear-gradient(90deg,#ef4444,#f59e0b,#10b981); border-radius:2px; transition:width 0.5s ease; }
|
||||
.word-cloud { display:flex; flex-wrap:wrap; gap:8px; margin-top:8px; }
|
||||
.word-tag { background:var(--bg-tertiary,#f3f4f6); padding:4px 10px; border-radius:12px; font-size:13px; color:var(--text-secondary); }
|
||||
.word-tag small { opacity:0.5; font-size:10px; margin-left:2px; }
|
||||
.analytics-longest { flex-direction:column; gap:4px; }
|
||||
.analytics-sentence-preview { font-style:italic; font-size:13px; color:var(--text-secondary); font-weight:400; }
|
||||
@keyframes analyticsFadeIn { from{opacity:0} to{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"] .word-tag { background:var(--gray-700,#374151); }
|
||||
|
||||
@@ -276,3 +276,29 @@ body[class*="dark"] .tree-item:hover,
|
||||
body[class*="dark"] .git-file:hover {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
/* Outline Panel */
|
||||
.outline-panel { display: flex; flex-direction: column; height: 100%; }
|
||||
.outline-list { flex: 1; overflow-y: auto; padding: 4px 0; }
|
||||
.outline-item { display: flex; align-items: center; justify-content: space-between; padding: 4px 12px; cursor: pointer; font-size: 13px; color: var(--text-secondary, #6b7280); transition: background 0.15s, color 0.15s; border-left: 2px solid transparent; }
|
||||
.outline-item:hover { background: var(--bg-tertiary, #f3f4f6); color: var(--text-primary, #1f2937); }
|
||||
.outline-item.active { color: var(--accent-blue, #3b82f6); background: rgba(59, 130, 246, 0.08); border-left-color: var(--accent-blue, #3b82f6); font-weight: 600; }
|
||||
.outline-level-1 { padding-left: 12px; }
|
||||
.outline-level-2 { padding-left: 24px; }
|
||||
.outline-level-3 { padding-left: 36px; }
|
||||
.outline-level-4 { padding-left: 48px; }
|
||||
.outline-level-5 { padding-left: 56px; }
|
||||
.outline-level-6 { padding-left: 64px; }
|
||||
.outline-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.outline-badge { font-size: 10px; opacity: 0.5; margin-left: 8px; flex-shrink: 0; }
|
||||
.outline-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 32px 16px; color: var(--text-muted, #9ca3af); text-align: center; }
|
||||
.outline-empty p { margin: 4px 0; }
|
||||
.outline-hint { font-family: monospace; font-size: 12px; opacity: 0.7; }
|
||||
.outline-footer { padding: 8px 12px; border-top: 1px solid var(--border-color, #e5e7eb); font-size: 11px; color: var(--text-muted, #9ca3af); }
|
||||
|
||||
/* Outline dark mode */
|
||||
body[class*="dark"] .outline-item { color: #9ca3af; }
|
||||
body[class*="dark"] .outline-item:hover { background: #374151; color: #e5e7eb; }
|
||||
body[class*="dark"] .outline-item.active { color: #8b9aff; background: rgba(139, 154, 255, 0.1); border-left-color: #8b9aff; }
|
||||
body[class*="dark"] .outline-empty { color: #6b7280; }
|
||||
body[class*="dark"] .outline-footer { border-color: #333; color: #6b7280; }
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/* Zen Mode — Distraction-free writing styles */
|
||||
|
||||
/* Hide chrome elements */
|
||||
body.zen-mode .app-header,
|
||||
body.zen-mode .tab-bar,
|
||||
body.zen-mode .toolbar,
|
||||
body.zen-mode .status-bar,
|
||||
body.zen-mode .sidebar,
|
||||
body.zen-mode .breadcrumb-bar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Main content fills viewport */
|
||||
body.zen-mode .main-content {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Editor container takes full width */
|
||||
body.zen-mode .editor-container {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Hide preview panes */
|
||||
body.zen-mode .preview-container,
|
||||
body.zen-mode .preview-pane,
|
||||
body.zen-mode .pane-resizer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Center and style editor content for comfortable reading width */
|
||||
body.zen-mode .cm-content {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
font-size: 18px;
|
||||
line-height: 1.8;
|
||||
padding-top: 80px;
|
||||
}
|
||||
|
||||
body.zen-mode .cm-line {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
body.zen-mode .editor-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Floating HUD pill */
|
||||
.zen-hud {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
padding: 10px 24px;
|
||||
border-radius: 24px;
|
||||
font-size: 13px;
|
||||
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.zen-hud-stat {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.zen-hud-sep {
|
||||
opacity: 0.3;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* Word goal progress bar */
|
||||
.zen-progress-container {
|
||||
width: 120px;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 2px;
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.zen-progress-bar {
|
||||
height: 100%;
|
||||
background: rgba(255, 178, 70, 0.85);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Dark mode — HUD stays dark in both themes */
|
||||
body[class*="dark"].zen-mode .zen-hud {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
@@ -522,6 +522,54 @@ body.dark .pdf-tab-container .pdf-controls {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-toast-container {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
z-index: var(--z-toast, 500);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-toast {
|
||||
min-width: 260px;
|
||||
max-width: 420px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
|
||||
color: #111827;
|
||||
background: #ffffff;
|
||||
border-left: 4px solid #3b82f6;
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.app-toast.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.app-toast-info {
|
||||
border-left-color: #2563eb;
|
||||
}
|
||||
|
||||
.app-toast-success {
|
||||
border-left-color: #059669;
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
.app-toast-warning {
|
||||
border-left-color: #d97706;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
/* Dark theme adjustments for states */
|
||||
body[class*="dark"] .preview-error-message {
|
||||
color: #9ca3af;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Modal System Styles
|
||||
* Unified modal components with glassmorphism backdrop
|
||||
* @version 4.0.0
|
||||
* @version 4.1.0
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* ModalManager - Unified modal system with accessibility support
|
||||
* @version 4.0.0
|
||||
* @version 4.1.0
|
||||
*/
|
||||
class ModalManager {
|
||||
#modal;
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ function createWelcomeContent(recentFiles = []) {
|
||||
<div class="welcome-container">
|
||||
<div class="welcome-hero">
|
||||
<h1 class="welcome-title">MarkdownConverter</h1>
|
||||
<p class="welcome-version">Version 4.0.0</p>
|
||||
<p class="welcome-version">Version 4.1.0</p>
|
||||
<p class="welcome-subtitle">Professional Markdown Editor & Universal Document Converter</p>
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ function createWelcomeContent(recentFiles = []) {
|
||||
</div>
|
||||
|
||||
<div class="welcome-section">
|
||||
<h2>What's New in v4.0.0</h2>
|
||||
<h2>What's New in v4.x</h2>
|
||||
<ul class="welcome-features">
|
||||
<li><strong>CodeMirror Editor</strong> — Syntax highlighting, code folding, multiple cursors</li>
|
||||
<li><strong>Sidebar Panels</strong> — File Explorer, Git, Snippets, Templates</li>
|
||||
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Zen Mode — distraction-free writing mode
|
||||
* Provides typewriter scrolling, line dimming, centered content, and a floating HUD.
|
||||
* Toggle with F11, exit with Escape.
|
||||
*/
|
||||
|
||||
const {
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
} = require('@codemirror/view');
|
||||
const { RangeSetBuilder } = require('@codemirror/state');
|
||||
|
||||
class ZenMode {
|
||||
/**
|
||||
* @param {import('./renderer').TabManager} tabManager
|
||||
*/
|
||||
constructor(tabManager) {
|
||||
this.tabManager = tabManager;
|
||||
this.active = false;
|
||||
this._hud = null;
|
||||
this._timerInterval = null;
|
||||
this._sessionStart = null;
|
||||
this._extensions = [];
|
||||
}
|
||||
|
||||
activate() {
|
||||
if (this.active) return;
|
||||
this.active = true;
|
||||
|
||||
document.body.classList.add('zen-mode');
|
||||
|
||||
// Collapse sidebar
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (sidebar) sidebar.classList.add('collapsed');
|
||||
|
||||
// Hide preview pane for the active tab
|
||||
const tab = this.tabManager.tabs.get(this.tabManager.activeTabId);
|
||||
if (tab) {
|
||||
const previewContainer = document.getElementById(`preview-pane-${tab.id}`);
|
||||
if (previewContainer) previewContainer.classList.add('hidden');
|
||||
const editorPane = document.getElementById(`editor-pane-${tab.id}`);
|
||||
if (editorPane) editorPane.classList.add('full-width');
|
||||
|
||||
// Add CM6 extensions to the active editor
|
||||
if (tab.editorView) {
|
||||
this._extensions = [
|
||||
typewriterScrollExtension,
|
||||
lineDimmingExtension,
|
||||
];
|
||||
// Reconfigure is not straightforward with CM6's immutable state.
|
||||
// Instead we dispatch effects or simply rebuild with new extensions.
|
||||
// Since we cannot hot-swap extensions on an existing EditorView,
|
||||
// we store a reference and the CSS + updateListener handle the rest.
|
||||
// The typewriter + dimming plugins are applied via a compartment approach
|
||||
// would be ideal, but for simplicity we use DOM + updateListener.
|
||||
this._applyTypewriterBehavior(tab.editorView);
|
||||
}
|
||||
}
|
||||
|
||||
// Create HUD
|
||||
this._createHUD();
|
||||
|
||||
// Start session timer
|
||||
this._sessionStart = Date.now();
|
||||
this._timerInterval = setInterval(() => this._updateHUD(), 1000);
|
||||
|
||||
// Focus editor
|
||||
if (tab?.editorView) tab.editorView.focus();
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
if (!this.active) return;
|
||||
this.active = false;
|
||||
|
||||
document.body.classList.remove('zen-mode');
|
||||
|
||||
// Restore preview pane for the active tab
|
||||
const tab = this.tabManager.tabs.get(this.tabManager.activeTabId);
|
||||
if (tab) {
|
||||
const previewContainer = document.getElementById(`preview-pane-${tab.id}`);
|
||||
if (previewContainer) previewContainer.classList.remove('hidden');
|
||||
const editorPane = document.getElementById(`editor-pane-${tab.id}`);
|
||||
if (editorPane) editorPane.classList.remove('full-width');
|
||||
|
||||
// Remove typewriter listener
|
||||
if (tab.editorView && this._selectionListener) {
|
||||
window.removeEventListener('zen-typewriter', this._selectionListener);
|
||||
this._selectionListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove HUD
|
||||
if (this._hud && this._hud.parentNode) {
|
||||
this._hud.parentNode.removeChild(this._hud);
|
||||
this._hud = null;
|
||||
}
|
||||
|
||||
// Stop timer
|
||||
if (this._timerInterval) {
|
||||
clearInterval(this._timerInterval);
|
||||
this._timerInterval = null;
|
||||
}
|
||||
this._sessionStart = null;
|
||||
this._extensions = [];
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.active) {
|
||||
this.deactivate();
|
||||
} else {
|
||||
this.activate();
|
||||
}
|
||||
}
|
||||
|
||||
_applyTypewriterBehavior(view) {
|
||||
// We use a custom update listener that scrolls the cursor to center.
|
||||
// This is stored on the instance so we can clean it up.
|
||||
let scrollTimeout = null;
|
||||
|
||||
const scrollFn = () => {
|
||||
if (!this.active) return;
|
||||
const pos = view.state.selection.main.head;
|
||||
requestAnimationFrame(() => {
|
||||
view.dispatch({
|
||||
effects: EditorView.scrollIntoView(pos, { y: 'center' }),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Listen for selection/doc changes via a polling approach
|
||||
// since we cannot add extensions to an existing view.
|
||||
// Instead, use the DOM scrollIntoView after selection changes.
|
||||
const observer = new MutationObserver(() => {
|
||||
// No-op, we use interval below
|
||||
});
|
||||
|
||||
// Use interval to keep cursor centered during typing
|
||||
this._scrollInterval = setInterval(() => {
|
||||
if (!this.active) {
|
||||
clearInterval(this._scrollInterval);
|
||||
this._scrollInterval = null;
|
||||
return;
|
||||
}
|
||||
// Typewriter scroll: center the cursor line
|
||||
try {
|
||||
const pos = view.state.selection.main.head;
|
||||
const coords = view.coordsAtPos(pos);
|
||||
const scroller = view.scrollDOM;
|
||||
if (coords && scroller) {
|
||||
const scrollerRect = scroller.getBoundingClientRect();
|
||||
const cursorCenter = coords.top + (coords.bottom - coords.top) / 2;
|
||||
const viewCenter = scrollerRect.top + scrollerRect.height / 2;
|
||||
const diff = cursorCenter - viewCenter;
|
||||
if (Math.abs(diff) > 30) {
|
||||
scroller.scrollTop += diff;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors from destroyed views
|
||||
}
|
||||
}, 200);
|
||||
|
||||
// Initial scroll to center
|
||||
scrollFn();
|
||||
}
|
||||
|
||||
_createHUD() {
|
||||
const hud = document.createElement('div');
|
||||
hud.className = 'zen-hud';
|
||||
hud.innerHTML = `
|
||||
<span class="zen-hud-item" id="zen-words">0 words</span>
|
||||
<span class="zen-hud-sep">·</span>
|
||||
<span class="zen-hud-item" id="zen-reading-time">0 min read</span>
|
||||
<span class="zen-hud-sep">·</span>
|
||||
<span class="zen-hud-item" id="zen-timer">00:00</span>
|
||||
<div class="zen-progress-container" id="zen-progress-container">
|
||||
<div class="zen-progress-bar" id="zen-progress-bar"></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(hud);
|
||||
this._hud = hud;
|
||||
this._updateHUD();
|
||||
}
|
||||
|
||||
_updateHUD() {
|
||||
if (!this.active || !this._hud) return;
|
||||
|
||||
const content = this.tabManager.getEditorContent();
|
||||
const words = content.trim() ? content.trim().split(/\s+/).filter(w => w.length > 0).length : 0;
|
||||
const readingTime = Math.max(1, Math.ceil(words / 200));
|
||||
|
||||
const wordsEl = document.getElementById('zen-words');
|
||||
const readingEl = document.getElementById('zen-reading-time');
|
||||
const timerEl = document.getElementById('zen-timer');
|
||||
|
||||
if (wordsEl) wordsEl.textContent = `${words.toLocaleString()} words`;
|
||||
if (readingEl) readingEl.textContent = `${readingTime} min read`;
|
||||
|
||||
// Session timer
|
||||
if (this._sessionStart) {
|
||||
const elapsed = Math.floor((Date.now() - this._sessionStart) / 1000);
|
||||
const minutes = String(Math.floor(elapsed / 60)).padStart(2, '0');
|
||||
const seconds = String(elapsed % 60).padStart(2, '0');
|
||||
if (timerEl) timerEl.textContent = `${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
// Word goal progress
|
||||
const goalStr = localStorage.getItem('zen-word-goal');
|
||||
const progressBar = document.getElementById('zen-progress-bar');
|
||||
const progressContainer = document.getElementById('zen-progress-container');
|
||||
if (progressBar && progressContainer) {
|
||||
if (goalStr) {
|
||||
const goal = parseInt(goalStr, 10);
|
||||
if (goal > 0) {
|
||||
const pct = Math.min(100, Math.round((words / goal) * 100));
|
||||
progressBar.style.width = `${pct}%`;
|
||||
progressContainer.style.display = 'block';
|
||||
} else {
|
||||
progressContainer.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
progressContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Typewriter scroll ViewPlugin
|
||||
const typewriterScrollExtension = ViewPlugin.fromClass(class {
|
||||
constructor(view) {
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
update(update) {
|
||||
if (update.selectionSet || update.docChanged) {
|
||||
const pos = update.state.selection.main.head;
|
||||
requestAnimationFrame(() => {
|
||||
if (this.view.dom && this.view.dom.isConnected) {
|
||||
this.view.dispatch({
|
||||
effects: EditorView.scrollIntoView(pos, { y: 'center' }),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Line dimming ViewPlugin
|
||||
function getOpacity(distance) {
|
||||
if (distance === 0) return 1.0;
|
||||
if (distance === 1) return 0.7;
|
||||
if (distance === 2) return 0.6;
|
||||
if (distance === 3) return 0.45;
|
||||
return 0.3;
|
||||
}
|
||||
|
||||
const lineDimmingExtension = ViewPlugin.fromClass(class {
|
||||
constructor(view) {
|
||||
this.decorations = this.buildDecorations(view);
|
||||
}
|
||||
|
||||
update(update) {
|
||||
if (update.docChanged || update.selectionSet || update.viewportChanged) {
|
||||
this.decorations = this.buildDecorations(update.view);
|
||||
}
|
||||
}
|
||||
|
||||
buildDecorations(view) {
|
||||
const builder = new RangeSetBuilder();
|
||||
const pos = view.state.selection.main.head;
|
||||
const activeLine = view.state.doc.lineAt(pos).number;
|
||||
const doc = view.state.doc;
|
||||
|
||||
for (let lineNum = 1; lineNum <= doc.lines; lineNum++) {
|
||||
const line = doc.line(lineNum);
|
||||
const distance = Math.abs(lineNum - activeLine);
|
||||
const opacity = getOpacity(distance);
|
||||
const deco = Decoration.line({
|
||||
attributes: { style: `opacity:${opacity};transition:opacity 0.15s ease` },
|
||||
});
|
||||
builder.add(line.from, line.from, deco);
|
||||
}
|
||||
|
||||
return builder.finish();
|
||||
}
|
||||
}, {
|
||||
decorations: v => v.decorations,
|
||||
});
|
||||
|
||||
module.exports = { ZenMode };
|
||||
@@ -0,0 +1,64 @@
|
||||
const { EventBus } = require('../src/plugins/event-bus');
|
||||
|
||||
describe('EventBus', () => {
|
||||
let bus;
|
||||
|
||||
beforeEach(() => {
|
||||
bus = new EventBus();
|
||||
});
|
||||
|
||||
test('on/emit — listener receives payload', () => {
|
||||
const received = [];
|
||||
bus.on('document:saved', (payload) => received.push(payload));
|
||||
bus.emit('document:saved', { filePath: '/test.md', tabId: 'tab1' });
|
||||
expect(received).toEqual([{ filePath: '/test.md', tabId: 'tab1' }]);
|
||||
});
|
||||
|
||||
test('on — ignores events with no listeners', () => {
|
||||
expect(() => bus.emit('unknown:event', {})).not.toThrow();
|
||||
});
|
||||
|
||||
test('off — removes specific listener', () => {
|
||||
const handler = jest.fn();
|
||||
bus.on('test:event', handler);
|
||||
bus.off('test:event', handler);
|
||||
bus.emit('test:event', {});
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('off — removes all listeners for event when no handler given', () => {
|
||||
const h1 = jest.fn();
|
||||
const h2 = jest.fn();
|
||||
bus.on('test:event', h1);
|
||||
bus.on('test:event', h2);
|
||||
bus.off('test:event');
|
||||
bus.emit('test:event', {});
|
||||
expect(h1).not.toHaveBeenCalled();
|
||||
expect(h2).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('hasHandler — returns true when listener exists', () => {
|
||||
bus.on('ai:analyze', () => {});
|
||||
expect(bus.hasHandler('ai:analyze')).toBe(true);
|
||||
});
|
||||
|
||||
test('hasHandler — returns false when no listener exists', () => {
|
||||
expect(bus.hasHandler('ai:analyze')).toBe(false);
|
||||
});
|
||||
|
||||
test('handler errors are caught and logged, not thrown', () => {
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
bus.on('bad:event', () => { throw new Error('boom'); });
|
||||
expect(() => bus.emit('bad:event', {})).not.toThrow();
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('multiple listeners all receive the event', () => {
|
||||
const results = [];
|
||||
bus.on('multi:event', () => results.push('a'));
|
||||
bus.on('multi:event', () => results.push('b'));
|
||||
bus.emit('multi:event', {});
|
||||
expect(results).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
const { GoalTracker } = require('../src/plugins/built-in/writing-studio/goal-tracker');
|
||||
|
||||
describe('GoalTracker', () => {
|
||||
let tracker;
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = {};
|
||||
tracker = new GoalTracker({
|
||||
get: (key) => store[key],
|
||||
set: (key, value) => { store[key] = value; }
|
||||
});
|
||||
});
|
||||
|
||||
test('addWords records words for today', () => {
|
||||
tracker.addWords(500);
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
expect(store['plugins.writing-studio.history']).toBeDefined();
|
||||
const history = JSON.parse(store['plugins.writing-studio.history']);
|
||||
expect(history[today].words).toBe(500);
|
||||
});
|
||||
|
||||
test('addWords accumulates across multiple calls', () => {
|
||||
tracker.addWords(300);
|
||||
tracker.addWords(200);
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const history = JSON.parse(store['plugins.writing-studio.history']);
|
||||
expect(history[today].words).toBe(500);
|
||||
});
|
||||
|
||||
test('getDailyProgress returns 0 when no history', () => {
|
||||
expect(tracker.getDailyProgress(1000)).toEqual({ written: 0, goal: 1000, pct: 0 });
|
||||
});
|
||||
|
||||
test('getDailyProgress returns percentage', () => {
|
||||
tracker.addWords(500);
|
||||
const progress = tracker.getDailyProgress(1000);
|
||||
expect(progress.written).toBe(500);
|
||||
expect(progress.pct).toBe(50);
|
||||
});
|
||||
|
||||
test('getStreak counts consecutive days meeting goal', () => {
|
||||
const today = new Date();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
const key = d.toISOString().split('T')[0];
|
||||
tracker._setHistoryDay(key, { words: 1200 });
|
||||
}
|
||||
const streak = tracker.getStreak(1000);
|
||||
expect(streak).toBe(3);
|
||||
});
|
||||
|
||||
test('getStreak breaks on missed day', () => {
|
||||
const today = new Date();
|
||||
tracker._setHistoryDay(today.toISOString().split('T')[0], { words: 1200 });
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
tracker._setHistoryDay(yesterday.toISOString().split('T')[0], { words: 500 });
|
||||
const dayBefore = new Date(today);
|
||||
dayBefore.setDate(dayBefore.getDate() - 2);
|
||||
tracker._setHistoryDay(dayBefore.toISOString().split('T')[0], { words: 1200 });
|
||||
expect(tracker.getStreak(1000)).toBe(1);
|
||||
});
|
||||
|
||||
test('getLast30Days returns array of 30 entries', () => {
|
||||
tracker.addWords(100);
|
||||
const days = tracker.getLast30Days();
|
||||
expect(days.length).toBe(30);
|
||||
expect(days[29].words).toBe(100);
|
||||
});
|
||||
|
||||
test('getWeeklyTotal sums last 7 days', () => {
|
||||
const today = new Date();
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
tracker._setHistoryDay(d.toISOString().split('T')[0], { words: 200 });
|
||||
}
|
||||
expect(tracker.getWeeklyTotal()).toBe(1400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
const { PluginAPI } = require('../src/plugins/plugin-api');
|
||||
|
||||
describe('PluginAPI', () => {
|
||||
test('has default no-op lifecycle methods', () => {
|
||||
const plugin = new PluginAPI();
|
||||
expect(() => plugin.init({})).not.toThrow();
|
||||
expect(() => plugin.activate()).not.toThrow();
|
||||
expect(() => plugin.deactivate()).not.toThrow();
|
||||
});
|
||||
|
||||
test('getManifest returns null by default', () => {
|
||||
const plugin = new PluginAPI();
|
||||
expect(plugin.getManifest()).toBeNull();
|
||||
});
|
||||
|
||||
test('subclass can override init', () => {
|
||||
let called = false;
|
||||
class MyPlugin extends PluginAPI {
|
||||
init(context) {
|
||||
called = true;
|
||||
this.context = context;
|
||||
}
|
||||
}
|
||||
const p = new MyPlugin();
|
||||
p.init({ foo: 'bar' });
|
||||
expect(called).toBe(true);
|
||||
expect(p.context.foo).toBe('bar');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
const { PluginContext } = require('../src/plugins/plugin-context');
|
||||
const { EventBus } = require('../src/plugins/event-bus');
|
||||
|
||||
describe('PluginContext', () => {
|
||||
let context;
|
||||
let mockDeps;
|
||||
|
||||
beforeEach(() => {
|
||||
mockDeps = {
|
||||
pluginId: 'test-plugin',
|
||||
sidebar: { registerPanel: jest.fn() },
|
||||
commands: { register: jest.fn() },
|
||||
statusBar: { registerIndicator: jest.fn() },
|
||||
eventBus: new EventBus(),
|
||||
settings: { get: jest.fn(), set: jest.fn(), onChanged: jest.fn() },
|
||||
editor: { getContent: jest.fn(), getSelection: jest.fn(), insertAtCursor: jest.fn(), onContentChanged: jest.fn() },
|
||||
ipc: { invoke: jest.fn(), on: jest.fn() },
|
||||
exportHooks: { preHooks: [], postHooks: [] }
|
||||
};
|
||||
context = new PluginContext(mockDeps);
|
||||
});
|
||||
|
||||
test('exposes sidebar.registerPanel with namespaced id', () => {
|
||||
const handler = jest.fn();
|
||||
context.sidebar.registerPanel('my-panel', { icon: 'test', title: 'Test', render: handler });
|
||||
expect(mockDeps.sidebar.registerPanel).toHaveBeenCalledWith('test-plugin:my-panel', { icon: 'test', title: 'Test', render: handler });
|
||||
});
|
||||
|
||||
test('exposes commands.register with crash-safe wrapper', () => {
|
||||
const badHandler = () => { throw new Error('boom'); };
|
||||
context.commands.register('bad-cmd', 'Bad', badHandler, 'Ctrl+Alt+T');
|
||||
const registeredHandler = mockDeps.commands.register.mock.calls[0][2];
|
||||
expect(() => registeredHandler()).not.toThrow();
|
||||
});
|
||||
|
||||
test('exposes settings.get/set scoped to plugin', () => {
|
||||
context.settings.get('myKey');
|
||||
context.settings.set('myKey', 'myValue');
|
||||
expect(mockDeps.settings.get).toHaveBeenCalledWith('plugins.test-plugin.myKey');
|
||||
expect(mockDeps.settings.set).toHaveBeenCalledWith('plugins.test-plugin.myKey', 'myValue');
|
||||
});
|
||||
|
||||
test('exposes editor methods', () => {
|
||||
context.editor.getContent();
|
||||
context.editor.getSelection();
|
||||
expect(mockDeps.editor.getContent).toHaveBeenCalled();
|
||||
expect(mockDeps.editor.getSelection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('exposes events via event bus', () => {
|
||||
const handler = jest.fn();
|
||||
context.events.on('test:event', handler);
|
||||
context.events.emit('test:event', { data: 1 });
|
||||
expect(handler).toHaveBeenCalledWith({ data: 1 });
|
||||
});
|
||||
|
||||
test('exposes events.hasHandler', () => {
|
||||
expect(context.events.hasHandler('no:such')).toBe(false);
|
||||
context.events.on('exists:event', () => {});
|
||||
expect(context.events.hasHandler('exists:event')).toBe(true);
|
||||
});
|
||||
|
||||
test('exposes ipc.invoke', () => {
|
||||
context.ipc.invoke('test:channel', { a: 1 });
|
||||
expect(mockDeps.ipc.invoke).toHaveBeenCalledWith('test:channel', { a: 1 });
|
||||
});
|
||||
|
||||
test('exposes exports.registerPreHook', () => {
|
||||
const handler = jest.fn();
|
||||
context.exports.registerPreHook(handler);
|
||||
expect(mockDeps.exportHooks.preHooks).toContain(handler);
|
||||
});
|
||||
|
||||
test('exposes exports.registerPostHook', () => {
|
||||
const handler = jest.fn();
|
||||
context.exports.registerPostHook(handler);
|
||||
expect(mockDeps.exportHooks.postHooks).toContain(handler);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { PluginLoader } = require('../src/plugins/plugin-loader');
|
||||
|
||||
describe('PluginLoader', () => {
|
||||
let tempDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plugin-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeManifest(dir, manifest) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||||
}
|
||||
|
||||
test('discoverPlugins — finds manifests in built-in directory', () => {
|
||||
const pluginDir = path.join(tempDir, 'my-plugin');
|
||||
writeManifest(pluginDir, {
|
||||
id: 'my-plugin',
|
||||
name: 'My Plugin',
|
||||
version: '1.0.0',
|
||||
description: 'Test'
|
||||
});
|
||||
const loader = new PluginLoader([tempDir]);
|
||||
const plugins = loader.discoverPlugins();
|
||||
expect(plugins).toHaveLength(1);
|
||||
expect(plugins[0].id).toBe('my-plugin');
|
||||
});
|
||||
|
||||
test('discoverPlugins — skips directories without manifest.json', () => {
|
||||
const emptyDir = path.join(tempDir, 'no-manifest');
|
||||
fs.mkdirSync(emptyDir, { recursive: true });
|
||||
const loader = new PluginLoader([emptyDir]);
|
||||
const plugins = loader.discoverPlugins();
|
||||
expect(plugins).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('validateManifest — accepts valid manifest', () => {
|
||||
const manifest = {
|
||||
id: 'test-plugin',
|
||||
name: 'Test Plugin',
|
||||
version: '1.0.0',
|
||||
description: 'A test plugin'
|
||||
};
|
||||
const loader = new PluginLoader([]);
|
||||
expect(loader.validateManifest(manifest)).toBe(true);
|
||||
});
|
||||
|
||||
test('validateManifest — rejects manifest missing id', () => {
|
||||
const manifest = { name: 'No ID', version: '1.0.0', description: 'x' };
|
||||
const loader = new PluginLoader([]);
|
||||
expect(() => loader.validateManifest(manifest)).toThrow(/id/);
|
||||
});
|
||||
|
||||
test('validateManifest — rejects manifest missing name', () => {
|
||||
const manifest = { id: 'test', version: '1.0.0', description: 'x' };
|
||||
const loader = new PluginLoader([]);
|
||||
expect(() => loader.validateManifest(manifest)).toThrow(/name/);
|
||||
});
|
||||
|
||||
test('validateManifest — rejects manifest with duplicate id', () => {
|
||||
const loader = new PluginLoader([]);
|
||||
loader.loadedIds = new Set(['existing-plugin']);
|
||||
expect(() => loader.validateManifest({ id: 'existing-plugin', name: 'Dup', version: '1.0.0', description: 'x' }))
|
||||
.toThrow(/duplicate/i);
|
||||
});
|
||||
|
||||
test('discoverPlugins — loads index.js Plugin export if present', () => {
|
||||
const pluginDir = path.join(tempDir, 'with-index');
|
||||
writeManifest(pluginDir, { id: 'with-index', name: 'With Index', version: '1.0.0', description: 'Test' });
|
||||
// Write a simple index.js with a Plugin export
|
||||
fs.writeFileSync(path.join(pluginDir, 'index.js'), `
|
||||
class SimplePlugin { init() {} }
|
||||
module.exports = { Plugin: SimplePlugin };
|
||||
`);
|
||||
const loader = new PluginLoader([tempDir]);
|
||||
const plugins = loader.discoverPlugins();
|
||||
expect(plugins).toHaveLength(1);
|
||||
expect(plugins[0].PluginClass).toBeDefined();
|
||||
expect(plugins[0].PluginClass.name).toBe('SimplePlugin');
|
||||
});
|
||||
|
||||
test('discoverPlugins — continues if one plugin fails to load', () => {
|
||||
const badDir = path.join(tempDir, 'bad');
|
||||
writeManifest(badDir, { id: 'bad', name: 'Bad', version: '1.0.0', description: 'Broken' });
|
||||
fs.writeFileSync(path.join(badDir, 'index.js'), 'throw new Error("broken");');
|
||||
|
||||
const goodDir = path.join(tempDir, 'good');
|
||||
writeManifest(goodDir, { id: 'good', name: 'Good', version: '1.0.0', description: 'Works' });
|
||||
|
||||
const loader = new PluginLoader([tempDir]);
|
||||
const plugins = loader.discoverPlugins();
|
||||
expect(plugins).toHaveLength(1);
|
||||
expect(plugins[0].id).toBe('good');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
const { PluginRegistry } = require('../src/plugins/plugin-registry');
|
||||
const { PluginAPI } = require('../src/plugins/plugin-api');
|
||||
const { EventBus } = require('../src/plugins/event-bus');
|
||||
|
||||
class TestPlugin extends PluginAPI {
|
||||
init(context) { this.initialized = true; this.ctx = context; }
|
||||
activate() { this.activated = true; }
|
||||
deactivate() { this.deactivated = true; }
|
||||
}
|
||||
|
||||
describe('PluginRegistry', () => {
|
||||
let registry;
|
||||
let mockDeps;
|
||||
|
||||
beforeEach(() => {
|
||||
mockDeps = {
|
||||
sidebar: { registerPanel: jest.fn() },
|
||||
commands: { register: jest.fn() },
|
||||
statusBar: { registerIndicator: jest.fn() },
|
||||
eventBus: new EventBus(),
|
||||
settings: { get: jest.fn(), set: jest.fn(), onChanged: jest.fn() },
|
||||
editor: { getContent: jest.fn(() => ''), getSelection: jest.fn(() => ''), insertAtCursor: jest.fn(), onContentChanged: jest.fn() },
|
||||
ipc: { invoke: jest.fn(), on: jest.fn() }
|
||||
};
|
||||
registry = new PluginRegistry(mockDeps);
|
||||
});
|
||||
|
||||
test('register — stores plugin and calls init', () => {
|
||||
registry.register({
|
||||
id: 'test', name: 'Test', version: '1.0.0', description: 'desc',
|
||||
manifest: {}, PluginClass: TestPlugin, dir: '/tmp/test'
|
||||
});
|
||||
const entry = registry.getPlugin('test');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.instance.initialized).toBe(true);
|
||||
});
|
||||
|
||||
test('register — works without PluginClass', () => {
|
||||
registry.register({
|
||||
id: 'manifest-only', name: 'Manifest Only', version: '1.0.0', description: 'desc',
|
||||
manifest: {}, PluginClass: null, dir: '/tmp/test'
|
||||
});
|
||||
expect(registry.getPlugin('manifest-only')).toBeDefined();
|
||||
});
|
||||
|
||||
test('register — init error does not crash, plugin not registered', () => {
|
||||
class BadPlugin extends PluginAPI {
|
||||
init() { throw new Error('init fail'); }
|
||||
}
|
||||
expect(() => {
|
||||
registry.register({
|
||||
id: 'bad', name: 'Bad', version: '1.0.0', description: 'desc',
|
||||
manifest: {}, PluginClass: BadPlugin, dir: '/tmp/test'
|
||||
});
|
||||
}).not.toThrow();
|
||||
expect(registry.getPlugin('bad')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getPlugin — returns undefined for unknown', () => {
|
||||
expect(registry.getPlugin('nope')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getAll — returns all registered plugins', () => {
|
||||
registry.register({ id: 'a', name: 'A', version: '1', description: '', manifest: {}, PluginClass: null, dir: '' });
|
||||
registry.register({ id: 'b', name: 'B', version: '1', description: '', manifest: {}, PluginClass: null, dir: '' });
|
||||
expect(registry.getAll()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('activate — calls activate on plugin instance', () => {
|
||||
registry.register({ id: 'test', name: 'Test', version: '1', description: '', manifest: {}, PluginClass: TestPlugin, dir: '' });
|
||||
registry.activate('test');
|
||||
expect(registry.getPlugin('test').instance.activated).toBe(true);
|
||||
});
|
||||
|
||||
test('deactivate — calls deactivate on plugin instance', () => {
|
||||
registry.register({ id: 'test', name: 'Test', version: '1', description: '', manifest: {}, PluginClass: TestPlugin, dir: '' });
|
||||
registry.deactivate('test');
|
||||
expect(registry.getPlugin('test').instance.deactivated).toBe(true);
|
||||
});
|
||||
|
||||
test('exportHooks are available and populated', () => {
|
||||
registry.register({ id: 'test', name: 'Test', version: '1', description: '', manifest: {}, PluginClass: TestPlugin, dir: '' });
|
||||
const handler = jest.fn();
|
||||
registry.getPlugin('test').instance.ctx.exports.registerPreHook(handler);
|
||||
expect(registry.exportHooks.preHooks).toContain(handler);
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,14 @@ describe('Preload Security', () => {
|
||||
'save-pasted-image',
|
||||
'load-template',
|
||||
'list-directory',
|
||||
'read-file',
|
||||
'write-file',
|
||||
'delete-file',
|
||||
'ensure-directory',
|
||||
'path-exists',
|
||||
'is-directory',
|
||||
'copy-path',
|
||||
'move-path',
|
||||
'open-file-path',
|
||||
'git-status',
|
||||
'git-stage',
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
const { ProjectManager } = require('../src/plugins/built-in/writing-studio/project-manager');
|
||||
|
||||
describe('ProjectManager', () => {
|
||||
let pm;
|
||||
let files;
|
||||
|
||||
beforeEach(() => {
|
||||
files = {};
|
||||
pm = new ProjectManager({
|
||||
readFile: (p) => files[p] || null,
|
||||
writeFile: (p, c) => { files[p] = c; },
|
||||
fileExists: (p) => p in files,
|
||||
listDir: (p) => Object.keys(files).filter(f => f.startsWith(p)).map(f => f.slice(p.length + 1))
|
||||
});
|
||||
});
|
||||
|
||||
test('createProject writes .project.json', () => {
|
||||
const project = pm.createProject('/manuscripts/novel', {
|
||||
title: 'My Novel', type: 'manuscript', targetWords: 80000
|
||||
});
|
||||
expect(project.title).toBe('My Novel');
|
||||
expect(files['/manuscripts/novel/.project.json']).toBeDefined();
|
||||
const parsed = JSON.parse(files['/manuscripts/novel/.project.json']);
|
||||
expect(parsed.target.words).toBe(80000);
|
||||
});
|
||||
|
||||
test('loadProject reads and returns project data', () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||
chapters: [], metadata: {}
|
||||
});
|
||||
const project = pm.loadProject('/manuscripts/novel');
|
||||
expect(project.title).toBe('Test');
|
||||
});
|
||||
|
||||
test('loadProject returns null if no project file', () => {
|
||||
expect(pm.loadProject('/nonexistent')).toBeNull();
|
||||
});
|
||||
|
||||
test('addChapter appends chapter and saves', () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||
chapters: [], metadata: {}
|
||||
});
|
||||
pm.addChapter('/manuscripts/novel', { file: '01-chapter.md', title: 'Chapter One', status: 'draft' });
|
||||
const parsed = JSON.parse(files['/manuscripts/novel/.project.json']);
|
||||
expect(parsed.chapters.length).toBe(1);
|
||||
expect(parsed.chapters[0].title).toBe('Chapter One');
|
||||
});
|
||||
|
||||
test('compileManuscript concatenates chapter files', () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||
chapters: [
|
||||
{ file: '01.md', title: 'One', status: 'draft' },
|
||||
{ file: '02.md', title: 'Two', status: 'draft' }
|
||||
], metadata: {}
|
||||
});
|
||||
files['/manuscripts/novel/01.md'] = 'First chapter content.';
|
||||
files['/manuscripts/novel/02.md'] = 'Second chapter content.';
|
||||
const result = pm.compileManuscript('/manuscripts/novel');
|
||||
expect(result).toBe('First chapter content.\n\n---\n\nSecond chapter content.');
|
||||
});
|
||||
|
||||
test('compileManuscript skips missing files', () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||
chapters: [
|
||||
{ file: '01.md', title: 'One', status: 'draft' },
|
||||
{ file: '02.md', title: 'Two', status: 'draft' }
|
||||
], metadata: {}
|
||||
});
|
||||
files['/manuscripts/novel/01.md'] = 'Only chapter one.';
|
||||
const result = pm.compileManuscript('/manuscripts/novel');
|
||||
expect(result).toBe('Only chapter one.');
|
||||
});
|
||||
|
||||
test('getStats returns total word count across chapters', () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||
chapters: [
|
||||
{ file: '01.md', title: 'One', status: 'draft' },
|
||||
{ file: '02.md', title: 'Two', status: 'draft' }
|
||||
], metadata: {}
|
||||
});
|
||||
files['/manuscripts/novel/01.md'] = 'word '.repeat(100).trim();
|
||||
files['/manuscripts/novel/02.md'] = 'more '.repeat(50).trim();
|
||||
const stats = pm.getStats('/manuscripts/novel');
|
||||
expect(stats.totalWords).toBeGreaterThan(0);
|
||||
expect(stats.chapterCount).toBe(2);
|
||||
expect(stats.targetWords).toBe(50000);
|
||||
expect(stats.pctComplete).toBeDefined();
|
||||
});
|
||||
|
||||
test('updateChapter modifies a chapter by index', () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||
chapters: [{ file: '01.md', title: 'Old Title', status: 'draft' }],
|
||||
metadata: {}
|
||||
});
|
||||
pm.updateChapter('/manuscripts/novel', 0, { title: 'New Title', status: 'revised' });
|
||||
const parsed = JSON.parse(files['/manuscripts/novel/.project.json']);
|
||||
expect(parsed.chapters[0].title).toBe('New Title');
|
||||
expect(parsed.chapters[0].status).toBe('revised');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
const { SettingsStore } = require('../src/plugins/settings-store');
|
||||
|
||||
describe('SettingsStore', () => {
|
||||
let store;
|
||||
let data;
|
||||
|
||||
beforeEach(() => {
|
||||
data = {};
|
||||
store = new SettingsStore({
|
||||
get: (key) => data[key],
|
||||
set: (key, value) => { data[key] = value; }
|
||||
});
|
||||
});
|
||||
|
||||
test('get returns value for full key', () => {
|
||||
data['plugins.my-plugin.myKey'] = 'myValue';
|
||||
expect(store.get('plugins.my-plugin.myKey')).toBe('myValue');
|
||||
});
|
||||
|
||||
test('get returns undefined for missing key', () => {
|
||||
expect(store.get('plugins.my-plugin.missing')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('set stores value', () => {
|
||||
store.set('plugins.my-plugin.myKey', 42);
|
||||
expect(data['plugins.my-plugin.myKey']).toBe(42);
|
||||
});
|
||||
|
||||
test('set overwrites existing value', () => {
|
||||
data['plugins.my-plugin.myKey'] = 'old';
|
||||
store.set('plugins.my-plugin.myKey', 'new');
|
||||
expect(data['plugins.my-plugin.myKey']).toBe('new');
|
||||
});
|
||||
});
|
||||
+9
-1
@@ -17,7 +17,15 @@ global.window.electronAPI = {
|
||||
setCurrent: jest.fn(),
|
||||
saveRecent: jest.fn(),
|
||||
clearRecent: jest.fn(),
|
||||
rendererReady: jest.fn()
|
||||
rendererReady: jest.fn(),
|
||||
read: jest.fn(() => Promise.resolve('')),
|
||||
write: jest.fn(() => Promise.resolve()),
|
||||
delete: jest.fn(() => Promise.resolve()),
|
||||
ensureDir: jest.fn(() => Promise.resolve()),
|
||||
exists: jest.fn(() => Promise.resolve(false)),
|
||||
isDirectory: jest.fn(() => Promise.resolve(false)),
|
||||
copy: jest.fn(() => Promise.resolve()),
|
||||
move: jest.fn(() => Promise.resolve())
|
||||
},
|
||||
theme: {
|
||||
get: jest.fn()
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
const { SnapshotManager } = require('../src/plugins/built-in/writing-studio/snapshot-manager');
|
||||
|
||||
describe('SnapshotManager', () => {
|
||||
let manager;
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = {};
|
||||
manager = new SnapshotManager({
|
||||
get: (key) => store[key],
|
||||
set: (key, value) => { store[key] = value; }
|
||||
});
|
||||
});
|
||||
|
||||
test('create stores snapshot with timestamp, content, wordCount', () => {
|
||||
const snap = manager.create('Hello world this is a test', 'auto');
|
||||
expect(snap).toHaveProperty('id');
|
||||
expect(snap.content).toBe('Hello world this is a test');
|
||||
expect(snap.wordCount).toBe(6);
|
||||
expect(snap.label).toBe('auto');
|
||||
});
|
||||
|
||||
test('list returns snapshots ordered newest first', () => {
|
||||
manager.create('first', 'auto');
|
||||
manager.create('second', 'auto');
|
||||
const list = manager.list();
|
||||
expect(list.length).toBe(2);
|
||||
expect(list[0].content).toBe('second');
|
||||
});
|
||||
|
||||
test('getById returns specific snapshot', () => {
|
||||
const snap = manager.create('find me', 'manual');
|
||||
const found = manager.getById(snap.id);
|
||||
expect(found.content).toBe('find me');
|
||||
});
|
||||
|
||||
test('getById returns null for missing id', () => {
|
||||
expect(manager.getById('nope')).toBeNull();
|
||||
});
|
||||
|
||||
test('restore returns content of snapshot', () => {
|
||||
const snap = manager.create('restore this', 'manual');
|
||||
expect(manager.restore(snap.id)).toBe('restore this');
|
||||
});
|
||||
|
||||
test('restore throws for missing snapshot', () => {
|
||||
expect(() => manager.restore('nope')).toThrow('Snapshot not found');
|
||||
});
|
||||
|
||||
test('delete removes a snapshot', () => {
|
||||
const snap = manager.create('delete me', 'auto');
|
||||
manager.delete(snap.id);
|
||||
expect(manager.getById(snap.id)).toBeNull();
|
||||
});
|
||||
|
||||
test('diff returns added/removed line counts', () => {
|
||||
const snap = manager.create('line one\nline two\nline three', 'auto');
|
||||
const result = manager.diff(snap.id, 'line one\nline modified\nline three\nline four');
|
||||
expect(result.added).toBe(2);
|
||||
expect(result.removed).toBe(1);
|
||||
});
|
||||
|
||||
test('diff throws for missing snapshot', () => {
|
||||
expect(() => manager.diff('nope', 'new content')).toThrow('Snapshot not found');
|
||||
});
|
||||
|
||||
test('prune keeps only the N most recent snapshots', () => {
|
||||
for (let i = 0; i < 10; i++) manager.create('snap ' + i, 'auto');
|
||||
manager.prune(5);
|
||||
expect(manager.list().length).toBe(5);
|
||||
expect(manager.list()[0].content).toBe('snap 9');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
const { SprintEngine } = require('../src/plugins/built-in/writing-studio/sprint-engine');
|
||||
|
||||
describe('SprintEngine', () => {
|
||||
let engine;
|
||||
let events;
|
||||
|
||||
beforeEach(() => {
|
||||
events = [];
|
||||
engine = new SprintEngine({
|
||||
onEvent: (name, data) => events.push({ name, data })
|
||||
});
|
||||
});
|
||||
|
||||
test('starts a sprint with duration and initial word count', () => {
|
||||
engine.start(25, 100);
|
||||
expect(engine.isActive()).toBe(true);
|
||||
expect(engine.getRemaining()).toBeLessThanOrEqual(25 * 60 * 1000);
|
||||
});
|
||||
|
||||
test('start throws if sprint already active', () => {
|
||||
engine.start(25, 100);
|
||||
expect(() => engine.start(10, 50)).toThrow('Sprint already active');
|
||||
});
|
||||
|
||||
test('stop calculates WPM from word delta', () => {
|
||||
engine.start(25, 100);
|
||||
const result = engine.stop(350); // 250 words added
|
||||
expect(result.wordDelta).toBe(250);
|
||||
expect(engine.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
test('stop throws if no active sprint', () => {
|
||||
expect(() => engine.stop(100)).toThrow('No active sprint');
|
||||
});
|
||||
|
||||
test('tick emits progress event with remaining time', () => {
|
||||
engine.start(25, 100);
|
||||
engine.tick(200);
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0].name).toBe('sprint:tick');
|
||||
expect(events[0].data.remaining).toBeDefined();
|
||||
expect(events[0].data.elapsed).toBe(200);
|
||||
});
|
||||
|
||||
test('tick auto-stops when time expires and emits sprint:complete', () => {
|
||||
engine.start(1, 100); // 1 minute
|
||||
engine.tick(60 * 1000 + 1); // just past
|
||||
expect(events.some(e => e.name === 'sprint:complete')).toBe(true);
|
||||
expect(engine.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
test('getRemaining returns 0 when no sprint active', () => {
|
||||
expect(engine.getRemaining()).toBe(0);
|
||||
});
|
||||
|
||||
test('getStats returns elapsed, WPM, wordDelta after stop', () => {
|
||||
engine.start(25, 100);
|
||||
const result = engine.stop(350);
|
||||
expect(result).toHaveProperty('wordDelta', 250);
|
||||
expect(result).toHaveProperty('elapsed');
|
||||
expect(result).toHaveProperty('wpm');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user