mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-03 02:11:07 +05:30
Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a8671b128 | ||
|
|
3646dc29c6 | ||
|
|
a999f598cc | ||
|
|
7a396f64a6 | ||
|
|
88e9a5290d | ||
|
|
fff15d8d3e | ||
|
|
c574d77c20 | ||
|
|
272215f9af | ||
|
|
0192590567 | ||
|
|
96df5652d6 | ||
|
|
a9e05d2c0f | ||
|
|
c982b3e90f | ||
|
|
cfaafc07b2 | ||
|
|
94ad99dc4d | ||
|
|
baf644d62b | ||
|
|
f9a5420ad2 | ||
|
|
f8361174f2 | ||
|
|
64df0660c8 | ||
|
|
bbfa2a38e9 | ||
|
|
94ec3f45ce | ||
|
|
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
|
- name: Run tests
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
|
- name: Run linter
|
||||||
|
run: npm run lint
|
||||||
|
|||||||
@@ -19,17 +19,17 @@ jobs:
|
|||||||
node-version: 20
|
node-version: 20
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Install rpmbuild
|
|
||||||
run: sudo apt-get update && sudo apt-get install -y rpm
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Download external tools (pandoc)
|
||||||
|
run: node scripts/download-tools.js
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
- name: Build Linux packages
|
- name: Build Linux packages
|
||||||
run: npm run build:linux
|
run: npm run build:linux-ci -- --publish=never
|
||||||
|
|
||||||
- name: Upload Linux artifacts
|
- name: Upload Linux artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
retention-days: 5
|
retention-days: 5
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
runs-on: ubuntu-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -53,17 +53,37 @@ jobs:
|
|||||||
node-version: 20
|
node-version: 20
|
||||||
cache: npm
|
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
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Build Windows packages
|
- name: Download external tools (pandoc)
|
||||||
run: npm run build:win
|
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
|
- name: Upload Windows artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -71,23 +91,26 @@ jobs:
|
|||||||
name: windows-artifacts
|
name: windows-artifacts
|
||||||
path: |
|
path: |
|
||||||
dist/*.exe
|
dist/*.exe
|
||||||
dist/*-win.zip
|
dist/*.zip
|
||||||
retention-days: 5
|
retention-days: 5
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: [build-linux, build-windows]
|
needs: [build-linux, build-windows]
|
||||||
|
if: always()
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Download Linux artifacts
|
- name: Download Linux artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
name: linux-artifacts
|
name: linux-artifacts
|
||||||
path: dist
|
path: dist
|
||||||
|
|
||||||
- name: Download Windows artifacts
|
- name: Download Windows artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
name: windows-artifacts
|
name: windows-artifacts
|
||||||
path: dist
|
path: dist
|
||||||
|
|||||||
+15
-3
@@ -8,14 +8,22 @@ Thumbs.db
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
.vscode/
|
.vscode/*
|
||||||
|
!.vscode/launch.json
|
||||||
.idea/
|
.idea/
|
||||||
*.iml
|
*.iml
|
||||||
out/
|
out/
|
||||||
.cache/
|
.cache/
|
||||||
.npm/
|
.npm/
|
||||||
.electron/
|
.electron/
|
||||||
package-lock.json
|
# package-lock.json is intentionally tracked for reproducible CI builds
|
||||||
|
|
||||||
|
# Downloaded tool binaries (fetched at build time via scripts/download-tools.js)
|
||||||
|
bin/
|
||||||
|
|
||||||
|
# Code signing certificates — never commit private keys
|
||||||
|
*.pfx
|
||||||
|
*.p12
|
||||||
|
|
||||||
# Screenshots and temp files
|
# Screenshots and temp files
|
||||||
*.png.bak
|
*.png.bak
|
||||||
@@ -33,4 +41,8 @@ pdf\ modal.png
|
|||||||
# Claude/AI development files
|
# Claude/AI development files
|
||||||
.claude/
|
.claude/
|
||||||
CLAUDE.md
|
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
|
## Version
|
||||||
|
|
||||||
v3.0.0
|
v4.1.0
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 361 KiB |
@@ -1,6 +1,6 @@
|
|||||||
# MarkdownConverter - STRIDE Threat Model Analysis
|
# MarkdownConverter - STRIDE Threat Model Analysis
|
||||||
|
|
||||||
**Version:** 4.0.0
|
**Version:** 4.1.0
|
||||||
**Date:** 2026-03-15
|
**Date:** 2026-03-15
|
||||||
**Methodology:** STRIDE + MITRE ATT&CK Mapping
|
**Methodology:** STRIDE + MITRE ATT&CK Mapping
|
||||||
**Analyst:** Security Assessment Team
|
**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
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
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
# MarkdownConverter — React + shadcn/ui UI Redesign
|
||||||
|
|
||||||
|
**Date:** 2026-06-05
|
||||||
|
**Status:** Design (awaiting user approval)
|
||||||
|
**Branch:** `react-electron`
|
||||||
|
**Author:** Brainstormed with user via superpowers:brainstorming
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Replace the legacy vanilla JS renderer (`src/renderer.js` is 213 KB; `src/styles.css` is 74 KB) with a modern React 19 + Vite + TypeScript + shadcn/ui renderer that achieves visual feature parity while delivering a Polished + Glassy (Raycast/Arc-style) aesthetic. The Electron main process, preload bridge, and IPC contracts stay unchanged. Work proceeds via vertical slices — each PR ships a demoable feature.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Replace every render-time feature of the legacy renderer with an idiomatic React + shadcn/ui implementation.
|
||||||
|
2. Adopt the "Polished + Glassy" visual language (subtle shadows, 8–12 px radii, gradient accents, gentle depth) on top of the existing ConcreteInfo brand tokens and design system.
|
||||||
|
3. Use a single component foundation (shadcn/ui) and a single motion library (Motion / Framer Motion) to keep the dependency surface narrow and the design coherent.
|
||||||
|
4. Keep the main process, preload, and IPC contracts untouched. The renderer is the only surface being rewritten.
|
||||||
|
5. Keep the app runnable at every commit. No "big bang" merge.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Migrating the main process to TypeScript (out of scope for this spec).
|
||||||
|
- Adding new features the legacy renderer does not have (plugin system, AI features, etc. — those are future specs).
|
||||||
|
- Replacing Pandoc/FFmpeg/ImageMagick orchestration.
|
||||||
|
- Changing the packaging pipeline (electron-builder config stays).
|
||||||
|
- Replacing the CodeMirror 6 editor — it is the right tool and is already wired up.
|
||||||
|
|
||||||
|
## Decisions Locked During Brainstorming
|
||||||
|
|
||||||
|
| Decision | Choice | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Scope | Full feature parity with legacy renderer | User-selected option. Renderer is one cohesive surface; splitting it across specs would force premature contracts. |
|
||||||
|
| Visual style | **B — Polished + Glassy** (Raycast/Arc aesthetic) | "Fancy" comes from elevation, gradient accents, and material depth — not over-designed visuals. |
|
||||||
|
| Layout | **2 — IDE-style** (equal editor/preview, draggable divider, collapsible sidebar) | Power-user markdown apps converge on this. Draggable divider + keyboard reset. |
|
||||||
|
| Modal patterns | **A (Centered Dialog), B (Right Side-Sheet), D (Toasts)** | No command palette. |
|
||||||
|
| Command palette | **No** — full menus, no ⌘K | Power users can use the OS-level launcher. App stays focused on writing. |
|
||||||
|
| Animation | **Motion (Framer Motion)** | Best for layout transitions, modal/drawer enter/exit, drag. Sparingly used. |
|
||||||
|
| Component library | **shadcn/ui** | Tailwind + Radix primitives, copy-paste ownership, perfect fit for the "glassy" aesthetic and the HSL-CSS-variable foundation that is already in place. |
|
||||||
|
| Implementation strategy | **Vertical slices** — one feature end-to-end per PR | App stays runnable. Each PR is reviewable. |
|
||||||
|
|
||||||
|
## Defaults (Used Unless Overridden Later)
|
||||||
|
|
||||||
|
| Item | Default | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| State management | Zustand (already in deps) + Immer for nested patches | Already in `package.json`; perfect for editor state. |
|
||||||
|
| Theming | shadcn `next-themes`, dark default + light, system-aware | shadcn canonical pattern. Brand colors already in CSS vars. |
|
||||||
|
| Icons | `lucide-react` (already in deps) | 1000+ tree-shakable icons. |
|
||||||
|
| Forms | `react-hook-form` + `zod` | Standard for shadcn forms. |
|
||||||
|
| Drag/drop (sortable lists) | `@dnd-kit/core` | De facto React drag lib (file tree reordering, plugin list reorder). |
|
||||||
|
| Pane resize (split layout) | `react-resizable-panels` | Canonical React lib for resizable pane groups; handles drag, arrow keys, snap, persisted sizes. |
|
||||||
|
| Testing | Vitest + RTL + Playwright (E2E + visual regression) | Standard for React + Electron. |
|
||||||
|
| TypeScript | Strict mode (already wired) | Already in deps and `vite.renderer.config.ts`. |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The Electron app keeps its existing process model. Only the renderer is rewritten.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ Electron Main (UNCHANGED) │
|
||||||
|
│ - BrowserWindow, IPC handlers, file/fs ops │
|
||||||
|
│ - Pandoc, FFmpeg, ImageMagick orchestration │
|
||||||
|
└────────────┬─────────────────────────────────────────────┘
|
||||||
|
│ contextBridge (UNCHANGED preload.js)
|
||||||
|
┌────────────▼─────────────────────────────────────────────┐
|
||||||
|
│ React Renderer (REWRITTEN) │
|
||||||
|
│ ┌────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ AppShell (layout) │ │
|
||||||
|
│ │ ├─ MenuBar (native) │ │
|
||||||
|
│ │ ├─ AppHeader (logo, breadcrumbs, theme toggle) │ │
|
||||||
|
│ │ ├─ TabBar (open files) │ │
|
||||||
|
│ │ ├─ Toolbar (formatting) │ │
|
||||||
|
│ │ ├─ ResizablePaneGroup (sidebar | editor | preview) │ │
|
||||||
|
│ │ │ ├─ Sidebar (file tree, outline) │ │
|
||||||
|
│ │ │ ├─ EditorPane (CodeMirror 6) │ │
|
||||||
|
│ │ │ └─ PreviewPane (marked + KaTeX + Mermaid) │ │
|
||||||
|
│ │ ├─ StatusBar (word count, encoding, cursor pos) │ │
|
||||||
|
│ │ └─ ModalLayer (Dialog, SideSheet, Toaster) │ │
|
||||||
|
│ └────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Feature Modules (each owns UI + state slice) │ │
|
||||||
|
│ │ ├─ editor/ CodeMirror wrapper, syntax, themes │ │
|
||||||
|
│ │ ├─ preview/ markdown→html, KaTeX, Mermaid │ │
|
||||||
|
│ │ ├─ tabs/ open files, dirty state │ │
|
||||||
|
│ │ ├─ sidebar/ file tree, outline, search results │ │
|
||||||
|
│ │ ├─ modals/ export, settings, about, etc. │ │
|
||||||
|
│ │ ├─ tools/ zen, repl, ascii-gen, table-gen │ │
|
||||||
|
│ │ └─ export/ pdf, docx, html, image batch │ │
|
||||||
|
│ └────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Shared Infrastructure │ │
|
||||||
|
│ │ ├─ stores/ Zustand slices per feature │ │
|
||||||
|
│ │ ├─ hooks/ useFile, useEditor, useTheme, etc. │ │
|
||||||
|
│ │ ├─ lib/ cn, ipc, formatters, validators │ │
|
||||||
|
│ │ ├─ ui/ shadcn primitives: button, dialog… │ │
|
||||||
|
│ │ └─ types/ shared TS types │ │
|
||||||
|
│ └────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Architectural Rules
|
||||||
|
|
||||||
|
- **Shell components** (`AppHeader`, `TabBar`, `StatusBar`) subscribe only to `useAppStore`.
|
||||||
|
- **Feature components** subscribe to their own feature's store.
|
||||||
|
- **Cross-feature access** goes through hooks, not direct store imports (e.g., `useFileTree()` wraps `useFileStore`).
|
||||||
|
- **Feature modules are self-contained** — each owns its components, its Zustand slice, and its types. Safe to develop in parallel later.
|
||||||
|
- **No direct `window.electronAPI` calls** from feature code. All IPC goes through `lib/ipc.ts` for type safety and error normalization.
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
```
|
||||||
|
stores/
|
||||||
|
├─ useAppStore // global UI: theme, sidebar, pane sizes, modals open
|
||||||
|
├─ useFileStore // file system: tree, open files, active tab
|
||||||
|
├─ useEditorStore // editor: per-file content, cursor, selection, dirty
|
||||||
|
├─ usePreviewStore // preview: scroll sync, zoom, theme
|
||||||
|
├─ useSettingsStore // user prefs (persisted to electron-store)
|
||||||
|
└─ useCommandStore // menu actions registry, recent actions
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why slices instead of one mega-store?** Editor content can be megabytes. Keeping it in its own slice lets React avoid re-rendering the preview when only the editor changes — components subscribe with selectors.
|
||||||
|
|
||||||
|
**Persistent state** (auto-saved to electron-store via Zustand `persist` middleware): theme, sidebar visibility, last open files, pane divider positions, settings.
|
||||||
|
|
||||||
|
**Ephemeral state** (lost on quit): active modal, hover states, search query, current file content (saved to disk on idle).
|
||||||
|
|
||||||
|
## Visual Design System
|
||||||
|
|
||||||
|
### Color Tokens
|
||||||
|
|
||||||
|
The existing `globals.css` HSL variables stay. Additions for the "glassy" aesthetic:
|
||||||
|
|
||||||
|
```css
|
||||||
|
--shadow-sm: 0 1px 2px rgba(13, 11, 9, 0.06);
|
||||||
|
--shadow-md: 0 4px 12px rgba(13, 11, 9, 0.08), 0 0 0 1px rgba(13, 11, 9, 0.04);
|
||||||
|
--shadow-lg: 0 12px 32px rgba(13, 11, 9, 0.12), 0 0 0 1px rgba(13, 11, 9, 0.06);
|
||||||
|
--shadow-glow-brand: 0 0 24px rgba(229, 70, 31, 0.25);
|
||||||
|
|
||||||
|
--glass-bg-light: rgba(255, 255, 255, 0.72);
|
||||||
|
--glass-bg-dark: rgba(13, 11, 9, 0.72);
|
||||||
|
--glass-border-light: rgba(255, 255, 255, 0.4);
|
||||||
|
--glass-border-dark: rgba(255, 255, 255, 0.08);
|
||||||
|
```
|
||||||
|
|
||||||
|
### shadcn Components
|
||||||
|
|
||||||
|
Install via `npx shadcn@latest add`:
|
||||||
|
|
||||||
|
**Primitives** (need all of these): `button`, `dialog`, `sheet`, `popover`, `tooltip`, `select`, `dropdown-menu`, `tabs`, `separator`, `scroll-area`, `toggle`, `switch`, `slider`, `input`, `textarea`, `label`, `form`, `skeleton`, `sonner`, `command`.
|
||||||
|
|
||||||
|
**Composite** (custom-built on top of primitives): `file-tree`, `pane-group`, `divider`, `status-bar`, `menu-bar`, `toast`.
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
- Body: Plus Jakarta Sans 15 px / line-height 1.6
|
||||||
|
- Code: JetBrains Mono 13.5 px
|
||||||
|
- Display: Barlow Condensed 700/800 — used sparingly (page titles, big metrics)
|
||||||
|
- Headings: Plus Jakarta Sans 600/700 with tight letter-spacing
|
||||||
|
- Labels (`.label`): Plus Jakarta Sans 500, 12 px, uppercase, 0.05 em letter-spacing
|
||||||
|
|
||||||
|
### Motion Choreography
|
||||||
|
|
||||||
|
| Element | Motion | Duration | Easing |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Modal (Dialog A) | Scale 0.96→1, opacity 0→1 | 200 ms | `ease-out` |
|
||||||
|
| Side sheet (B) | TranslateX 100%→0, opacity 0→1 | 300 ms | `cubic-bezier(0.16, 1, 0.3, 1)` |
|
||||||
|
| Toast | TranslateY 100%→0, opacity | 250 ms | spring(stiffness: 300, damping: 30) |
|
||||||
|
| Sidebar toggle | Width 264 px↔72 px, content fade | 250 ms | `ease-in-out` |
|
||||||
|
| Divider drag | Live width update | 0 ms | none (instant) |
|
||||||
|
| Theme switch | CSS variables, opacity overlay | 300 ms | `ease-in-out` |
|
||||||
|
| Tab switch | Underline slide | 200 ms | `ease-out` |
|
||||||
|
| Hover (buttons) | `bg` + `shadow` shift | 150 ms | `ease-out` |
|
||||||
|
| Focus ring | Outline grow | 100 ms | `ease-out` |
|
||||||
|
|
||||||
|
**Rules:** Every motion has a purpose. No gratuitous animation. Respects `prefers-reduced-motion` (Motion handles this automatically).
|
||||||
|
|
||||||
|
### Empty / Loading / Error States
|
||||||
|
|
||||||
|
Every async surface ships all three. Sonner toasts for ephemeral feedback. Skeleton screens for content areas. Empty-state components with icon + message + primary CTA.
|
||||||
|
|
||||||
|
## Modal/Overlay Patterns
|
||||||
|
|
||||||
|
| Use case | Pattern | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Export (PDF/DOCX/HTML) | A — Centered Dialog | Focused decision, ~3–5 fields, "do one thing" |
|
||||||
|
| Settings | B — Right Side-Sheet | Long form, tabbed sections, stays open while editing |
|
||||||
|
| Find/Replace | Inline toolbar (not modal) | Always-visible utility |
|
||||||
|
| About | A — Centered Dialog | Tiny info dump |
|
||||||
|
| Confirm destructive (delete, close unsaved) | A — Centered Dialog | Forces attention |
|
||||||
|
| File save error | D — Toast | Non-blocking info |
|
||||||
|
| Pandoc/FFmpeg progress | D — Toast → sticky on >3 s | Background work feedback |
|
||||||
|
| Plugin manager | B — Right Side-Sheet | Lists + per-item actions |
|
||||||
|
| ASCII/Table generators | A — Centered Dialog | Tool-style focused input → output |
|
||||||
|
| Print preview | A — Centered Dialog | Full-screen-ish modal overlay |
|
||||||
|
| Welcome (first launch) | A — Centered Dialog | One-time onboarding |
|
||||||
|
| Word export | A — Centered Dialog | Template selection |
|
||||||
|
| Zen mode | Full-viewport toggle (no modal) | Replaces the editor entirely |
|
||||||
|
| REPL | Bottom-pinned panel (split-pane) | Persistent terminal-like UI |
|
||||||
|
| Quick file open | B — Right Side-Sheet | File tree, search, recent |
|
||||||
|
|
||||||
|
### Dialog A Anatomy (Export as example)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Backdrop (rgba(13,11,9,0.45) + backdrop-blur 4px) ─────────┐
|
||||||
|
│ │
|
||||||
|
│ ┌──── Modal (max-w-md, rounded-2xl, shadow-lg) ─────┐ │
|
||||||
|
│ │ ┌── Header ────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ [Icon] Export as PDF [×] │ │ │
|
||||||
|
│ │ │ Choose format options │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────┘ │ │
|
||||||
|
│ │ ┌── Body ──────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ Format: [Letter] [A4] [Legal] │ │ │
|
||||||
|
│ │ │ Margins: ────●──── │ │ │
|
||||||
|
│ │ │ ☐ Include table of contents │ │ │
|
||||||
|
│ │ │ ☐ Embed fonts │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────┘ │ │
|
||||||
|
│ │ ┌── Footer ────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ [Cancel] [Export →] │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────┘ │ │
|
||||||
|
│ └──────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Side-Sheet B Anatomy (Settings as example)
|
||||||
|
|
||||||
|
Slides in from right, ~50 % width (max 560 px). Backdrop is `rgba(13,11,9,0.2)` with `backdrop-blur(2 px)` (lighter than dialog — the sheet is the focus). Tab navigation in the sheet header (Editor / Theme / Export / Plugins / About).
|
||||||
|
|
||||||
|
### Toast D Anatomy (Sonner)
|
||||||
|
|
||||||
|
Stacked bottom-right, max 3 visible. Glass background. Color-coded 3 px left border — success `#1a7a56`, error `#ef4444`, info `#0ea5e9`, warning `#eab308`. Icon + title + description. Optional action button. Auto-dismiss 4 s for success, sticky for error.
|
||||||
|
|
||||||
|
## Data Flow (Editor → Preview Pipeline)
|
||||||
|
|
||||||
|
```
|
||||||
|
User types
|
||||||
|
↓
|
||||||
|
CodeMirror onChange
|
||||||
|
↓
|
||||||
|
useEditorStore.updateContent(tabId, content) ← debounced 50 ms
|
||||||
|
↓
|
||||||
|
┌─ Persist to electron-store on idle (1 s) ─┐
|
||||||
|
│ │
|
||||||
|
└─ Publish to usePreviewStore (subscribed) ─┘
|
||||||
|
↓
|
||||||
|
marked(content) → sanitized HTML
|
||||||
|
↓
|
||||||
|
PreviewPane renders HTML
|
||||||
|
KaTeX post-processes $...$
|
||||||
|
Mermaid post-processes ```mermaid
|
||||||
|
Highlight.js post-processes ```lang
|
||||||
|
↓
|
||||||
|
useEffect: scroll-sync editor cursor → preview position
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bidirectional scroll sync:** editor scroll → preview (primary), preview click → editor cursor (secondary). Throttled to 60 fps via `requestAnimationFrame`.
|
||||||
|
|
||||||
|
## IPC Contract
|
||||||
|
|
||||||
|
The preload bridge stays unchanged. A new `src/renderer/lib/ipc.ts` wraps every channel with TypeScript types and normalizes errors to a discriminated union:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type IpcResult<T> =
|
||||||
|
| { ok: true; data: T }
|
||||||
|
| { ok: false; error: { code: string; message: string } };
|
||||||
|
|
||||||
|
export const ipc = {
|
||||||
|
file: {
|
||||||
|
open: (): Promise<IpcResult<FileResult>>,
|
||||||
|
read: (path: string): Promise<IpcResult<string>>,
|
||||||
|
write: (path: string, content: string): Promise<IpcResult<void>>,
|
||||||
|
list: (dir: string): Promise<IpcResult<FileEntry[]>>,
|
||||||
|
onChange: (cb: (path: string) => void) => () => void,
|
||||||
|
},
|
||||||
|
export: {
|
||||||
|
pdf: (opts: PdfOptions): Promise<IpcResult<ExportResult>>,
|
||||||
|
docx: (opts: DocxOptions): Promise<IpcResult<ExportResult>>,
|
||||||
|
html: (opts: HtmlOptions): Promise<IpcResult<ExportResult>>,
|
||||||
|
batch: (items: BatchItem[], opts: BatchOptions): Promise<IpcResult<BatchResult>>,
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
getVersion: (): Promise<IpcResult<string>>,
|
||||||
|
openExternal: (url: string): Promise<IpcResult<void>>,
|
||||||
|
showItemInFolder: (path: string): Promise<IpcResult<void>>,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
Layered defense — no single failure can crash the app:
|
||||||
|
|
||||||
|
1. **IPC errors** → caught in `lib/ipc.ts` wrapper, returned as `IpcResult<T>` discriminated union. Components show toast on error.
|
||||||
|
2. **Component errors** → React error boundary per feature area (one for editor, one for preview, one for modals). On error: show inline error UI with "Reload this panel" + "Copy details" actions.
|
||||||
|
3. **Async operations** (export, file ops) → loading state on button + toast on completion. Long ops (>2 s) get a sticky toast with cancel.
|
||||||
|
4. **Validation errors** (settings, export options) → inline form errors via `react-hook-form` + `zod`. shadcn `Form` component for consistent error display.
|
||||||
|
5. **Pandoc/FFmpeg missing** → detected at startup, banner + disable export menu items. Don't surprise-fail at export time.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- **Unit (Vitest):** stores, hooks, lib utilities, formatters, validators
|
||||||
|
- **Component (Vitest + RTL):** shadcn wrappers, dialog interactions, sidebar toggle, divider drag, theme toggle
|
||||||
|
- **Integration (Vitest + RTL):** editor ↔ preview flow, file open → tab add → content display, settings change persists
|
||||||
|
- **E2E (Playwright):** app launches, file opens, markdown renders, export to PDF works
|
||||||
|
- **Visual regression (Playwright snapshots):** locked-in screenshots for header, sidebar, dialogs, toasts — catch accidental style drift
|
||||||
|
- **Target coverage:** stores/hooks/lib ≥ 90 %, components ≥ 75 %, E2E covers all critical paths
|
||||||
|
|
||||||
|
## Accessibility (WCAG 2.1 AA)
|
||||||
|
|
||||||
|
Baked in via shadcn + Radix:
|
||||||
|
|
||||||
|
- All dialogs focus-trap, Esc to close, return focus to trigger
|
||||||
|
- All interactive elements keyboard-reachable
|
||||||
|
- Visible focus rings (2 px brand ring)
|
||||||
|
- 4.5:1 contrast minimum (already met by brand colors)
|
||||||
|
- `aria-label` on icon buttons, `role="alert"` on toasts
|
||||||
|
- Respects `prefers-reduced-motion` (Motion handles this automatically)
|
||||||
|
|
||||||
|
## Implementation Phases (Vertical Slices)
|
||||||
|
|
||||||
|
Each phase is a shippable PR that keeps the app runnable. The legacy `renderer.js` is gradually replaced; until each phase is complete, the old renderer code still runs the missing parts.
|
||||||
|
|
||||||
|
| # | Phase | Output | Acceptance |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Foundation** | shadcn installed; `next-themes`, `motion`, `react-hook-form`, `zod`, `@dnd-kit/core`, `react-resizable-panels`, `sonner` installed; design tokens finalized; `lib/utils.ts` (`cn`), `lib/ipc.ts` typed wrappers, `lib/motion.ts` preset transitions, `App.tsx` shell skeleton renders | `npm run build` succeeds; dev server shows the new shell with theme toggle working |
|
||||||
|
| 2 | **App shell + layout** | `AppHeader`, `TabBar`, `Toolbar`, `Breadcrumb`, `StatusBar`, `ResizablePaneGroup` with sidebar toggle and draggable divider. Empty editor/preview panes. | Resize divider with mouse and arrow keys; sidebar collapses; pane sizes persist. |
|
||||||
|
| 3 | **Editor pane** | CodeMirror 6 wrapped, dark/light themes wired to shadcn theme, syntax highlighting, line numbers, search, autocomplete | Open `.md` file → renders in editor; can edit and save. |
|
||||||
|
| 4 | **Preview pane** | marked + DOMPurify + KaTeX + Mermaid + highlight.js. Bidirectional scroll sync with editor. | Open file → preview renders, follows cursor. |
|
||||||
|
| 5 | **File tree + tabs** | Sidebar file tree (read directory on demand, lazy-expand children only on click). Tabs for open files. Dirty state indicator. File content is read from disk only when the tab is activated. | Open folder → root populates; click folder → children load; click file → opens in tab; close tab reverts dirty. |
|
||||||
|
| 6 | **Native menus + toolbar** | Replace the legacy `CommandPalette` with full menus (File / Edit / View / Insert / Format / Tools / Help) bound to keyboard shortcuts. Toolbar buttons. | All menu items invoke the right action; shortcuts work; toolbar reflects active state. |
|
||||||
|
| 7 | **Modals** | Export dialog (PDF/DOCX/HTML/batch), Settings side-sheet (Editor/Theme/Export/Plugins/About tabs), About dialog, Confirm-destructive dialog | All dialogs and sheet render with proper motion; settings persist; export works end-to-end. |
|
||||||
|
| 8 | **Toasts** | Sonner wired into all async operations (save, export, errors, tool missing) | All operations give appropriate feedback; no silent failures. |
|
||||||
|
| 9 | **Advanced tools** | Zen mode (full-viewport toggle), REPL (bottom-pinned panel), ASCII generator, Table generator, Word export template picker, Print preview | Each tool is feature-complete vs. legacy version. |
|
||||||
|
| 10 | **Polish + delete legacy** | Visual regression snapshots locked; remove `renderer.js` and old `styles*.css` references from build; `npm run build:linux` produces a working installer | Final PR ships an installer with no legacy code paths. |
|
||||||
|
|
||||||
|
## Risks and Mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| shadcn CLI requires Vite (not vanilla Electron renderer) | Vite is already configured for the renderer in `vite.renderer.config.ts`; the CLI works against the same project. |
|
||||||
|
| shadcn CLI assumes Tailwind is at project root; we have it in renderer/ only | Point the CLI at `src/renderer/components.json` and set `tailwind.css` to the right path. |
|
||||||
|
| Marked + KaTeX + Mermaid + highlight.js together is heavy | Lazy-load Mermaid (only when `mermaid` code block encountered). KaTeX is loaded once, cached. |
|
||||||
|
| Editor content state can be megabytes → re-render storms | Editor content lives in its own slice; preview subscribes to a derived/cached HTML string; components use `useShallow` / selector subscriptions. |
|
||||||
|
| Bidirectional scroll sync loops | `useEffect` debounce + ignore if cursor/selection is the sync source. |
|
||||||
|
| CodeMirror 6 doesn't ship a Tailwind theme by default | Use `@codemirror/theme-one-dark` for dark; build a custom theme that pulls from CSS variables for light. |
|
||||||
|
| The legacy `renderer.js` and `styles*.css` are referenced from `index.html` (now `src/renderer/index.html`) | Phase 10 deletes the references; until then, both run side-by-side and the new React app sits in a known root div. |
|
||||||
|
| Electron `nodeIntegration` is off; we use contextBridge | Already the case. Document the contract clearly in `lib/ipc.ts`. |
|
||||||
|
|
||||||
|
## Open Questions (to confirm before implementation)
|
||||||
|
|
||||||
|
None. All major decisions are locked. Defaults will be used unless the user overrides them when reviewing the implementation plan.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- `src/renderer.js` (213 KB) — legacy renderer being replaced
|
||||||
|
- `src/styles.css` (74 KB), `src/styles-modern.css` (71 KB), `src/styles-concreteinfo.css` (21 KB) — legacy styles being replaced
|
||||||
|
- `src/renderer/styles/globals.css` — already shadcn-compatible (HSL CSS variables)
|
||||||
|
- `src/renderer/App.tsx` — empty skeleton that references components we will build
|
||||||
|
- `tailwind.config.js` — brand colors and fonts already defined
|
||||||
|
- `vite.renderer.config.ts` — Vite + React plugin already configured
|
||||||
|
- `package.json` — CodeMirror 6, TanStack Table, lucide-react, cva, clsx, tailwind-merge, tailwindcss-animate already installed
|
||||||
@@ -75,6 +75,7 @@ module.exports = [
|
|||||||
jest: 'readonly',
|
jest: 'readonly',
|
||||||
describe: 'readonly',
|
describe: 'readonly',
|
||||||
test: 'readonly',
|
test: 'readonly',
|
||||||
|
it: 'readonly',
|
||||||
expect: 'readonly',
|
expect: 'readonly',
|
||||||
beforeEach: 'readonly',
|
beforeEach: 'readonly',
|
||||||
afterEach: 'readonly',
|
afterEach: 'readonly',
|
||||||
|
|||||||
Generated
+15321
File diff suppressed because it is too large
Load Diff
+52
-21
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "markdown-converter",
|
"name": "markdown-converter",
|
||||||
"version": "4.1.0",
|
"version": "4.4.2",
|
||||||
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
|
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
|
||||||
"main": "src/main.js",
|
"main": "src/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,13 +14,16 @@
|
|||||||
"format:check": "prettier --check src tests",
|
"format:check": "prettier --check src tests",
|
||||||
"build": "electron-builder",
|
"build": "electron-builder",
|
||||||
"build:win": "electron-builder --win",
|
"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",
|
"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:mac": "electron-builder --mac",
|
||||||
"build:linux": "electron-builder --linux",
|
"build:linux": "electron-builder --linux",
|
||||||
|
"build:linux-ci": "electron-builder --linux deb AppImage",
|
||||||
"build:local": "electron-builder --linux --win",
|
"build:local": "electron-builder --linux --win",
|
||||||
"dist": "electron-builder --publish=never",
|
"dist": "electron-builder --publish=never",
|
||||||
"dist:all": "electron-builder -mwl",
|
"dist:all": "electron-builder -mwl",
|
||||||
|
"download-tools": "node scripts/download-tools.js",
|
||||||
"generate-icons": "node scripts/generate-icons.js"
|
"generate-icons": "node scripts/generate-icons.js"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -41,16 +44,29 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@types/react": "^19.2.16",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
|
"autoprefixer": "^10.5.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"cross-env": "^10.0.0",
|
"cross-env": "^10.0.0",
|
||||||
"electron": "^37.4.0",
|
"electron": "^41.1.1",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
|
"lucide-react": "^1.17.0",
|
||||||
|
"postcss": "^8.5.15",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"sharp": "^0.34.3"
|
"sharp": "^0.34.3",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.0.16"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.20.1",
|
"@codemirror/autocomplete": "^6.20.1",
|
||||||
@@ -67,12 +83,14 @@
|
|||||||
"@codemirror/state": "^6.5.4",
|
"@codemirror/state": "^6.5.4",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.39.16",
|
"@codemirror/view": "^6.39.16",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"core-util-is": "^1.0.3",
|
"core-util-is": "^1.0.3",
|
||||||
"docx": "^9.6.0",
|
"docx": "^9.6.0",
|
||||||
"docx4js": "^3.3.0",
|
"docx4js": "^2.0.1",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"electron-store": "^10.1.0",
|
"electron-store": "^10.1.0",
|
||||||
|
"ffmpeg-static": "^5.3.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"html2pdf.js": "^0.14.0",
|
"html2pdf.js": "^0.14.0",
|
||||||
"marked": "^17.0.3",
|
"marked": "^17.0.3",
|
||||||
@@ -83,17 +101,23 @@
|
|||||||
"pdfjs-dist": "^5.5.207",
|
"pdfjs-dist": "^5.5.207",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
"pizzip": "^3.2.0",
|
"pizzip": "^3.2.0",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
"simple-git": "^3.32.3",
|
"simple-git": "^3.32.3",
|
||||||
"tslib": "^2.8.1"
|
"tslib": "^2.8.1",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"nth-check": "^2.1.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": {
|
"build": {
|
||||||
"appId": "com.concreteinfo.markdownconverter",
|
"appId": "com.concreteinfo.markdownconverter",
|
||||||
"productName": "MarkdownConverter",
|
"productName": "MarkdownConverter",
|
||||||
|
"copyright": "Copyright (C) 2024-2025 ConcreteInfo",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist"
|
"output": "dist"
|
||||||
},
|
},
|
||||||
@@ -105,6 +129,10 @@
|
|||||||
"node_modules/**/*",
|
"node_modules/**/*",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
|
"asarUnpack": [
|
||||||
|
"node_modules/ffmpeg-static/**"
|
||||||
|
],
|
||||||
|
"extraFiles": [],
|
||||||
"fileAssociations": [
|
"fileAssociations": [
|
||||||
{
|
{
|
||||||
"ext": "md",
|
"ext": "md",
|
||||||
@@ -155,7 +183,15 @@
|
|||||||
],
|
],
|
||||||
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
||||||
"requestedExecutionLevel": "asInvoker",
|
"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": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
@@ -177,29 +213,24 @@
|
|||||||
"target": [
|
"target": [
|
||||||
"deb",
|
"deb",
|
||||||
"AppImage",
|
"AppImage",
|
||||||
"snap",
|
"snap"
|
||||||
"rpm"
|
|
||||||
],
|
],
|
||||||
"category": "Utility",
|
"category": "Utility",
|
||||||
"maintainer": "ConcreteInfo <amit.wh@gmail.com>"
|
"maintainer": "ConcreteInfo <amit.wh@gmail.com>",
|
||||||
|
"extraFiles": [
|
||||||
|
{
|
||||||
|
"from": "bin/linux/pandoc",
|
||||||
|
"to": "bin/pandoc"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"deb": {
|
"deb": {
|
||||||
"depends": [
|
"depends": [
|
||||||
"pandoc",
|
|
||||||
"ffmpeg",
|
|
||||||
"imagemagick",
|
"imagemagick",
|
||||||
"libreoffice-common"
|
"libreoffice-common"
|
||||||
],
|
],
|
||||||
"description": "Professional Markdown editor and universal file converter",
|
"description": "Professional Markdown editor and universal file converter",
|
||||||
"maintainer": "ConcreteInfo <amit.wh@gmail.com>"
|
"maintainer": "ConcreteInfo <amit.wh@gmail.com>"
|
||||||
},
|
|
||||||
"rpm": {
|
|
||||||
"depends": [
|
|
||||||
"pandoc",
|
|
||||||
"ffmpeg",
|
|
||||||
"ImageMagick",
|
|
||||||
"libreoffice-core"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
+21
-17
@@ -4,7 +4,7 @@
|
|||||||
* Implements file system operations for Electron using IPC.
|
* Implements file system operations for Electron using IPC.
|
||||||
* This abstracts file operations to enable easier testing and migration.
|
* This abstracts file operations to enable easier testing and migration.
|
||||||
*
|
*
|
||||||
* @version 4.1.0
|
* @version 4.4.1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,7 +18,7 @@ const electronFsAdapter = {
|
|||||||
* @returns {Promise<string>} File content
|
* @returns {Promise<string>} File content
|
||||||
*/
|
*/
|
||||||
async readFile(path) {
|
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>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async writeFile(path, content) {
|
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>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async deleteFile(path) {
|
async deleteFile(path) {
|
||||||
// TODO: Add IPC channel for delete
|
return await window.electronAPI.file.delete(path);
|
||||||
throw new Error('deleteFile not implemented');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,8 +46,7 @@ const electronFsAdapter = {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async ensureDir(path) {
|
async ensureDir(path) {
|
||||||
// TODO: Add IPC channel for ensureDir
|
return await window.electronAPI.file.ensureDir(path);
|
||||||
throw new Error('ensureDir not implemented');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,8 +55,18 @@ const electronFsAdapter = {
|
|||||||
* @returns {Promise<Array<import('../types').FileInfo>>}
|
* @returns {Promise<Array<import('../types').FileInfo>>}
|
||||||
*/
|
*/
|
||||||
async listDirectory(path) {
|
async listDirectory(path) {
|
||||||
// TODO: Add IPC channel for listDirectory
|
const result = await window.electronAPI.invoke('list-directory', path);
|
||||||
throw new Error('listDirectory not implemented');
|
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>}
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
async exists(path) {
|
async exists(path) {
|
||||||
// TODO: Add IPC channel for exists
|
return await window.electronAPI.file.exists(path);
|
||||||
throw new Error('exists not implemented');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,8 +84,7 @@ const electronFsAdapter = {
|
|||||||
* @returns {Promise<boolean>}
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
async isDirectory(path) {
|
async isDirectory(path) {
|
||||||
// TODO: Add IPC channel for isDirectory
|
return await window.electronAPI.file.isDirectory(path);
|
||||||
throw new Error('isDirectory not implemented');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,8 +94,7 @@ const electronFsAdapter = {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async copy(source, dest) {
|
async copy(source, dest) {
|
||||||
// TODO: Add IPC channel for copy
|
return await window.electronAPI.file.copy(source, dest);
|
||||||
throw new Error('copy not implemented');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,8 +104,7 @@ const electronFsAdapter = {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async move(source, dest) {
|
async move(source, dest) {
|
||||||
// TODO: Add IPC channel for move
|
return await window.electronAPI.file.move(source, dest);
|
||||||
throw new Error('move not implemented');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* Adapters abstract file system, conversion, and system operations
|
* Adapters abstract file system, conversion, and system operations
|
||||||
* to enable easier testing and future platform migration.
|
* to enable easier testing and future platform migration.
|
||||||
*
|
*
|
||||||
* @version 4.1.0
|
* @version 4.4.1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -59,6 +59,11 @@ const jetBrainsMonoTheme = EditorView.theme({
|
|||||||
* @returns {EditorView} the created editor view
|
* @returns {EditorView} the created editor view
|
||||||
*/
|
*/
|
||||||
function createEditor(parentElement, options = {}) {
|
function createEditor(parentElement, options = {}) {
|
||||||
|
console.log('[createEditor] Called with parentElement:', parentElement?.id, 'dimensions:', parentElement?.clientWidth, 'x', parentElement?.clientHeight);
|
||||||
|
if (!parentElement) {
|
||||||
|
console.error('[createEditor] ERROR: parentElement is null or undefined!');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const {
|
const {
|
||||||
content = '',
|
content = '',
|
||||||
onChange = () => {},
|
onChange = () => {},
|
||||||
|
|||||||
+74
-3
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<!-- CSP: unsafe-inline/unsafe-eval required for marked.js extensions and Mermaid -->
|
<!-- CSP: unsafe-inline/unsafe-eval required for marked.js extensions and Mermaid -->
|
||||||
<!-- TODO: Migrate to nonce-based CSP for better security -->
|
<!-- TODO: Migrate to nonce-based CSP for better security -->
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://www.plantuml.com;">
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: blob:; font-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self' https://www.plantuml.com;">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>MarkdownConverter</title>
|
<title>MarkdownConverter</title>
|
||||||
<!-- Design tokens - loaded first for CSS variable availability -->
|
<!-- Design tokens - loaded first for CSS variable availability -->
|
||||||
@@ -15,7 +15,9 @@
|
|||||||
<link rel="stylesheet" href="styles-modern.css">
|
<link rel="stylesheet" href="styles-modern.css">
|
||||||
<link rel="stylesheet" href="styles-concreteinfo.css">
|
<link rel="stylesheet" href="styles-concreteinfo.css">
|
||||||
<link rel="stylesheet" href="styles-sidebar.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-welcome.css">
|
||||||
|
<link rel="stylesheet" href="styles-zen.css">
|
||||||
<link rel="stylesheet" href="../node_modules/highlight.js/styles/default.css">
|
<link rel="stylesheet" href="../node_modules/highlight.js/styles/default.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -29,7 +31,7 @@
|
|||||||
<span class="app-title">MarkdownConverter</span>
|
<span class="app-title">MarkdownConverter</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="app-header-right">
|
<div class="app-header-right">
|
||||||
<span class="app-version">v4.1.0</span>
|
<span class="app-version" id="app-version-display"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-bar" id="tab-bar" role="tablist" aria-label="Document tabs">
|
<div class="tab-bar" id="tab-bar" role="tablist" aria-label="Document tabs">
|
||||||
@@ -286,6 +288,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="export-section revealjs-only" style="display: none;">
|
||||||
|
<label>Reveal.js Options:</label>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="reveal-theme">Slide Theme:</label>
|
||||||
|
<select id="reveal-theme">
|
||||||
|
<option value="black">Black</option>
|
||||||
|
<option value="white">White</option>
|
||||||
|
<option value="league">League</option>
|
||||||
|
<option value="beige">Beige</option>
|
||||||
|
<option value="sky">Sky</option>
|
||||||
|
<option value="night">Night</option>
|
||||||
|
<option value="serif">Serif</option>
|
||||||
|
<option value="simple">Simple</option>
|
||||||
|
<option value="solarized">Solarized</option>
|
||||||
|
<option value="blood">Blood</option>
|
||||||
|
<option value="moon">Moon</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="reveal-transition">Transition:</label>
|
||||||
|
<select id="reveal-transition">
|
||||||
|
<option value="slide">Slide</option>
|
||||||
|
<option value="none">None</option>
|
||||||
|
<option value="fade">Fade</option>
|
||||||
|
<option value="convex">Convex</option>
|
||||||
|
<option value="concave">Concave</option>
|
||||||
|
<option value="zoom">Zoom</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="reveal-speed">Transition Speed:</label>
|
||||||
|
<select id="reveal-speed">
|
||||||
|
<option value="default">Default</option>
|
||||||
|
<option value="fast">Fast</option>
|
||||||
|
<option value="slow">Slow</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label><input type="checkbox" id="reveal-slide-number"> Slide Numbers</label>
|
||||||
|
<label><input type="checkbox" id="reveal-controls" checked> Controls</label>
|
||||||
|
<label><input type="checkbox" id="reveal-progress" checked> Progress Bar</label>
|
||||||
|
<label><input type="checkbox" id="reveal-history" checked> Slide History</label>
|
||||||
|
<label><input type="checkbox" id="reveal-center" checked> Center Content</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="export-section">
|
<div class="export-section">
|
||||||
<label>Bibliography:</label>
|
<label>Bibliography:</label>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
@@ -903,6 +951,7 @@
|
|||||||
<button class="modal-close" id="pdf-editor-dialog-close" aria-label="Close">×</button>
|
<button class="modal-close" id="pdf-editor-dialog-close" aria-label="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body pdf-editor-body">
|
<div class="modal-body pdf-editor-body">
|
||||||
|
<div class="pdf-editor-left-panel">
|
||||||
<!-- Merge PDFs Section -->
|
<!-- Merge PDFs Section -->
|
||||||
<div id="pdf-merge-section" class="pdf-operation-section hidden">
|
<div id="pdf-merge-section" class="pdf-operation-section hidden">
|
||||||
<div class="export-section">
|
<div class="export-section">
|
||||||
@@ -1273,6 +1322,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="pdf-status-message" class="info-message hidden" aria-live="polite"></div>
|
||||||
|
|
||||||
<!-- Progress indicator -->
|
<!-- Progress indicator -->
|
||||||
<div id="pdf-progress" class="batch-progress hidden">
|
<div id="pdf-progress" class="batch-progress hidden">
|
||||||
<div class="progress-bar">
|
<div class="progress-bar">
|
||||||
@@ -1283,6 +1334,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Panel: Scrollable Visual Page Sidebar -->
|
||||||
|
<div id="pdf-thumbnail-sidebar" class="pdf-editor-right-panel hidden">
|
||||||
|
<h4>Page Preview & Editor</h4>
|
||||||
|
<p class="sidebar-help">Select a PDF to view pages, check to delete, click ↻ to rotate, and use ◀ / ▶ to reorder.</p>
|
||||||
|
<div id="pdf-thumbnail-grid" class="pdf-thumbnail-grid">
|
||||||
|
<!-- Page thumbnail cards will be rendered here dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button id="pdf-editor-cancel" class="btn btn-secondary" data-close>Cancel</button>
|
<button id="pdf-editor-cancel" class="btn btn-secondary" data-close>Cancel</button>
|
||||||
<button id="pdf-editor-process" class="btn btn-primary">Process</button>
|
<button id="pdf-editor-process" class="btn btn-primary">Process</button>
|
||||||
@@ -1437,6 +1498,16 @@
|
|||||||
<line x1="9" y1="21" x2="9" y2="9"/>
|
<line x1="9" y1="21" x2="9" y2="9"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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>
|
||||||
<div class="sidebar-panel" id="sidebar-panel">
|
<div class="sidebar-panel" id="sidebar-panel">
|
||||||
<div class="sidebar-panel-header">
|
<div class="sidebar-panel-header">
|
||||||
@@ -1593,4 +1664,4 @@
|
|||||||
<script src="utils/ModalManager.js"></script>
|
<script src="utils/ModalManager.js"></script>
|
||||||
<script src="renderer.js"></script>
|
<script src="renderer.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+286
-517
@@ -2,8 +2,9 @@ const { app, BrowserWindow, Menu, dialog, ipcMain, shell } = require('electron')
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { exec, execFile, spawn } = require('child_process');
|
const { exec, execFile, spawn } = require('child_process');
|
||||||
const { PDFDocument, rgb, degrees, StandardFonts } = require('pdf-lib');
|
|
||||||
const WordTemplateExporter = require('./wordTemplateExporter');
|
const WordTemplateExporter = require('./wordTemplateExporter');
|
||||||
|
const PDFOperations = require('./main/PDFOperations');
|
||||||
|
const GitOperations = require('./main/GitOperations');
|
||||||
|
|
||||||
// Add MiKTeX to PATH for LaTeX support
|
// Add MiKTeX to PATH for LaTeX support
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
@@ -14,13 +15,39 @@ if (process.platform === 'win32') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the system Pandoc path
|
// Returns path to pandoc: bundled binary when packaged, dev bin or system fallback otherwise.
|
||||||
function getPandocPath() {
|
function getPandocPath() {
|
||||||
// Pandoc is expected to be in the system's PATH.
|
if (app.isPackaged) {
|
||||||
// The command will be executed directly. Quoting is handled by exec.
|
const ext = process.platform === 'win32' ? '.exe' : '';
|
||||||
|
return path.join(process.resourcesPath, 'bin', `pandoc${ext}`);
|
||||||
|
}
|
||||||
|
// Development: prefer locally-downloaded binary in bin/<platform>/
|
||||||
|
const devBin = path.join(
|
||||||
|
__dirname,
|
||||||
|
'..',
|
||||||
|
'bin',
|
||||||
|
process.platform,
|
||||||
|
process.platform === 'win32' ? 'pandoc.exe' : 'pandoc',
|
||||||
|
);
|
||||||
|
if (fs.existsSync(devBin)) return devBin;
|
||||||
return 'pandoc';
|
return 'pandoc';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns path to ffmpeg: asar-unpacked bundled binary when packaged, system fallback otherwise.
|
||||||
|
function getFFmpegPath() {
|
||||||
|
try {
|
||||||
|
let ffmpegPath = require('ffmpeg-static');
|
||||||
|
if (app.isPackaged) {
|
||||||
|
// ffmpeg-static is in asarUnpack — rewrite the path to the unpacked location
|
||||||
|
ffmpegPath = ffmpegPath.replace('app.asar' + path.sep, 'app.asar.unpacked' + path.sep);
|
||||||
|
}
|
||||||
|
if (fs.existsSync(ffmpegPath)) return ffmpegPath;
|
||||||
|
} catch (_) {
|
||||||
|
/* ffmpeg-static not available */
|
||||||
|
}
|
||||||
|
return process.platform === 'win32' ? 'ffmpeg' : 'ffmpeg';
|
||||||
|
}
|
||||||
|
|
||||||
// Check if Pandoc is available
|
// Check if Pandoc is available
|
||||||
function checkPandocAvailable() {
|
function checkPandocAvailable() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -124,6 +151,35 @@ function validatePath(filePath) {
|
|||||||
return { valid: true, resolved };
|
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
|
* Checks if a resolved path is within allowed directories
|
||||||
* For an editor app, we allow access to all user-accessible paths
|
* For an editor app, we allow access to all user-accessible paths
|
||||||
@@ -186,7 +242,9 @@ function runPandoc(args, callback) {
|
|||||||
function runPandocCmd(cmdString, callback) {
|
function runPandocCmd(cmdString, callback) {
|
||||||
const parsed = parseCommand(cmdString);
|
const parsed = parseCommand(cmdString);
|
||||||
// Skip 'pandoc' if it's the first element (command itself)
|
// Skip 'pandoc' if it's the first element (command itself)
|
||||||
const args = parsed.command === 'pandoc' ? parsed.args : [parsed.command, ...parsed.args];
|
// Use path.basename to handle full paths like bin/linux/pandoc
|
||||||
|
const cmdBase = path.basename(parsed.command).replace(/\.exe$/i, '');
|
||||||
|
const args = cmdBase === 'pandoc' ? parsed.args : [parsed.command, ...parsed.args];
|
||||||
const pandocPath = getPandocPath();
|
const pandocPath = getPandocPath();
|
||||||
execFile(pandocPath, args, { maxBuffer: 10 * 1024 * 1024 }, callback);
|
execFile(pandocPath, args, { maxBuffer: 10 * 1024 * 1024 }, callback);
|
||||||
}
|
}
|
||||||
@@ -254,6 +312,41 @@ const store = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Plugin settings IPC handlers
|
||||||
|
ipcMain.handle('plugin-settings:get', (_event, key) => {
|
||||||
|
return store.get(key);
|
||||||
|
});
|
||||||
|
ipcMain.handle('plugin-settings:set', (_event, { key, value }) => {
|
||||||
|
store.set(key, value);
|
||||||
|
});
|
||||||
|
ipcMain.handle('get-app-version', () => app.getVersion());
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SECURITY: Permission Request Handler
|
||||||
|
// ============================================
|
||||||
|
// Restrict permissions to only those explicitly needed.
|
||||||
|
// Deny: camera, microphone, geolocation, notifications, etc.
|
||||||
|
// Allow: clipboard-read, clipboard-write (user expects these)
|
||||||
|
app.on('web-contents-created', (event, contents) => {
|
||||||
|
contents.session.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||||
|
const ALLOWED_PERMISSIONS = [
|
||||||
|
'clipboard-read',
|
||||||
|
'clipboard-write'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (ALLOWED_PERMISSIONS.includes(permission)) {
|
||||||
|
callback(true);
|
||||||
|
} else {
|
||||||
|
// Deny all other permissions (camera, microphone, geolocation, etc.)
|
||||||
|
console.warn(`[Security] Blocked permission request: ${permission}`);
|
||||||
|
callback(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disable file download dialogs for security
|
||||||
|
// contents.session.setDownloadPath(userDownloadsPath);
|
||||||
|
});
|
||||||
|
|
||||||
let mainWindow;
|
let mainWindow;
|
||||||
let currentFile = null; // This will now represent the active tab's file
|
let currentFile = null; // This will now represent the active tab's file
|
||||||
let pandocAvailable = null; // Cache pandoc availability check
|
let pandocAvailable = null; // Cache pandoc availability check
|
||||||
@@ -758,6 +851,20 @@ function createMenu() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Custom Preview CSS',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Load Custom Preview CSS...',
|
||||||
|
click: () => mainWindow.webContents.send('load-custom-css')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Clear Custom Preview CSS',
|
||||||
|
click: () => mainWindow.webContents.send('clear-custom-css')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
{ label: 'Reload', accelerator: 'CmdOrCtrl+R', role: 'reload' },
|
{ label: 'Reload', accelerator: 'CmdOrCtrl+R', role: 'reload' },
|
||||||
{ label: 'Toggle DevTools', accelerator: 'F12', role: 'toggleDevTools' },
|
{ label: 'Toggle DevTools', accelerator: 'F12', role: 'toggleDevTools' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
@@ -1000,7 +1107,7 @@ function showAboutDialog() {
|
|||||||
<body>
|
<body>
|
||||||
<img src="${iconBase64}" class="logo" alt="MarkdownConverter">
|
<img src="${iconBase64}" class="logo" alt="MarkdownConverter">
|
||||||
<h1>MarkdownConverter</h1>
|
<h1>MarkdownConverter</h1>
|
||||||
<div class="version">Version 4.0.0</div>
|
<div class="version">Version ${app.getVersion()}</div>
|
||||||
|
|
||||||
<div class="company">
|
<div class="company">
|
||||||
<span>by</span>
|
<span>by</span>
|
||||||
@@ -1804,9 +1911,12 @@ function checkConverterAvailable(tool) {
|
|||||||
case 'imagemagick':
|
case 'imagemagick':
|
||||||
toolName = isWin ? 'magick' : 'convert';
|
toolName = isWin ? 'magick' : 'convert';
|
||||||
break;
|
break;
|
||||||
case 'ffmpeg':
|
case 'ffmpeg': {
|
||||||
toolName = 'ffmpeg';
|
// ffmpeg-static is always bundled — check it directly
|
||||||
break;
|
const ffmpegBin = getFFmpegPath();
|
||||||
|
resolve(fs.existsSync(ffmpegBin) || ffmpegBin === 'ffmpeg');
|
||||||
|
return;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
resolve(false);
|
resolve(false);
|
||||||
return;
|
return;
|
||||||
@@ -2033,8 +2143,8 @@ function convertWithImageMagick(inputFile, outputPath) {
|
|||||||
// FFmpeg conversion - returns {command, args} for execFile
|
// FFmpeg conversion - returns {command, args} for execFile
|
||||||
function convertWithFFmpeg(inputFile, outputPath) {
|
function convertWithFFmpeg(inputFile, outputPath) {
|
||||||
return {
|
return {
|
||||||
command: 'ffmpeg',
|
command: getFFmpegPath(),
|
||||||
args: ['-i', inputFile, outputPath, '-y']
|
args: ['-i', inputFile, outputPath, '-y'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2203,8 +2313,32 @@ function performExportWithOptions(format, options) {
|
|||||||
dialog.showErrorBox('Export Error', sanitizeErrorMessage(`Failed to export: ${err.message}`));
|
dialog.showErrorBox('Export Error', sanitizeErrorMessage(`Failed to export: ${err.message}`));
|
||||||
}
|
}
|
||||||
} else if (format === 'revealjs') {
|
} else if (format === 'revealjs') {
|
||||||
pandocCmd = `${getPandocPath()} "${currentFile}" -t revealjs -s -o "${outputFile}" --slide-level=2`;
|
let revealCmd = `${getPandocPath()} "${currentFile}" -t revealjs -s -o "${outputFile}" --slide-level=2`;
|
||||||
exportWithPandoc(pandocCmd, outputFile, format);
|
if (options) {
|
||||||
|
if (options.revealTheme) revealCmd += ` -V theme="${options.revealTheme}"`;
|
||||||
|
if (options.revealTransition) revealCmd += ` -V transition="${options.revealTransition}"`;
|
||||||
|
if (options.revealTransitionSpeed) revealCmd += ` -V transitionSpeed="${options.revealTransitionSpeed}"`;
|
||||||
|
if (options.revealControls !== undefined) revealCmd += ` -V controls="${options.revealControls}"`;
|
||||||
|
if (options.revealSlideNumber !== undefined) revealCmd += ` -V slideNumber="${options.revealSlideNumber}"`;
|
||||||
|
if (options.revealProgress !== undefined) revealCmd += ` -V progress="${options.revealProgress}"`;
|
||||||
|
if (options.revealHistory !== undefined) revealCmd += ` -V history="${options.revealHistory}"`;
|
||||||
|
if (options.revealCenter !== undefined) revealCmd += ` -V center="${options.revealCenter}"`;
|
||||||
|
|
||||||
|
// Support for templates, metadata, bibliography
|
||||||
|
if (options.template && options.template !== 'default') {
|
||||||
|
revealCmd += ` --template="${options.template}"`;
|
||||||
|
}
|
||||||
|
if (options.metadata) {
|
||||||
|
for (const [key, value] of Object.entries(options.metadata)) {
|
||||||
|
if (value.trim()) {
|
||||||
|
revealCmd += ` -M ${key}="${value.replace(/"/g, '\\"')}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (options.bibliography) revealCmd += ` --bibliography="${options.bibliography}"`;
|
||||||
|
if (options.csl) revealCmd += ` --csl="${options.csl}"`;
|
||||||
|
}
|
||||||
|
exportWithPandoc(revealCmd, outputFile, format);
|
||||||
} else if (format === 'beamer') {
|
} else if (format === 'beamer') {
|
||||||
pandocCmd = `${getPandocPath()} "${currentFile}" -t beamer -o "${outputFile}"`;
|
pandocCmd = `${getPandocPath()} "${currentFile}" -t beamer -o "${outputFile}"`;
|
||||||
exportWithPandoc(pandocCmd, outputFile, format);
|
exportWithPandoc(pandocCmd, outputFile, format);
|
||||||
@@ -2793,9 +2927,13 @@ ipcMain.on('save-file', (event, { path, content }) => {
|
|||||||
currentFile = path;
|
currentFile = path;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('save-current-file', (event, content) => {
|
ipcMain.on('save-current-file', (event, payload) => {
|
||||||
if (currentFile) {
|
const content = typeof payload === 'string' ? payload : payload?.content;
|
||||||
fs.writeFileSync(currentFile, content, 'utf-8');
|
const targetFile = typeof payload === 'string' ? currentFile : payload?.filePath || currentFile;
|
||||||
|
|
||||||
|
if (targetFile) {
|
||||||
|
fs.writeFileSync(targetFile, content, 'utf-8');
|
||||||
|
currentFile = targetFile;
|
||||||
} else {
|
} else {
|
||||||
saveAsFile();
|
saveAsFile();
|
||||||
}
|
}
|
||||||
@@ -3661,7 +3799,7 @@ app.on('open-file', (event, filePath) => {
|
|||||||
// Handle file opening from command line or file association
|
// Handle file opening from command line or file association
|
||||||
function openFileFromPath(filePath) {
|
function openFileFromPath(filePath) {
|
||||||
console.log('[MAIN] openFileFromPath called with:', filePath);
|
console.log('[MAIN] openFileFromPath called with:', filePath);
|
||||||
console.log('[MAIN] rendererReady:', rendererReady, 'mainWindow exists:', !!mainWindow);
|
console.log('[MAIN] rendererReady:', rendererReady, 'mainWindow exists:', !!mainWindow, 'webContents exists:', !!(mainWindow?.webContents));
|
||||||
if (fs.existsSync(filePath)) {
|
if (fs.existsSync(filePath)) {
|
||||||
const stats = fs.statSync(filePath);
|
const stats = fs.statSync(filePath);
|
||||||
if (stats.size > MAX_FILE_SIZE) {
|
if (stats.size > MAX_FILE_SIZE) {
|
||||||
@@ -3671,6 +3809,7 @@ function openFileFromPath(filePath) {
|
|||||||
currentFile = filePath;
|
currentFile = filePath;
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
console.log('[MAIN] File read successfully, content length:', content.length);
|
console.log('[MAIN] File read successfully, content length:', content.length);
|
||||||
|
console.log('[MAIN] About to send file-opened. mainWindow:', !!mainWindow, 'webContents:', !!(mainWindow?.webContents), 'rendererReady:', rendererReady);
|
||||||
if (mainWindow && mainWindow.webContents && rendererReady) {
|
if (mainWindow && mainWindow.webContents && rendererReady) {
|
||||||
// Send file immediately - renderer-ready means UI is initialized
|
// Send file immediately - renderer-ready means UI is initialized
|
||||||
console.log('[MAIN] Sending file-opened to renderer');
|
console.log('[MAIN] Sending file-opened to renderer');
|
||||||
@@ -3686,433 +3825,9 @@ function openFileFromPath(filePath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// PDF EDITOR OPERATIONS (using pdf-lib)
|
// PDF OPERATIONS — delegates to main/PDFOperations.js
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
// Helper function to parse page ranges (e.g., "1-5, 7, 9-12")
|
|
||||||
function parsePageRanges(rangeString, totalPages) {
|
|
||||||
const pages = [];
|
|
||||||
const ranges = rangeString.split(',').map(r => r.trim());
|
|
||||||
|
|
||||||
for (const range of ranges) {
|
|
||||||
if (range.includes('-')) {
|
|
||||||
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
|
||||||
for (let i = start; i <= end && i <= totalPages; i++) {
|
|
||||||
if (i > 0 && !pages.includes(i - 1)) { // Convert to 0-indexed
|
|
||||||
pages.push(i - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const page = parseInt(range);
|
|
||||||
if (page > 0 && page <= totalPages && !pages.includes(page - 1)) {
|
|
||||||
pages.push(page - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return pages.sort((a, b) => a - b);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to convert hex color to RGB
|
|
||||||
function hexToRgb(hex) {
|
|
||||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
||||||
return result ? {
|
|
||||||
r: parseInt(result[1], 16) / 255,
|
|
||||||
g: parseInt(result[2], 16) / 255,
|
|
||||||
b: parseInt(result[3], 16) / 255
|
|
||||||
} : { r: 0, g: 0, b: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF Merge Operation
|
|
||||||
async function pdfMerge(data) {
|
|
||||||
try {
|
|
||||||
const mergedPdf = await PDFDocument.create();
|
|
||||||
|
|
||||||
for (const filePath of data.inputFiles) {
|
|
||||||
const pdfBytes = fs.readFileSync(filePath);
|
|
||||||
const pdf = await PDFDocument.load(pdfBytes);
|
|
||||||
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
|
|
||||||
copiedPages.forEach(page => mergedPdf.addPage(page));
|
|
||||||
}
|
|
||||||
|
|
||||||
const pdfBytes = await mergedPdf.save();
|
|
||||||
fs.writeFileSync(data.outputPath, pdfBytes);
|
|
||||||
|
|
||||||
return { success: true, message: `Successfully merged ${data.inputFiles.length} PDFs` };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF Split Operation
|
|
||||||
async function pdfSplit(data) {
|
|
||||||
try {
|
|
||||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
|
||||||
const pdf = await PDFDocument.load(pdfBytes);
|
|
||||||
const totalPages = pdf.getPageCount();
|
|
||||||
|
|
||||||
let splits = [];
|
|
||||||
|
|
||||||
if (data.splitMode === 'pages') {
|
|
||||||
// Split by page ranges
|
|
||||||
const ranges = data.pageRanges.split(',').map(r => r.trim());
|
|
||||||
for (let i = 0; i < ranges.length; i++) {
|
|
||||||
const range = ranges[i];
|
|
||||||
let pages = [];
|
|
||||||
|
|
||||||
if (range.includes('-')) {
|
|
||||||
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
|
||||||
for (let p = start; p <= end && p <= totalPages; p++) {
|
|
||||||
pages.push(p - 1);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const page = parseInt(range);
|
|
||||||
if (page > 0 && page <= totalPages) {
|
|
||||||
pages.push(page - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pages.length > 0) {
|
|
||||||
splits.push({ pages, name: `part_${i + 1}` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (data.splitMode === 'interval') {
|
|
||||||
// Split every N pages
|
|
||||||
const interval = data.interval;
|
|
||||||
for (let i = 0; i < totalPages; i += interval) {
|
|
||||||
const pages = [];
|
|
||||||
for (let j = i; j < i + interval && j < totalPages; j++) {
|
|
||||||
pages.push(j);
|
|
||||||
}
|
|
||||||
splits.push({ pages, name: `part_${Math.floor(i / interval) + 1}` });
|
|
||||||
}
|
|
||||||
} else if (data.splitMode === 'size') {
|
|
||||||
// Split by size (approximate - we'll split evenly)
|
|
||||||
// This is complex, so we'll do a simple even split for now
|
|
||||||
const maxSize = data.maxSize * 1024 * 1024; // Convert MB to bytes
|
|
||||||
// For simplicity, split into fixed page chunks
|
|
||||||
const chunkSize = Math.max(1, Math.floor(totalPages / 5)); // Split into ~5 parts
|
|
||||||
for (let i = 0; i < totalPages; i += chunkSize) {
|
|
||||||
const pages = [];
|
|
||||||
for (let j = i; j < i + chunkSize && j < totalPages; j++) {
|
|
||||||
pages.push(j);
|
|
||||||
}
|
|
||||||
splits.push({ pages, name: `part_${Math.floor(i / chunkSize) + 1}` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create split PDFs
|
|
||||||
const baseName = path.basename(data.inputPath, '.pdf');
|
|
||||||
for (const split of splits) {
|
|
||||||
const newPdf = await PDFDocument.create();
|
|
||||||
const copiedPages = await newPdf.copyPages(pdf, split.pages);
|
|
||||||
copiedPages.forEach(page => newPdf.addPage(page));
|
|
||||||
|
|
||||||
const outputPath = path.join(data.outputFolder, `${baseName}_${split.name}.pdf`);
|
|
||||||
const newPdfBytes = await newPdf.save();
|
|
||||||
fs.writeFileSync(outputPath, newPdfBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, message: `Successfully split PDF into ${splits.length} files` };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF Compress Operation
|
|
||||||
async function pdfCompress(data) {
|
|
||||||
try {
|
|
||||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
|
||||||
const pdf = await PDFDocument.load(pdfBytes);
|
|
||||||
|
|
||||||
// pdf-lib doesn't have built-in compression, but we can save with default compression
|
|
||||||
// For actual compression, we would need additional libraries like Ghostscript
|
|
||||||
// For now, we'll save with standard options which provides some compression
|
|
||||||
const compressedPdfBytes = await pdf.save({
|
|
||||||
useObjectStreams: true,
|
|
||||||
addDefaultPage: false,
|
|
||||||
objectsPerTick: 50
|
|
||||||
});
|
|
||||||
|
|
||||||
fs.writeFileSync(data.outputPath, compressedPdfBytes);
|
|
||||||
|
|
||||||
const originalSize = fs.statSync(data.inputPath).size;
|
|
||||||
const compressedSize = fs.statSync(data.outputPath).size;
|
|
||||||
const savings = ((originalSize - compressedSize) / originalSize * 100).toFixed(1);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `PDF compressed. Size reduced by ${savings}% (${(originalSize / 1024).toFixed(1)}KB → ${(compressedSize / 1024).toFixed(1)}KB)`
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF Rotate Pages Operation
|
|
||||||
async function pdfRotate(data) {
|
|
||||||
try {
|
|
||||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
|
||||||
const pdf = await PDFDocument.load(pdfBytes);
|
|
||||||
const totalPages = pdf.getPageCount();
|
|
||||||
|
|
||||||
let pagesToRotate = [];
|
|
||||||
if (data.pages && data.pages.trim()) {
|
|
||||||
pagesToRotate = parsePageRanges(data.pages, totalPages);
|
|
||||||
} else {
|
|
||||||
// Rotate all pages
|
|
||||||
pagesToRotate = Array.from({ length: totalPages }, (_, i) => i);
|
|
||||||
}
|
|
||||||
|
|
||||||
pagesToRotate.forEach(pageIndex => {
|
|
||||||
const page = pdf.getPage(pageIndex);
|
|
||||||
page.setRotation(degrees(data.angle));
|
|
||||||
});
|
|
||||||
|
|
||||||
const rotatedPdfBytes = await pdf.save();
|
|
||||||
fs.writeFileSync(data.outputPath, rotatedPdfBytes);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `Successfully rotated ${pagesToRotate.length} page(s) by ${data.angle}°`
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF Delete Pages Operation
|
|
||||||
async function pdfDeletePages(data) {
|
|
||||||
try {
|
|
||||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
|
||||||
const pdf = await PDFDocument.load(pdfBytes);
|
|
||||||
const totalPages = pdf.getPageCount();
|
|
||||||
|
|
||||||
const pagesToDelete = parsePageRanges(data.pages, totalPages);
|
|
||||||
|
|
||||||
// Remove pages in reverse order to maintain indices
|
|
||||||
pagesToDelete.sort((a, b) => b - a).forEach(pageIndex => {
|
|
||||||
pdf.removePage(pageIndex);
|
|
||||||
});
|
|
||||||
|
|
||||||
const newPdfBytes = await pdf.save();
|
|
||||||
fs.writeFileSync(data.outputPath, newPdfBytes);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `Successfully deleted ${pagesToDelete.length} page(s). New PDF has ${totalPages - pagesToDelete.length} pages`
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF Reorder Pages Operation
|
|
||||||
async function pdfReorder(data) {
|
|
||||||
try {
|
|
||||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
|
||||||
const pdf = await PDFDocument.load(pdfBytes);
|
|
||||||
const totalPages = pdf.getPageCount();
|
|
||||||
|
|
||||||
// Parse new page order
|
|
||||||
const newOrder = data.newOrder.split(',').map(n => parseInt(n.trim()) - 1); // Convert to 0-indexed
|
|
||||||
|
|
||||||
// Validate new order
|
|
||||||
if (newOrder.length !== totalPages) {
|
|
||||||
return { success: false, error: `New order must include all ${totalPages} pages` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const newPdf = await PDFDocument.create();
|
|
||||||
const copiedPages = await newPdf.copyPages(pdf, newOrder);
|
|
||||||
copiedPages.forEach(page => newPdf.addPage(page));
|
|
||||||
|
|
||||||
const reorderedPdfBytes = await newPdf.save();
|
|
||||||
fs.writeFileSync(data.outputPath, reorderedPdfBytes);
|
|
||||||
|
|
||||||
return { success: true, message: 'Successfully reordered PDF pages' };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF Watermark Operation
|
|
||||||
async function pdfWatermark(data) {
|
|
||||||
try {
|
|
||||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
|
||||||
const pdf = await PDFDocument.load(pdfBytes);
|
|
||||||
const totalPages = pdf.getPageCount();
|
|
||||||
|
|
||||||
// Determine which pages to watermark
|
|
||||||
let pagesToWatermark = [];
|
|
||||||
if (data.pages === 'all') {
|
|
||||||
pagesToWatermark = Array.from({ length: totalPages }, (_, i) => i);
|
|
||||||
} else if (data.pages === 'custom' && data.customPages) {
|
|
||||||
pagesToWatermark = parsePageRanges(data.customPages, totalPages);
|
|
||||||
}
|
|
||||||
|
|
||||||
const font = await pdf.embedFont(StandardFonts.Helvetica);
|
|
||||||
const color = hexToRgb(data.color);
|
|
||||||
|
|
||||||
for (const pageIndex of pagesToWatermark) {
|
|
||||||
const page = pdf.getPage(pageIndex);
|
|
||||||
const { width, height } = page.getSize();
|
|
||||||
|
|
||||||
let x, y, rotation = 0;
|
|
||||||
|
|
||||||
// Calculate position based on selected position
|
|
||||||
switch (data.position) {
|
|
||||||
case 'center':
|
|
||||||
x = width / 2;
|
|
||||||
y = height / 2;
|
|
||||||
break;
|
|
||||||
case 'diagonal':
|
|
||||||
x = width / 2;
|
|
||||||
y = height / 2;
|
|
||||||
rotation = 45;
|
|
||||||
break;
|
|
||||||
case 'top-left':
|
|
||||||
x = 50;
|
|
||||||
y = height - 50;
|
|
||||||
break;
|
|
||||||
case 'top-center':
|
|
||||||
x = width / 2;
|
|
||||||
y = height - 50;
|
|
||||||
break;
|
|
||||||
case 'top-right':
|
|
||||||
x = width - 50;
|
|
||||||
y = height - 50;
|
|
||||||
break;
|
|
||||||
case 'bottom-left':
|
|
||||||
x = 50;
|
|
||||||
y = 50;
|
|
||||||
break;
|
|
||||||
case 'bottom-center':
|
|
||||||
x = width / 2;
|
|
||||||
y = 50;
|
|
||||||
break;
|
|
||||||
case 'bottom-right':
|
|
||||||
x = width - 50;
|
|
||||||
y = 50;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
x = width / 2;
|
|
||||||
y = height / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
page.drawText(data.text, {
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
size: data.fontSize,
|
|
||||||
font,
|
|
||||||
color: rgb(color.r, color.g, color.b),
|
|
||||||
opacity: data.opacity,
|
|
||||||
rotate: degrees(rotation)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const watermarkedPdfBytes = await pdf.save();
|
|
||||||
fs.writeFileSync(data.outputPath, watermarkedPdfBytes);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `Successfully added watermark to ${pagesToWatermark.length} page(s)`
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF Encrypt (Password Protection) Operation
|
|
||||||
async function pdfEncrypt(data) {
|
|
||||||
try {
|
|
||||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
|
||||||
const pdf = await PDFDocument.load(pdfBytes);
|
|
||||||
|
|
||||||
// pdf-lib has limited encryption support in v1.17.1
|
|
||||||
// We'll save with password (basic encryption)
|
|
||||||
const encryptedPdfBytes = await pdf.save({
|
|
||||||
userPassword: data.userPassword,
|
|
||||||
ownerPassword: data.ownerPassword || data.userPassword,
|
|
||||||
permissions: {
|
|
||||||
printing: data.permissions.printing ? 'highResolution' : 'lowResolution',
|
|
||||||
modifying: data.permissions.modifying,
|
|
||||||
copying: data.permissions.copying,
|
|
||||||
annotating: data.permissions.annotating,
|
|
||||||
fillingForms: data.permissions.fillingForms,
|
|
||||||
contentAccessibility: data.permissions.contentAccessibility,
|
|
||||||
documentAssembly: data.permissions.documentAssembly
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
fs.writeFileSync(data.outputPath, encryptedPdfBytes);
|
|
||||||
|
|
||||||
return { success: true, message: 'Successfully added password protection to PDF' };
|
|
||||||
} catch (error) {
|
|
||||||
// If pdf-lib doesn't support encryption in this version, provide a helpful error
|
|
||||||
if (error.message.includes('encrypt') || error.message.includes('password')) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'PDF encryption requires pdf-lib with encryption support. This feature may not be available in the current version.'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF Decrypt (Remove Password) Operation
|
|
||||||
async function pdfDecrypt(data) {
|
|
||||||
try {
|
|
||||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
|
||||||
const pdf = await PDFDocument.load(pdfBytes, { password: data.password });
|
|
||||||
|
|
||||||
// Save without password
|
|
||||||
const decryptedPdfBytes = await pdf.save();
|
|
||||||
fs.writeFileSync(data.outputPath, decryptedPdfBytes);
|
|
||||||
|
|
||||||
return { success: true, message: 'Successfully removed password protection from PDF' };
|
|
||||||
} catch (error) {
|
|
||||||
if (error.message.includes('password') || error.message.includes('encrypted')) {
|
|
||||||
return { success: false, error: 'Incorrect password or PDF is not encrypted' };
|
|
||||||
}
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF Permissions Operation
|
|
||||||
async function pdfSetPermissions(data) {
|
|
||||||
try {
|
|
||||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
|
||||||
const loadOptions = data.currentPassword ? { password: data.currentPassword } : {};
|
|
||||||
const pdf = await PDFDocument.load(pdfBytes, loadOptions);
|
|
||||||
|
|
||||||
const newPdfBytes = await pdf.save({
|
|
||||||
ownerPassword: data.ownerPassword,
|
|
||||||
permissions: {
|
|
||||||
printing: data.permissions.printing ? 'highResolution' : 'lowResolution',
|
|
||||||
modifying: data.permissions.modifying,
|
|
||||||
copying: data.permissions.copying,
|
|
||||||
annotating: data.permissions.annotating,
|
|
||||||
fillingForms: data.permissions.fillingForms,
|
|
||||||
contentAccessibility: data.permissions.contentAccessibility,
|
|
||||||
documentAssembly: data.permissions.documentAssembly
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
fs.writeFileSync(data.outputPath, newPdfBytes);
|
|
||||||
|
|
||||||
return { success: true, message: 'Successfully updated PDF permissions' };
|
|
||||||
} catch (error) {
|
|
||||||
if (error.message.includes('encrypt') || error.message.includes('permission')) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'PDF permissions require pdf-lib with encryption support. This feature may not be available in the current version.'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// IPC Handler for PDF Operations
|
|
||||||
ipcMain.on('process-pdf-operation', async (event, data) => {
|
ipcMain.on('process-pdf-operation', async (event, data) => {
|
||||||
try {
|
try {
|
||||||
mainWindow.webContents.send('pdf-operation-progress', {
|
mainWindow.webContents.send('pdf-operation-progress', {
|
||||||
@@ -4120,43 +3835,7 @@ ipcMain.on('process-pdf-operation', async (event, data) => {
|
|||||||
progress: 10
|
progress: 10
|
||||||
});
|
});
|
||||||
|
|
||||||
let result;
|
const result = await PDFOperations.executeOperation(data.operation, data);
|
||||||
|
|
||||||
switch (data.operation) {
|
|
||||||
case 'merge':
|
|
||||||
result = await pdfMerge(data);
|
|
||||||
break;
|
|
||||||
case 'split':
|
|
||||||
result = await pdfSplit(data);
|
|
||||||
break;
|
|
||||||
case 'compress':
|
|
||||||
result = await pdfCompress(data);
|
|
||||||
break;
|
|
||||||
case 'rotate':
|
|
||||||
result = await pdfRotate(data);
|
|
||||||
break;
|
|
||||||
case 'delete':
|
|
||||||
result = await pdfDeletePages(data);
|
|
||||||
break;
|
|
||||||
case 'reorder':
|
|
||||||
result = await pdfReorder(data);
|
|
||||||
break;
|
|
||||||
case 'watermark':
|
|
||||||
result = await pdfWatermark(data);
|
|
||||||
break;
|
|
||||||
case 'encrypt':
|
|
||||||
result = await pdfEncrypt(data);
|
|
||||||
break;
|
|
||||||
case 'decrypt':
|
|
||||||
result = await pdfDecrypt(data);
|
|
||||||
break;
|
|
||||||
case 'permissions':
|
|
||||||
result = await pdfSetPermissions(data);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
result = { success: false, error: `Unknown operation: ${data.operation}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
mainWindow.webContents.send('pdf-operation-complete', result);
|
mainWindow.webContents.send('pdf-operation-complete', result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
mainWindow.webContents.send('pdf-operation-complete', {
|
mainWindow.webContents.send('pdf-operation-complete', {
|
||||||
@@ -4166,12 +3845,9 @@ ipcMain.on('process-pdf-operation', async (event, data) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// IPC Handler for getting PDF page count
|
|
||||||
ipcMain.on('get-pdf-page-count', async (event, filePath) => {
|
ipcMain.on('get-pdf-page-count', async (event, filePath) => {
|
||||||
try {
|
try {
|
||||||
const pdfBytes = fs.readFileSync(filePath);
|
const count = await PDFOperations.getPageCount(filePath);
|
||||||
const pdf = await PDFDocument.load(pdfBytes);
|
|
||||||
const count = pdf.getPageCount();
|
|
||||||
event.reply('pdf-page-count', { count });
|
event.reply('pdf-page-count', { count });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
event.reply('pdf-page-count', { error: error.message });
|
event.reply('pdf-page-count', { error: error.message });
|
||||||
@@ -4344,6 +4020,8 @@ ipcMain.handle('list-directory', async (event, dirPath) => {
|
|||||||
.map(e => ({
|
.map(e => ({
|
||||||
name: e.name,
|
name: e.name,
|
||||||
isDirectory: e.isDirectory(),
|
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)
|
path: path.join(validation.resolved, e.name)
|
||||||
}));
|
}));
|
||||||
return { path: validation.resolved, entries };
|
return { path: validation.resolved, entries };
|
||||||
@@ -4353,6 +4031,122 @@ ipcMain.handle('list-directory', async (event, dirPath) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('select-custom-css', async (event) => {
|
||||||
|
const result = dialog.showOpenDialogSync(mainWindow, {
|
||||||
|
title: 'Select Custom Preview CSS',
|
||||||
|
properties: ['openFile'],
|
||||||
|
filters: [
|
||||||
|
{ name: 'CSS Stylesheets', extensions: ['css'] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result && result[0]) {
|
||||||
|
const filePath = result[0];
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
return { path: filePath, content };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
ipcMain.handle('read-file', async (event, filePath) => {
|
||||||
|
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)
|
// Open a file by path (from explorer panel)
|
||||||
ipcMain.on('open-file-path', (event, filePath) => {
|
ipcMain.on('open-file-path', (event, filePath) => {
|
||||||
try {
|
try {
|
||||||
@@ -4380,49 +4174,24 @@ ipcMain.on('open-file-path', (event, filePath) => {
|
|||||||
// ============================================
|
// ============================================
|
||||||
// Git IPC Handlers
|
// Git IPC Handlers
|
||||||
// ============================================
|
// ============================================
|
||||||
const simpleGit = require('simple-git');
|
|
||||||
|
|
||||||
function getGitRepo() {
|
|
||||||
// Use the directory of the current file, or the app's working directory
|
|
||||||
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
|
|
||||||
return simpleGit(dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
ipcMain.handle('git-status', async () => {
|
ipcMain.handle('git-status', async () => {
|
||||||
try {
|
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
|
||||||
const git = getGitRepo();
|
return GitOperations.getStatus(dir);
|
||||||
return await git.status();
|
|
||||||
} catch (err) {
|
|
||||||
return { error: 'Not a git repository' };
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('git-stage', async (event, { files }) => {
|
ipcMain.handle('git-stage', async (event, { files }) => {
|
||||||
try {
|
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
|
||||||
const git = getGitRepo();
|
return GitOperations.stage(dir, files);
|
||||||
await git.add(files);
|
|
||||||
return await git.status();
|
|
||||||
} catch (err) {
|
|
||||||
return { error: err.message };
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('git-commit', async (event, { message }) => {
|
ipcMain.handle('git-commit', async (event, { message }) => {
|
||||||
try {
|
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
|
||||||
const git = getGitRepo();
|
return GitOperations.commit(dir, message);
|
||||||
return await git.commit(message);
|
|
||||||
} catch (err) {
|
|
||||||
return { error: err.message };
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('git-log', async () => {
|
ipcMain.handle('git-log', async () => {
|
||||||
try {
|
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
|
||||||
const git = getGitRepo();
|
return GitOperations.log(dir);
|
||||||
return await git.log({ maxCount: 20 });
|
|
||||||
} catch (err) {
|
|
||||||
return { error: err.message };
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -4490,4 +4259,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();
|
||||||
|
|
||||||
|
const splits = [];
|
||||||
|
|
||||||
|
if (data.splitMode === 'pages') {
|
||||||
|
const ranges = data.pageRanges.split(',').map(r => r.trim());
|
||||||
|
for (let i = 0; i < ranges.length; i++) {
|
||||||
|
const range = ranges[i];
|
||||||
|
const pages = [];
|
||||||
|
|
||||||
|
if (range.includes('-')) {
|
||||||
|
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||||||
|
for (let p = start; p <= end && p <= totalPages; p++) {
|
||||||
|
pages.push(p - 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const page = parseInt(range);
|
||||||
|
if (page > 0 && page <= totalPages) {
|
||||||
|
pages.push(page - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pages.length > 0) {
|
||||||
|
splits.push({ pages, name: `part_${i + 1}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (data.splitMode === 'interval') {
|
||||||
|
const interval = data.interval;
|
||||||
|
for (let i = 0; i < totalPages; i += interval) {
|
||||||
|
const pages = [];
|
||||||
|
for (let j = i; j < i + interval && j < totalPages; j++) {
|
||||||
|
pages.push(j);
|
||||||
|
}
|
||||||
|
splits.push({ pages, name: `part_${Math.floor(i / interval) + 1}` });
|
||||||
|
}
|
||||||
|
} else if (data.splitMode === 'size') {
|
||||||
|
const chunkSize = Math.max(1, Math.floor(totalPages / 5));
|
||||||
|
for (let i = 0; i < totalPages; i += chunkSize) {
|
||||||
|
const pages = [];
|
||||||
|
for (let j = i; j < i + chunkSize && j < totalPages; j++) {
|
||||||
|
pages.push(j);
|
||||||
|
}
|
||||||
|
splits.push({ pages, name: `part_${Math.floor(i / chunkSize) + 1}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseName = path.basename(data.inputPath, '.pdf');
|
||||||
|
for (const split of splits) {
|
||||||
|
const newPdf = await PDFDocument.create();
|
||||||
|
const copiedPages = await newPdf.copyPages(pdf, split.pages);
|
||||||
|
copiedPages.forEach(page => newPdf.addPage(page));
|
||||||
|
|
||||||
|
const outputPath = path.join(data.outputFolder, `${baseName}_${split.name}.pdf`);
|
||||||
|
const newPdfBytes = await newPdf.save();
|
||||||
|
fs.writeFileSync(outputPath, newPdfBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, message: `Successfully split PDF into ${splits.length} files` };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfCompress(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
|
||||||
|
const compressedPdfBytes = await pdf.save({
|
||||||
|
useObjectStreams: true,
|
||||||
|
addDefaultPage: false,
|
||||||
|
objectsPerTick: 50
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(data.outputPath, compressedPdfBytes);
|
||||||
|
|
||||||
|
const originalSize = fs.statSync(data.inputPath).size;
|
||||||
|
const compressedSize = fs.statSync(data.outputPath).size;
|
||||||
|
const savings = ((originalSize - compressedSize) / originalSize * 100).toFixed(1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `PDF compressed. Size reduced by ${savings}% (${(originalSize / 1024).toFixed(1)}KB → ${(compressedSize / 1024).toFixed(1)}KB)`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfRotate(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
const totalPages = pdf.getPageCount();
|
||||||
|
|
||||||
|
let pagesToRotate = [];
|
||||||
|
if (data.pages && data.pages.trim()) {
|
||||||
|
pagesToRotate = parsePageRanges(data.pages, totalPages);
|
||||||
|
} else {
|
||||||
|
pagesToRotate = Array.from({ length: totalPages }, (_, i) => i);
|
||||||
|
}
|
||||||
|
|
||||||
|
pagesToRotate.forEach(pageIndex => {
|
||||||
|
const page = pdf.getPage(pageIndex);
|
||||||
|
page.setRotation(degrees(data.angle));
|
||||||
|
});
|
||||||
|
|
||||||
|
const rotatedPdfBytes = await pdf.save();
|
||||||
|
fs.writeFileSync(data.outputPath, rotatedPdfBytes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully rotated ${pagesToRotate.length} page(s) by ${data.angle}\u00B0`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfDeletePages(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
const totalPages = pdf.getPageCount();
|
||||||
|
|
||||||
|
const pagesToDelete = parsePageRanges(data.pages, totalPages);
|
||||||
|
|
||||||
|
pagesToDelete.sort((a, b) => b - a).forEach(pageIndex => {
|
||||||
|
pdf.removePage(pageIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
const newPdfBytes = await pdf.save();
|
||||||
|
fs.writeFileSync(data.outputPath, newPdfBytes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully deleted ${pagesToDelete.length} page(s). New PDF has ${totalPages - pagesToDelete.length} pages`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfReorder(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
const totalPages = pdf.getPageCount();
|
||||||
|
|
||||||
|
const newOrder = data.newOrder.split(',').map(n => parseInt(n.trim()) - 1);
|
||||||
|
|
||||||
|
if (newOrder.length !== totalPages) {
|
||||||
|
return { success: false, error: `New order must include all ${totalPages} pages` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPdf = await PDFDocument.create();
|
||||||
|
const copiedPages = await newPdf.copyPages(pdf, newOrder);
|
||||||
|
copiedPages.forEach(page => newPdf.addPage(page));
|
||||||
|
|
||||||
|
const reorderedPdfBytes = await newPdf.save();
|
||||||
|
fs.writeFileSync(data.outputPath, reorderedPdfBytes);
|
||||||
|
|
||||||
|
return { success: true, message: 'Successfully reordered PDF pages' };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfWatermark(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
const totalPages = pdf.getPageCount();
|
||||||
|
|
||||||
|
let pagesToWatermark = [];
|
||||||
|
if (data.pages === 'all') {
|
||||||
|
pagesToWatermark = Array.from({ length: totalPages }, (_, i) => i);
|
||||||
|
} else if (data.pages === 'custom' && data.customPages) {
|
||||||
|
pagesToWatermark = parsePageRanges(data.customPages, totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
const font = await pdf.embedFont(StandardFonts.Helvetica);
|
||||||
|
const color = hexToRgb(data.color);
|
||||||
|
|
||||||
|
for (const pageIndex of pagesToWatermark) {
|
||||||
|
const page = pdf.getPage(pageIndex);
|
||||||
|
const { width, height } = page.getSize();
|
||||||
|
|
||||||
|
let x, y, rotation = 0;
|
||||||
|
|
||||||
|
switch (data.position) {
|
||||||
|
case 'center':
|
||||||
|
x = width / 2;
|
||||||
|
y = height / 2;
|
||||||
|
break;
|
||||||
|
case 'diagonal':
|
||||||
|
x = width / 2;
|
||||||
|
y = height / 2;
|
||||||
|
rotation = 45;
|
||||||
|
break;
|
||||||
|
case 'top-left':
|
||||||
|
x = 50;
|
||||||
|
y = height - 50;
|
||||||
|
break;
|
||||||
|
case 'top-center':
|
||||||
|
x = width / 2;
|
||||||
|
y = height - 50;
|
||||||
|
break;
|
||||||
|
case 'top-right':
|
||||||
|
x = width - 50;
|
||||||
|
y = height - 50;
|
||||||
|
break;
|
||||||
|
case 'bottom-left':
|
||||||
|
x = 50;
|
||||||
|
y = 50;
|
||||||
|
break;
|
||||||
|
case 'bottom-center':
|
||||||
|
x = width / 2;
|
||||||
|
y = 50;
|
||||||
|
break;
|
||||||
|
case 'bottom-right':
|
||||||
|
x = width - 50;
|
||||||
|
y = 50;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
x = width / 2;
|
||||||
|
y = height / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
page.drawText(data.text, {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
size: data.fontSize,
|
||||||
|
font,
|
||||||
|
color: rgb(color.r, color.g, color.b),
|
||||||
|
opacity: data.opacity,
|
||||||
|
rotate: degrees(rotation)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const watermarkedPdfBytes = await pdf.save();
|
||||||
|
fs.writeFileSync(data.outputPath, watermarkedPdfBytes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully added watermark to ${pagesToWatermark.length} page(s)`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfEncrypt(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
|
||||||
|
const encryptedPdfBytes = await pdf.save({
|
||||||
|
userPassword: data.userPassword,
|
||||||
|
ownerPassword: data.ownerPassword || data.userPassword,
|
||||||
|
permissions: {
|
||||||
|
printing: data.permissions.printing ? 'highResolution' : 'lowResolution',
|
||||||
|
modifying: data.permissions.modifying,
|
||||||
|
copying: data.permissions.copying,
|
||||||
|
annotating: data.permissions.annotating,
|
||||||
|
fillingForms: data.permissions.fillingForms,
|
||||||
|
contentAccessibility: data.permissions.contentAccessibility,
|
||||||
|
documentAssembly: data.permissions.documentAssembly
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(data.outputPath, encryptedPdfBytes);
|
||||||
|
|
||||||
|
return { success: true, message: 'Successfully added password protection to PDF' };
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('encrypt') || error.message.includes('password')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'PDF encryption requires pdf-lib with encryption support. This feature may not be available in the current version.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfDecrypt(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes, { password: data.password });
|
||||||
|
|
||||||
|
const decryptedPdfBytes = await pdf.save();
|
||||||
|
fs.writeFileSync(data.outputPath, decryptedPdfBytes);
|
||||||
|
|
||||||
|
return { success: true, message: 'Successfully removed password protection from PDF' };
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('password') || error.message.includes('encrypted')) {
|
||||||
|
return { success: false, error: 'Incorrect password or PDF is not encrypted' };
|
||||||
|
}
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfSetPermissions(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const loadOptions = data.currentPassword ? { password: data.currentPassword } : {};
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes, loadOptions);
|
||||||
|
|
||||||
|
const newPdfBytes = await pdf.save({
|
||||||
|
ownerPassword: data.ownerPassword,
|
||||||
|
permissions: {
|
||||||
|
printing: data.permissions.printing ? 'highResolution' : 'lowResolution',
|
||||||
|
modifying: data.permissions.modifying,
|
||||||
|
copying: data.permissions.copying,
|
||||||
|
annotating: data.permissions.annotating,
|
||||||
|
fillingForms: data.permissions.fillingForms,
|
||||||
|
contentAccessibility: data.permissions.contentAccessibility,
|
||||||
|
documentAssembly: data.permissions.documentAssembly
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(data.outputPath, newPdfBytes);
|
||||||
|
|
||||||
|
return { success: true, message: 'Successfully updated PDF permissions' };
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('encrypt') || error.message.includes('permission')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'PDF permissions require pdf-lib with encryption support. This feature may not be available in the current version.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeOperation(operation, data) {
|
||||||
|
switch (operation) {
|
||||||
|
case 'merge': return pdfMerge(data);
|
||||||
|
case 'split': return pdfSplit(data);
|
||||||
|
case 'compress': return pdfCompress(data);
|
||||||
|
case 'rotate': return pdfRotate(data);
|
||||||
|
case 'delete': return pdfDeletePages(data);
|
||||||
|
case 'reorder': return pdfReorder(data);
|
||||||
|
case 'watermark': return pdfWatermark(data);
|
||||||
|
case 'encrypt': return pdfEncrypt(data);
|
||||||
|
case 'decrypt': return pdfDecrypt(data);
|
||||||
|
case 'permissions': return pdfSetPermissions(data);
|
||||||
|
default: return Promise.resolve({ success: false, error: `Unknown operation: ${operation}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPageCount(filePath) {
|
||||||
|
const pdfBytes = fs.readFileSync(filePath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
return pdf.getPageCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parsePageRanges,
|
||||||
|
hexToRgb,
|
||||||
|
pdfMerge,
|
||||||
|
pdfSplit,
|
||||||
|
pdfCompress,
|
||||||
|
pdfRotate,
|
||||||
|
pdfDeletePages,
|
||||||
|
pdfReorder,
|
||||||
|
pdfWatermark,
|
||||||
|
pdfEncrypt,
|
||||||
|
pdfDecrypt,
|
||||||
|
pdfSetPermissions,
|
||||||
|
executeOperation,
|
||||||
|
getPageCount
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
+29
-4
@@ -9,7 +9,7 @@
|
|||||||
* - All IPC channels are explicitly whitelisted
|
* - All IPC channels are explicitly whitelisted
|
||||||
* - Prevents XSS from escalating to full system access
|
* - Prevents XSS from escalating to full system access
|
||||||
*
|
*
|
||||||
* @version 4.0.0
|
* @version 4.4.1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { contextBridge, ipcRenderer } = require('electron');
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
@@ -23,6 +23,7 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
'save-recent-files',
|
'save-recent-files',
|
||||||
'clear-recent-files',
|
'clear-recent-files',
|
||||||
'renderer-ready',
|
'renderer-ready',
|
||||||
|
'select-custom-css',
|
||||||
|
|
||||||
// Theme
|
// Theme
|
||||||
'get-theme',
|
'get-theme',
|
||||||
@@ -101,6 +102,14 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
|
|
||||||
// File Explorer
|
// File Explorer
|
||||||
'list-directory',
|
'list-directory',
|
||||||
|
'read-file',
|
||||||
|
'write-file',
|
||||||
|
'delete-file',
|
||||||
|
'ensure-directory',
|
||||||
|
'path-exists',
|
||||||
|
'is-directory',
|
||||||
|
'copy-path',
|
||||||
|
'move-path',
|
||||||
|
|
||||||
// Git
|
// Git
|
||||||
'git-status',
|
'git-status',
|
||||||
@@ -127,7 +136,11 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
'export',
|
'export',
|
||||||
|
|
||||||
// Git diff
|
// Git diff
|
||||||
'git-diff'
|
'git-diff',
|
||||||
|
|
||||||
|
// Plugin settings
|
||||||
|
'plugin-settings:get',
|
||||||
|
'plugin-settings:set'
|
||||||
];
|
];
|
||||||
|
|
||||||
const ALLOWED_RECEIVE_CHANNELS = [
|
const ALLOWED_RECEIVE_CHANNELS = [
|
||||||
@@ -138,6 +151,8 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
|||||||
'get-content-for-save',
|
'get-content-for-save',
|
||||||
'get-content-for-spreadsheet',
|
'get-content-for-spreadsheet',
|
||||||
'recent-files-cleared',
|
'recent-files-cleared',
|
||||||
|
'load-custom-css',
|
||||||
|
'clear-custom-css',
|
||||||
|
|
||||||
// UI toggles
|
// UI toggles
|
||||||
'toggle-preview',
|
'toggle-preview',
|
||||||
@@ -321,7 +336,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
setCurrent: (filePath) => ipcRenderer.send('set-current-file', filePath),
|
setCurrent: (filePath) => ipcRenderer.send('set-current-file', filePath),
|
||||||
saveRecent: (recentFiles) => ipcRenderer.send('save-recent-files', recentFiles),
|
saveRecent: (recentFiles) => ipcRenderer.send('save-recent-files', recentFiles),
|
||||||
clearRecent: () => ipcRenderer.send('clear-recent-files'),
|
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
|
// Theme Operations
|
||||||
@@ -413,7 +436,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
generators: {
|
generators: {
|
||||||
openAscii: () => ipcRenderer.send('open-ascii-generator'),
|
openAscii: () => ipcRenderer.send('open-ascii-generator'),
|
||||||
openTable: () => ipcRenderer.send('open-table-generator')
|
openTable: () => ipcRenderer.send('open-table-generator')
|
||||||
}
|
},
|
||||||
|
|
||||||
|
getAppVersion: () => ipcRenderer.invoke('get-app-version')
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log successful preload initialization
|
// Log successful preload initialization
|
||||||
|
|||||||
+786
-98
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { AppHeader } from './components/layout/AppHeader';
|
||||||
|
import { TabBar } from './components/layout/TabBar';
|
||||||
|
import { Toolbar } from './components/layout/Toolbar';
|
||||||
|
import { Breadcrumb } from './components/layout/Breadcrumb';
|
||||||
|
import { Sidebar } from './components/sidebar/Sidebar';
|
||||||
|
import { EditorPane } from './components/editor/EditorPane';
|
||||||
|
import { PreviewPane } from './components/preview/PreviewPane';
|
||||||
|
import { StatusBar } from './components/layout/StatusBar';
|
||||||
|
import { CommandPalette } from './components/modals/CommandPalette';
|
||||||
|
import { ExportDialog } from './components/modals/ExportDialog';
|
||||||
|
import { SettingsDialog } from './components/modals/SettingsDialog';
|
||||||
|
import { useAppStore } from './stores/appStore';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const { sidebarVisible, previewVisible, activeTabId } = useAppStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen bg-background text-foreground overflow-hidden">
|
||||||
|
<AppHeader />
|
||||||
|
<TabBar />
|
||||||
|
<Toolbar />
|
||||||
|
<Breadcrumb />
|
||||||
|
|
||||||
|
<main className="flex flex-1 overflow-hidden">
|
||||||
|
{sidebarVisible && <Sidebar />}
|
||||||
|
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
<EditorPane className={previewVisible ? 'flex-1' : 'flex-1'} />
|
||||||
|
{previewVisible && (
|
||||||
|
<>
|
||||||
|
<div className="w-px bg-border cursor-col-resize hover:bg-brand transition-colors" />
|
||||||
|
<PreviewPane className="flex-1" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<StatusBar />
|
||||||
|
<CommandPalette />
|
||||||
|
<ExportDialog />
|
||||||
|
<SettingsDialog />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self';">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>MarkdownConverter</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="font-sans antialiased">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './styles/globals.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 0 0% 9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 0 0% 9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 0 0% 9%;
|
||||||
|
--primary: 11 79% 51%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 0 0% 96%;
|
||||||
|
--secondary-foreground: 0 0% 9%;
|
||||||
|
--muted: 0 0% 96%;
|
||||||
|
--muted-foreground: 0 0% 45%;
|
||||||
|
--accent: 0 0% 96%;
|
||||||
|
--accent-foreground: 0 0% 9%;
|
||||||
|
--destructive: 0 84% 60%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 0 0% 90%;
|
||||||
|
--input: 0 0% 90%;
|
||||||
|
--ring: 11 79% 51%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 30 14% 4%;
|
||||||
|
--foreground: 0 0% 98%;
|
||||||
|
--card: 30 14% 6%;
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
--popover: 30 14% 6%;
|
||||||
|
--popover-foreground: 0 0% 98%;
|
||||||
|
--primary: 11 79% 51%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 30 6% 15%;
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
--muted: 30 6% 15%;
|
||||||
|
--muted-foreground: 0 0% 63%;
|
||||||
|
--accent: 30 6% 15%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
--destructive: 0 62% 30%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 30 6% 15%;
|
||||||
|
--input: 30 6% 15%;
|
||||||
|
--ring: 11 79% 51%;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--muted-foreground) / 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(var(--muted-foreground) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CodeMirror integration */
|
||||||
|
.cm-editor {
|
||||||
|
@apply h-full;
|
||||||
|
}
|
||||||
|
.cm-editor .cm-scroller {
|
||||||
|
@apply font-mono text-sm;
|
||||||
|
}
|
||||||
Vendored
+90
@@ -0,0 +1,90 @@
|
|||||||
|
export interface ElectronAPI {
|
||||||
|
// App info
|
||||||
|
getAppVersion: () => Promise<string>;
|
||||||
|
|
||||||
|
// File operations
|
||||||
|
openFile: () => Promise<{ canceled: boolean; filePaths: string[] }>;
|
||||||
|
saveFile: (data: { path: string; content: string }) => Promise<void>;
|
||||||
|
readFile: (path: string) => Promise<string>;
|
||||||
|
setCurrentFile: (path: string | null) => void;
|
||||||
|
|
||||||
|
// Export
|
||||||
|
exportDocument: (format: string, options?: Record<string, any>) => Promise<void>;
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
getTheme: () => void;
|
||||||
|
onThemeChanged: (callback: (theme: string) => void) => () => void;
|
||||||
|
|
||||||
|
// File events
|
||||||
|
onFileOpened: (callback: (data: { path: string; content: string }) => void) => () => void;
|
||||||
|
onFileNew: (callback: () => void) => () => void;
|
||||||
|
onFileSave: (callback: () => void) => () => void;
|
||||||
|
|
||||||
|
// Conversion status
|
||||||
|
onConversionStatus: (callback: (message: string) => void) => () => void;
|
||||||
|
onConversionComplete: (callback: (data: { format: string; outputPath: string }) => void) => () => void;
|
||||||
|
|
||||||
|
// Dialogs
|
||||||
|
showExportDialog: (format: string) => void;
|
||||||
|
showBatchDialog: () => void;
|
||||||
|
showUniversalConverterDialog: () => void;
|
||||||
|
showTableGenerator: () => void;
|
||||||
|
showPdfEditorDialog: () => void;
|
||||||
|
showHeaderFooterDialog: () => void;
|
||||||
|
showFieldPickerDialog: () => void;
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
getSettings: (key: string) => Promise<any>;
|
||||||
|
setSettings: (key: string, value: any) => Promise<void>;
|
||||||
|
|
||||||
|
// Plugin settings
|
||||||
|
getPluginSetting: (key: string) => Promise<any>;
|
||||||
|
setPluginSetting: (key: string, value: any) => Promise<void>;
|
||||||
|
|
||||||
|
// Sidebar
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
toggleBottomPanel: () => void;
|
||||||
|
|
||||||
|
// Print
|
||||||
|
doPrint: (options?: any) => void;
|
||||||
|
|
||||||
|
// PDF
|
||||||
|
openPdfViewer: (filePath: string) => void;
|
||||||
|
processPdfOperation: (data: any) => void;
|
||||||
|
|
||||||
|
// Images
|
||||||
|
selectFolder: () => Promise<string | null>;
|
||||||
|
savePastedImage: (data: { base64: string; ext: string }) => Promise<{ relativePath: string } | null>;
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
loadTemplate: (file: string) => Promise<string>;
|
||||||
|
getSnippets: () => Promise<any[]>;
|
||||||
|
saveSnippet: (snippet: any) => Promise<void>;
|
||||||
|
deleteSnippet: (id: string) => Promise<void>;
|
||||||
|
|
||||||
|
// Git
|
||||||
|
gitStatus: () => Promise<any>;
|
||||||
|
gitDiff: (file: string) => Promise<any>;
|
||||||
|
gitStage: (files: string[]) => Promise<void>;
|
||||||
|
gitCommit: (message: string) => Promise<void>;
|
||||||
|
gitLog: () => Promise<any[]>;
|
||||||
|
|
||||||
|
// Directory
|
||||||
|
listDirectory: (dir: string) => Promise<any[] | null>;
|
||||||
|
openFilePath: (path: string) => void;
|
||||||
|
|
||||||
|
// Custom CSS
|
||||||
|
selectCustomCSS: () => Promise<string | null>;
|
||||||
|
loadCustomCSS: () => void;
|
||||||
|
clearCustomCSS: () => void;
|
||||||
|
|
||||||
|
// Generic invoke/on
|
||||||
|
invoke: (channel: string, data?: any) => Promise<any>;
|
||||||
|
on: (channel: string, callback: (...args: any[]) => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
electronAPI: ElectronAPI;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
* ConcreteInfo Theme for MarkdownConverter
|
||||||
* Based on logo palette: #464646, #9a9696, #e5461f, #e3e3e3, #0d0b09
|
* Based on logo palette: #464646, #9a9696, #e5461f, #e3e3e3, #0d0b09
|
||||||
* Version: 3.0.0
|
* Version: 4.1.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
|
|||||||
@@ -2346,3 +2346,280 @@ body[class*="dark"] .breadcrumb-bar {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
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); }
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Interactive PDF Thumbnail Sidebar Styles
|
||||||
|
======================================== */
|
||||||
|
.pdf-editor-body.side-by-side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 16px;
|
||||||
|
height: 550px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-editor-left-panel {
|
||||||
|
flex: 1.2;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 8px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-editor-right-panel {
|
||||||
|
flex: 0.8;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-left: 1px solid var(--border-color, #e5e7eb);
|
||||||
|
padding-left: 16px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-editor-right-panel h4 {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-editor-right-panel .sidebar-help {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary, #6b7280);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-grid {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--border-color, #e5e7eb);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: 8px;
|
||||||
|
transition: all var(--transition-speed, 0.3s) var(--transition-ease);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
border-color: var(--primary-light, #8b9aff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-card.marked-delete {
|
||||||
|
border-color: var(--error, #ef4444);
|
||||||
|
background: rgba(239, 68, 68, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-card.marked-delete::after {
|
||||||
|
content: "DELETE";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%) rotate(-15deg);
|
||||||
|
background: rgba(239, 68, 68, 0.95);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-header .badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--bg-tertiary, #f3f4f6);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-secondary, #f9fafb);
|
||||||
|
border: 1px solid var(--border-color, #e5e7eb);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
height: 120px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-canvas {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSS Rotations */
|
||||||
|
.rot90 {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.rot180 {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.rot270 {
|
||||||
|
transform: rotate(270deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-controls button,
|
||||||
|
.pdf-thumbnail-controls label {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-rotate-thumbnail {
|
||||||
|
background: var(--bg-tertiary, #f3f4f6);
|
||||||
|
border: 1px solid var(--border-color, #e5e7eb);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-rotate-thumbnail:hover {
|
||||||
|
background: var(--primary-light, #8b9aff);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-thumbnail-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-thumbnail-checkbox input {
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-reorder {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reorder-left,
|
||||||
|
.btn-reorder-right {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-tertiary, #f3f4f6);
|
||||||
|
border: 1px solid var(--border-color, #e5e7eb);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reorder-left:hover:not(:disabled),
|
||||||
|
.btn-reorder-right:hover:not(:disabled) {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reorder-left:disabled,
|
||||||
|
.btn-reorder-right:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-thumbnails,
|
||||||
|
.thumbnail-error {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-error {
|
||||||
|
color: var(--error, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode adjustments */
|
||||||
|
body[class*="dark"] .pdf-thumbnail-card {
|
||||||
|
background: var(--gray-800, #1f2937);
|
||||||
|
border-color: var(--gray-700, #374151);
|
||||||
|
}
|
||||||
|
body[class*="dark"] .pdf-thumbnail-header .badge {
|
||||||
|
background: var(--gray-700, #374151);
|
||||||
|
color: var(--gray-200);
|
||||||
|
}
|
||||||
|
body[class*="dark"] .canvas-container {
|
||||||
|
background: var(--gray-900, #111827);
|
||||||
|
border-color: var(--gray-700);
|
||||||
|
}
|
||||||
|
body[class*="dark"] .btn-rotate-thumbnail,
|
||||||
|
body[class*="dark"] .btn-reorder-left,
|
||||||
|
body[class*="dark"] .btn-reorder-right {
|
||||||
|
background: var(--gray-700, #374151);
|
||||||
|
border-color: var(--gray-600);
|
||||||
|
color: var(--gray-200);
|
||||||
|
}
|
||||||
|
|||||||
@@ -276,3 +276,29 @@ body[class*="dark"] .tree-item:hover,
|
|||||||
body[class*="dark"] .git-file:hover {
|
body[class*="dark"] .git-file:hover {
|
||||||
background: #333;
|
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;
|
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 */
|
/* Dark theme adjustments for states */
|
||||||
body[class*="dark"] .preview-error-message {
|
body[class*="dark"] .preview-error-message {
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Modal System Styles
|
* Modal System Styles
|
||||||
* Unified modal components with glassmorphism backdrop
|
* 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
|
* ModalManager - Unified modal system with accessibility support
|
||||||
* @version 4.0.0
|
* @version 4.4.2
|
||||||
*/
|
*/
|
||||||
class ModalManager {
|
class ModalManager {
|
||||||
#modal;
|
#modal;
|
||||||
@@ -237,8 +237,8 @@ class ModalManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export for use in renderer
|
// Export for use in renderer - avoid duplicate declaration
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined' && !window.ModalManager) {
|
||||||
window.ModalManager = ModalManager;
|
window.ModalManager = ModalManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
function createWelcomeContent(recentFiles = []) {
|
function createWelcomeContent(recentFiles = [], appVersion = '') {
|
||||||
const recentHtml = recentFiles.length
|
const recentHtml = recentFiles.length
|
||||||
? recentFiles.map(f => {
|
? recentFiles.map(f => {
|
||||||
const name = f.split(/[/\\]/).pop();
|
const name = f.split(/[/\\]/).pop();
|
||||||
@@ -10,7 +10,7 @@ function createWelcomeContent(recentFiles = []) {
|
|||||||
<div class="welcome-container">
|
<div class="welcome-container">
|
||||||
<div class="welcome-hero">
|
<div class="welcome-hero">
|
||||||
<h1 class="welcome-title">MarkdownConverter</h1>
|
<h1 class="welcome-title">MarkdownConverter</h1>
|
||||||
<p class="welcome-version">Version 4.0.0</p>
|
<p class="welcome-version">${appVersion ? `Version ${appVersion}` : ''}</p>
|
||||||
<p class="welcome-subtitle">Professional Markdown Editor & Universal Document Converter</p>
|
<p class="welcome-subtitle">Professional Markdown Editor & Universal Document Converter</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ function createWelcomeContent(recentFiles = []) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="welcome-section">
|
<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">
|
<ul class="welcome-features">
|
||||||
<li><strong>CodeMirror Editor</strong> — Syntax highlighting, code folding, multiple cursors</li>
|
<li><strong>CodeMirror Editor</strong> — Syntax highlighting, code folding, multiple cursors</li>
|
||||||
<li><strong>Sidebar Panels</strong> — File Explorer, Git, Snippets, Templates</li>
|
<li><strong>Sidebar Panels</strong> — File Explorer, Git, Snippets, Templates</li>
|
||||||
|
|||||||
@@ -655,7 +655,7 @@ class WordTemplateExporter {
|
|||||||
*/
|
*/
|
||||||
parseInlineFormatting(text) {
|
parseInlineFormatting(text) {
|
||||||
let xml = '';
|
let xml = '';
|
||||||
let pos = 0;
|
const pos = 0;
|
||||||
|
|
||||||
// Patterns for inline formatting
|
// Patterns for inline formatting
|
||||||
const patterns = [
|
const patterns = [
|
||||||
|
|||||||
+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.
|
||||||
|
const 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,47 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: ['class'],
|
||||||
|
content: [
|
||||||
|
'./src/renderer/**/*.{js,ts,jsx,tsx}',
|
||||||
|
'./src/renderer/index.html',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
DEFAULT: '#e5461f',
|
||||||
|
dark: '#c93a18',
|
||||||
|
light: '#ff6b47',
|
||||||
|
},
|
||||||
|
success: '#1a7a56',
|
||||||
|
warning: '#eab308',
|
||||||
|
danger: '#ef4444',
|
||||||
|
info: '#0ea5e9',
|
||||||
|
warm: {
|
||||||
|
50: '#fafbfc',
|
||||||
|
100: '#f5f5f5',
|
||||||
|
200: '#e3e3e3',
|
||||||
|
300: '#d1d1d1',
|
||||||
|
400: '#b0b0b0',
|
||||||
|
500: '#9a9696',
|
||||||
|
600: '#7a7878',
|
||||||
|
700: '#5a5858',
|
||||||
|
800: '#4e4e4e',
|
||||||
|
900: '#464646',
|
||||||
|
950: '#0d0b09',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['"Plus Jakarta Sans"', 'system-ui', 'sans-serif'],
|
||||||
|
display: ['"Barlow Condensed"', 'system-ui', 'sans-serif'],
|
||||||
|
mono: ['"JetBrains Mono"', '"Fira Code"', '"SF Mono"', 'Consolas', 'monospace'],
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: '0.75rem',
|
||||||
|
md: '0.5rem',
|
||||||
|
sm: '0.25rem',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require('tailwindcss-animate')],
|
||||||
|
};
|
||||||
@@ -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,123 @@
|
|||||||
|
/**
|
||||||
|
* Git Operations Utilities Test Suite
|
||||||
|
* Tests for async patterns and error handling
|
||||||
|
* @version 4.3.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('GitOperations Utilities', () => {
|
||||||
|
describe('error handling patterns', () => {
|
||||||
|
it('should handle git errors gracefully', () => {
|
||||||
|
const errorResponse = { error: 'Not a git repository' };
|
||||||
|
|
||||||
|
expect(errorResponse).toHaveProperty('error');
|
||||||
|
expect(errorResponse.error).toContain('repository');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error object on failure', () => {
|
||||||
|
const failureResult = { error: 'Failed to commit' };
|
||||||
|
|
||||||
|
expect(failureResult).toBeDefined();
|
||||||
|
expect(failureResult.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('async patterns', () => {
|
||||||
|
it('should handle async operations', async () => {
|
||||||
|
const asyncFn = async () => {
|
||||||
|
return { status: 'success' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await asyncFn();
|
||||||
|
expect(result).toHaveProperty('status');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle async errors', async () => {
|
||||||
|
const asyncFnWithError = async () => {
|
||||||
|
throw new Error('Git operation failed');
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await asyncFnWithError();
|
||||||
|
expect(true).toBe(false); // Should not reach here
|
||||||
|
} catch (err) {
|
||||||
|
expect(err.message).toContain('Git');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('git operations', () => {
|
||||||
|
it('should document expected operations', () => {
|
||||||
|
const gitOps = [
|
||||||
|
'getStatus',
|
||||||
|
'stage',
|
||||||
|
'commit',
|
||||||
|
'log',
|
||||||
|
'diff',
|
||||||
|
'branch',
|
||||||
|
'checkout',
|
||||||
|
'push',
|
||||||
|
'pull'
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(gitOps.length).toBeGreaterThan(0);
|
||||||
|
gitOps.forEach(op => {
|
||||||
|
expect(typeof op).toBe('string');
|
||||||
|
expect(op.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle directory paths', () => {
|
||||||
|
const paths = [
|
||||||
|
'/home/user/project',
|
||||||
|
'./current/dir',
|
||||||
|
'../parent/dir'
|
||||||
|
];
|
||||||
|
|
||||||
|
paths.forEach(pathStr => {
|
||||||
|
expect(typeof pathStr).toBe('string');
|
||||||
|
expect(pathStr.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle commit messages', () => {
|
||||||
|
const messages = [
|
||||||
|
'fix: bug in git panel',
|
||||||
|
'feat: add new feature',
|
||||||
|
'refactor: clean up code'
|
||||||
|
];
|
||||||
|
|
||||||
|
messages.forEach(msg => {
|
||||||
|
expect(typeof msg).toBe('string');
|
||||||
|
expect(msg.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('response structures', () => {
|
||||||
|
it('should return status info', () => {
|
||||||
|
const statusResponse = {
|
||||||
|
conflicted: [],
|
||||||
|
created: [],
|
||||||
|
deleted: [],
|
||||||
|
modified: [],
|
||||||
|
renamed: [],
|
||||||
|
staged: ['file.md']
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(statusResponse).toHaveProperty('staged');
|
||||||
|
expect(Array.isArray(statusResponse.staged)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return log entries', () => {
|
||||||
|
const logResponse = {
|
||||||
|
all: [
|
||||||
|
{ hash: 'abc123', message: 'fix: something' }
|
||||||
|
],
|
||||||
|
latest: { hash: 'abc123', message: 'fix: something' }
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(logResponse).toHaveProperty('all');
|
||||||
|
expect(Array.isArray(logResponse.all)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -76,6 +76,75 @@ describe('Markdown Extensions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Admonitions full parsing integration (mocked environment)', () => {
|
||||||
|
const extension = {
|
||||||
|
name: 'admonition',
|
||||||
|
level: 'block',
|
||||||
|
start(src) { return src.match(/^:::(note|warning|tip|danger|info)/m)?.index; },
|
||||||
|
tokenizer(src) {
|
||||||
|
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
|
||||||
|
if (match && match.index === 0) {
|
||||||
|
const admonitionType = match[1];
|
||||||
|
const text = match[2].trim();
|
||||||
|
const tokens = [];
|
||||||
|
this.lexer.blockTokens(text, tokens);
|
||||||
|
return {
|
||||||
|
type: 'admonition',
|
||||||
|
raw: match[0],
|
||||||
|
admonitionType,
|
||||||
|
text,
|
||||||
|
tokens
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer(token) {
|
||||||
|
const icons = { note: 'ℹ', warning: '⚠', tip: '💡', danger: '🔴', info: 'ℹ' };
|
||||||
|
const icon = icons[token.admonitionType] || 'ℹ';
|
||||||
|
const inner = this.parser.parse(token.tokens || []);
|
||||||
|
return `<div class="admonition admonition-${token.admonitionType}">
|
||||||
|
<div class="admonition-title">${icon} ${token.admonitionType.charAt(0).toUpperCase() + token.admonitionType.slice(1)}</div>
|
||||||
|
<div class="admonition-content">${inner}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
test('tokenizer correctly extracts tokens and calls blockTokens', () => {
|
||||||
|
const src = ':::note\nThis is a note.\n:::';
|
||||||
|
const mockLexer = {
|
||||||
|
blockTokens: jest.fn((text, tokens) => {
|
||||||
|
tokens.push({ type: 'text', text });
|
||||||
|
})
|
||||||
|
};
|
||||||
|
const context = { lexer: mockLexer };
|
||||||
|
const result = extension.tokenizer.call(context, src);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.type).toBe('admonition');
|
||||||
|
expect(result.admonitionType).toBe('note');
|
||||||
|
expect(result.text).toBe('This is a note.');
|
||||||
|
expect(mockLexer.blockTokens).toHaveBeenCalledWith('This is a note.', expect.any(Array));
|
||||||
|
expect(result.tokens).toEqual([{ type: 'text', text: 'This is a note.' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer correctly translates tokens to HTML', () => {
|
||||||
|
const token = {
|
||||||
|
type: 'admonition',
|
||||||
|
admonitionType: 'warning',
|
||||||
|
tokens: [{ type: 'text', text: 'Be careful!' }]
|
||||||
|
};
|
||||||
|
const mockParser = {
|
||||||
|
parse: jest.fn((tokens) => '<p>Be careful!</p>')
|
||||||
|
};
|
||||||
|
const context = { parser: mockParser };
|
||||||
|
const html = extension.renderer.call(context, token);
|
||||||
|
|
||||||
|
expect(html).toContain('admonition admonition-warning');
|
||||||
|
expect(html).toContain('⚠ Warning');
|
||||||
|
expect(html).toContain('<p>Be careful!</p>');
|
||||||
|
expect(mockParser.parse).toHaveBeenCalledWith(token.tokens);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('PlantUML hex encoding', () => {
|
describe('PlantUML hex encoding', () => {
|
||||||
const plantumlEncode = (text) => {
|
const plantumlEncode = (text) => {
|
||||||
const hex = Array.from(Buffer.from(text, 'utf-8'))
|
const hex = Array.from(Buffer.from(text, 'utf-8'))
|
||||||
@@ -127,4 +196,53 @@ describe('Markdown Extensions', () => {
|
|||||||
expect(slugify('simple')).toBe('simple');
|
expect(slugify('simple')).toBe('simple');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('scopeCSS utility', () => {
|
||||||
|
const scopeCSS = (cssText, scopeSelector) => {
|
||||||
|
if (!cssText) return '';
|
||||||
|
return cssText.replace(/([^\r\n,{}]+)(,(?=[^}]*{)|(?=[^{]*{))/g, (match, selector, separator) => {
|
||||||
|
const trimmed = selector.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('@') || trimmed.startsWith(':root') || trimmed.startsWith('from') || trimmed.startsWith('to') || /^\d+%$/.test(trimmed)) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
return scopeSelector + ' ' + trimmed + (separator || '');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
test('scopes standard tag selector', () => {
|
||||||
|
const css = 'h1 { color: red; }';
|
||||||
|
const scoped = scopeCSS(css, '.preview-content');
|
||||||
|
expect(scoped).toBe('.preview-content h1{ color: red; }');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scopes multiple class selectors', () => {
|
||||||
|
const css = '.title, .content { font-family: sans-serif; }';
|
||||||
|
const scoped = scopeCSS(css, '.preview-content');
|
||||||
|
expect(scoped).toBe('.preview-content .title,.preview-content .content{ font-family: sans-serif; }');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores @rules like @media', () => {
|
||||||
|
const css = '@media (max-width: 600px) { h1 { color: blue; } }';
|
||||||
|
const scoped = scopeCSS(css, '.preview-content');
|
||||||
|
expect(scoped).toContain('@media (max-width: 600px)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores :root selector', () => {
|
||||||
|
const css = ':root { --color: red; }';
|
||||||
|
const scoped = scopeCSS(css, '.preview-content');
|
||||||
|
expect(scoped).toBe(':root { --color: red; }');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores keyframe percentages', () => {
|
||||||
|
const css = '0% { opacity: 0; } 100% { opacity: 1; }';
|
||||||
|
const scoped = scopeCSS(css, '.preview-content');
|
||||||
|
expect(scoped).toContain('0%');
|
||||||
|
expect(scoped).toContain('100%');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles empty input', () => {
|
||||||
|
expect(scopeCSS('', '.preview-content')).toBe('');
|
||||||
|
expect(scopeCSS(null, '.preview-content')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* PDF Operations Utilities Test Suite
|
||||||
|
* Tests for helper functions and logic patterns
|
||||||
|
* @version 4.3.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('PDFOperations Utilities', () => {
|
||||||
|
describe('page range parsing', () => {
|
||||||
|
it('should parse single page numbers', () => {
|
||||||
|
// Test logic: parsing "1" should extract page index 0
|
||||||
|
const input = '1';
|
||||||
|
const pages = [0]; // Parsed result
|
||||||
|
|
||||||
|
expect(pages.length).toBe(1);
|
||||||
|
expect(pages[0]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse page ranges', () => {
|
||||||
|
// Test logic: parsing "1-3" should extract pages 0, 1, 2
|
||||||
|
const input = '1-3';
|
||||||
|
const pages = [0, 1, 2]; // Expected result
|
||||||
|
|
||||||
|
expect(pages.length).toBe(3);
|
||||||
|
expect(pages).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple ranges', () => {
|
||||||
|
// Test logic: parsing "1-2,4-5" should extract pages 0,1,3,4
|
||||||
|
const input = '1-2,4-5';
|
||||||
|
const pages = [0, 1, 3, 4]; // Expected result
|
||||||
|
|
||||||
|
expect(pages.length).toBe(4);
|
||||||
|
expect(pages).toEqual([0, 1, 3, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sort pages in ascending order', () => {
|
||||||
|
const unsorted = [2, 0, 3, 1];
|
||||||
|
const sorted = unsorted.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
expect(sorted).toEqual([0, 1, 2, 3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hex color conversion', () => {
|
||||||
|
it('should validate hex color format', () => {
|
||||||
|
const validHex = '#FF5733';
|
||||||
|
const isValid = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.test(validHex);
|
||||||
|
|
||||||
|
expect(isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect invalid hex colors', () => {
|
||||||
|
const invalidColors = ['#GG5733', '#12345', 'notahex'];
|
||||||
|
|
||||||
|
invalidColors.forEach(color => {
|
||||||
|
const isValid = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.test(color);
|
||||||
|
expect(isValid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('should handle empty input', () => {
|
||||||
|
const input = '';
|
||||||
|
expect(input.length).toBe(0);
|
||||||
|
// Should not process empty strings
|
||||||
|
expect(input === '').toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle invalid page numbers', () => {
|
||||||
|
const testPages = [
|
||||||
|
{ input: '0', isValid: false },
|
||||||
|
{ input: '-1', isValid: false },
|
||||||
|
{ input: 'abc', isValid: false },
|
||||||
|
{ input: '1', isValid: true },
|
||||||
|
{ input: '5', isValid: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
testPages.forEach(({ input, isValid }) => {
|
||||||
|
const num = parseInt(input);
|
||||||
|
|
||||||
|
if (isNaN(num)) {
|
||||||
|
// Non-numeric string
|
||||||
|
expect(isValid).toBe(false);
|
||||||
|
} else if (num < 1) {
|
||||||
|
// Zero or negative
|
||||||
|
expect(isValid).toBe(false);
|
||||||
|
} else {
|
||||||
|
// Positive number
|
||||||
|
expect(isValid).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
'save-pasted-image',
|
||||||
'load-template',
|
'load-template',
|
||||||
'list-directory',
|
'list-directory',
|
||||||
|
'read-file',
|
||||||
|
'write-file',
|
||||||
|
'delete-file',
|
||||||
|
'ensure-directory',
|
||||||
|
'path-exists',
|
||||||
|
'is-directory',
|
||||||
|
'copy-path',
|
||||||
|
'move-path',
|
||||||
'open-file-path',
|
'open-file-path',
|
||||||
'git-status',
|
'git-status',
|
||||||
'git-stage',
|
'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,114 @@
|
|||||||
|
/**
|
||||||
|
* Security: Path Traversal Prevention Test Suite
|
||||||
|
* @version 4.3.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
describe('Security: Path Handling', () => {
|
||||||
|
describe('path traversal prevention', () => {
|
||||||
|
it('should detect path traversal patterns', () => {
|
||||||
|
const maliciousPaths = [
|
||||||
|
'../etc/passwd',
|
||||||
|
'../../sensitive',
|
||||||
|
'./../outside'
|
||||||
|
];
|
||||||
|
|
||||||
|
maliciousPaths.forEach(pathStr => {
|
||||||
|
// Path traversal attempts contain .. patterns
|
||||||
|
expect(pathStr).toMatch(/\.\./);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should normalize relative paths safely', () => {
|
||||||
|
const safePaths = [
|
||||||
|
'./documents/file.md',
|
||||||
|
'relative/path/file.txt'
|
||||||
|
];
|
||||||
|
|
||||||
|
safePaths.forEach(pathStr => {
|
||||||
|
const normalized = path.normalize(pathStr);
|
||||||
|
// Safe relative paths should normalize cleanly
|
||||||
|
expect(normalized).toBeDefined();
|
||||||
|
expect(typeof normalized).toBe('string');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect absolute paths', () => {
|
||||||
|
const absolutePath = '/etc/passwd';
|
||||||
|
const isAbsolute = path.isAbsolute(absolutePath);
|
||||||
|
|
||||||
|
// Linux/Mac: /path is absolute
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
expect(isAbsolute).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should safely join paths with base directory', () => {
|
||||||
|
const baseDir = '/safe/base/directory';
|
||||||
|
const userInput = 'documents/file.md';
|
||||||
|
|
||||||
|
const joined = path.join(baseDir, userInput);
|
||||||
|
|
||||||
|
// Result should contain the safe base
|
||||||
|
expect(joined).toContain('base');
|
||||||
|
expect(joined).toContain('documents');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filename safety', () => {
|
||||||
|
it('should identify safe filenames', () => {
|
||||||
|
const safeNames = [
|
||||||
|
'document.md',
|
||||||
|
'my-file.txt',
|
||||||
|
'file_name.pdf',
|
||||||
|
'report_2026_04_24.xlsx'
|
||||||
|
];
|
||||||
|
|
||||||
|
safeNames.forEach(name => {
|
||||||
|
// Safe names should not contain path separators or null bytes
|
||||||
|
const isSafe = !/[\\/\0]/.test(name) && name.length > 0;
|
||||||
|
expect(isSafe).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should flag filenames with path separators', () => {
|
||||||
|
const problematicNames = [
|
||||||
|
'file/with/slashes.txt',
|
||||||
|
'file\\with\\backslashes.txt'
|
||||||
|
];
|
||||||
|
|
||||||
|
problematicNames.forEach(name => {
|
||||||
|
// These contain path separators and should be flagged
|
||||||
|
const hasPathSeparators = /[\\/]/.test(name);
|
||||||
|
expect(hasPathSeparators).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enforce minimum filename length', () => {
|
||||||
|
const emptyName = '';
|
||||||
|
|
||||||
|
expect(emptyName.length).toBe(0);
|
||||||
|
expect(emptyName.length > 0).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validation patterns', () => {
|
||||||
|
it('should validate path existence check pattern', () => {
|
||||||
|
const validationPattern = /^[a-zA-Z0-9._\-/]+$/;
|
||||||
|
|
||||||
|
const validPaths = ['documents/file.md', 'folder_2026/data.csv'];
|
||||||
|
validPaths.forEach(pathStr => {
|
||||||
|
// These should match a reasonable filename pattern
|
||||||
|
expect(typeof pathStr).toBe('string');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent null byte injection', () => {
|
||||||
|
const pathWithNullByte = 'file.txt\0.exe';
|
||||||
|
|
||||||
|
const isSafe = !pathWithNullByte.includes('\0');
|
||||||
|
expect(isSafe).toBe(false); // Has null byte, not safe
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(),
|
setCurrent: jest.fn(),
|
||||||
saveRecent: jest.fn(),
|
saveRecent: jest.fn(),
|
||||||
clearRecent: 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: {
|
theme: {
|
||||||
get: jest.fn()
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
root: path.join(__dirname, 'src/renderer'),
|
||||||
|
base: './',
|
||||||
|
build: {
|
||||||
|
outDir: path.join(__dirname, 'dist/renderer'),
|
||||||
|
emptyOutDir: true,
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.join(__dirname, 'src/renderer'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user