mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 17:29:29 +05:30
Compare commits
6
Commits
b83ba91731
...
v4.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0be46d4bac | ||
|
|
1e24b52f3e | ||
|
|
58bd19ecd1 | ||
|
|
7ab5a0ddb4 | ||
|
|
efca458495 | ||
|
|
c4dcbd8caf |
@@ -25,6 +25,13 @@ jobs:
|
|||||||
- name: Download external tools (pandoc)
|
- name: Download external tools (pandoc)
|
||||||
run: node scripts/download-tools.js
|
run: node scripts/download-tools.js
|
||||||
|
|
||||||
|
- name: Bundle MarkItDown (optional, best effort)
|
||||||
|
# Freezes markitdown + Python into bin/linux/markitdown; failure is
|
||||||
|
# non-fatal — the build then ships without it and the app falls back
|
||||||
|
# to a system markitdown at runtime.
|
||||||
|
run: npm run bundle:markitdown
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
@@ -59,6 +66,12 @@ jobs:
|
|||||||
- name: Download external tools (pandoc)
|
- name: Download external tools (pandoc)
|
||||||
run: node scripts/download-tools.js
|
run: node scripts/download-tools.js
|
||||||
|
|
||||||
|
- name: Bundle MarkItDown (optional, best effort)
|
||||||
|
# Windows runners ship python + venv; non-fatal on failure — the
|
||||||
|
# package then omits the bundled binary by design
|
||||||
|
run: npm run bundle:markitdown
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
@@ -94,8 +107,44 @@ jobs:
|
|||||||
dist/*.zip
|
dist/*.zip
|
||||||
retention-days: 5
|
retention-days: 5
|
||||||
|
|
||||||
|
build-macos:
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Download external tools (pandoc)
|
||||||
|
run: node scripts/download-tools.js
|
||||||
|
|
||||||
|
- name: Bundle MarkItDown (optional, best effort)
|
||||||
|
run: npm run bundle:markitdown
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Build macOS packages
|
||||||
|
run: npm run build:mac -- --publish=never
|
||||||
|
|
||||||
|
- name: Upload macOS artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: macos-artifacts
|
||||||
|
path: |
|
||||||
|
dist/*.dmg
|
||||||
|
dist/*.zip
|
||||||
|
retention-days: 5
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: [build-linux, build-windows]
|
needs: [build-linux, build-windows, build-macos]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -115,6 +164,13 @@ jobs:
|
|||||||
name: windows-artifacts
|
name: windows-artifacts
|
||||||
path: dist
|
path: dist
|
||||||
|
|
||||||
|
- name: Download macOS artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
name: macos-artifacts
|
||||||
|
path: dist
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -51,12 +51,24 @@ A powerful cross-platform Markdown editor and document converter powered by Pand
|
|||||||
- **ASCII Art Generator** - Create text banners and diagrams
|
- **ASCII Art Generator** - Create text banners and diagrams
|
||||||
- **Word templates** - Use custom Word templates for enhanced exports
|
- **Word templates** - Use custom Word templates for enhanced exports
|
||||||
- **Import documents** - Import from 30+ formats (DOCX, PDF, HTML, etc.)
|
- **Import documents** - Import from 30+ formats (DOCX, PDF, HTML, etc.)
|
||||||
|
- **MarkItDown import** - Any file → Markdown via [Microsoft MarkItDown](https://github.com/microsoft/markitdown): PDF, DOCX, PPTX, XLSX, Outlook .msg, EPUB, images, ZIP (audio/OCR with the `[all]` extras)
|
||||||
|
- **Excel export** - Markdown tables to native .xlsx workbooks (one sheet per table)
|
||||||
|
- **AI Assistant** - Multi-provider AI help (OpenAI/Anthropic/Ollama/LM Studio): chat panel, summarize/improve/translate commands, grammar proofreading
|
||||||
|
- **Inline comments** - Anchor-based document comments in `.comments/` sidecars with F8 navigation
|
||||||
|
- **Wiki-links & Backlinks** - `[[Note]]` links with click-to-create and a "what links here?" panel (local knowledge base)
|
||||||
|
- **Crash recovery** - Session restore of open tabs and unsaved buffers after a crash
|
||||||
|
- **Version history** - Automatic pre-save snapshots with restore/diff/delete from the History panel
|
||||||
|
- **Vim mode & snippet expansion** - Vim keybindings toggle; Tab expands saved snippets
|
||||||
|
- **Quick Note** - Global scratchpad (Ctrl+Alt+Q) that appends to `notes/quick-notes.md`
|
||||||
|
- **Real PDF encryption** - Password protection, removal, and permissions actually work
|
||||||
|
- **Offline math & diagrams** - KaTeX bundled locally; PlantUML renders locally when the CLI is installed
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- [Node.js](https://nodejs.org/) (v16 or later)
|
- [Node.js](https://nodejs.org/) (v16 or later)
|
||||||
- [Pandoc](https://pandoc.org/installing.html) (required for export functionality)
|
- [Pandoc](https://pandoc.org/installing.html) (required for export functionality)
|
||||||
|
- Optional: [MarkItDown](https://github.com/microsoft/markitdown) (`pip install "markitdown[all]"`) for any-file → Markdown import
|
||||||
|
|
||||||
### Install Dependencies
|
### Install Dependencies
|
||||||
```bash
|
```bash
|
||||||
@@ -96,9 +108,18 @@ npm run build:linux
|
|||||||
| Redo | Ctrl+Shift+Z |
|
| Redo | Ctrl+Shift+Z |
|
||||||
| New Tab | Ctrl+T |
|
| New Tab | Ctrl+T |
|
||||||
| Close Tab | Ctrl+W |
|
| Close Tab | Ctrl+W |
|
||||||
| Toggle Preview | Ctrl+Shift+P |
|
| Toggle Preview | Ctrl+Shift+V |
|
||||||
| Zoom In | Ctrl+Shift++ |
|
| Zoom In | Ctrl+Shift++ |
|
||||||
| Zoom Out | Ctrl+Shift+- |
|
| Zoom Out | Ctrl+Shift+- |
|
||||||
|
| Command Palette | Ctrl+Shift+P |
|
||||||
|
| Zen Mode | F11 |
|
||||||
|
| Writing Analytics | Ctrl+Shift+A |
|
||||||
|
| Quick Note | Ctrl+Alt+Q |
|
||||||
|
| Universal Converter | Ctrl+Shift+C |
|
||||||
|
| Table Generator | Ctrl+Shift+T |
|
||||||
|
| ASCII Art Generator | Ctrl+Shift+A |
|
||||||
|
| Next Comment | F8 |
|
||||||
|
| Add Comment at Cursor | Ctrl+Alt+M |
|
||||||
|
|
||||||
## Themes
|
## Themes
|
||||||
|
|
||||||
@@ -140,6 +161,43 @@ Open PDF files directly in MarkdownConverter:
|
|||||||
- Rotate pages left or right
|
- Rotate pages left or right
|
||||||
- Close PDF to return to editor
|
- Close PDF to return to editor
|
||||||
|
|
||||||
|
## Bundled Dependencies, Legal Notices & Credits
|
||||||
|
|
||||||
|
MarkdownConverter ships as a self-contained package. Everything needed for the
|
||||||
|
core workflows is **bundled**; a few large optional tools are detected from
|
||||||
|
the system when present.
|
||||||
|
|
||||||
|
### Bundled with the app
|
||||||
|
|
||||||
|
| Component | License | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| [Pandoc](https://pandoc.org) 3.9 | GPL-2.0+ (separate process) | 25+ export/import formats |
|
||||||
|
| [FFmpeg](https://ffmpeg.org) (via ffmpeg-static) | GPL-3.0+ build (separate process) | audio/video tools |
|
||||||
|
| [MarkItDown](https://github.com/microsoft/markitdown) (MIT) + embedded Python runtime (PSF) | MIT / PSF | any-file → Markdown import (PDF/DOCX/PPTX/XLSX/Outlook/EPUB/images/ZIP) |
|
||||||
|
| [sharp](https://sharp.pixelplumbing.com) + libvips | Apache-2.0 / LGPL-2.1+ (dynamic) | image tools |
|
||||||
|
| [KaTeX](https://katex.org), [marked](https://marked.js.org), [highlight.js], [DOMPurify], [mermaid], [CodeMirror 6], pdf-lib (@cantoo fork), pdfjs-dist, JSZip, simple-git | MIT / Apache-2.0 / BSD-3 / MPL-2.0 | editor, preview, PDF, Git |
|
||||||
|
| JetBrains Mono & Fira Code fonts | SIL OFL 1.1 | editor typography |
|
||||||
|
|
||||||
|
GPL-licensed tools run as **separate processes** (never linked into the app)
|
||||||
|
and their complete corresponding sources are offered in
|
||||||
|
[SOURCES.md](SOURCES.md). Full details: [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md),
|
||||||
|
also available inside the app under **Help → Third-Party Notices & Licenses**.
|
||||||
|
|
||||||
|
### Not bundled (used when installed)
|
||||||
|
|
||||||
|
LibreOffice (Office conversion), MiKTeX/TeX Live (LaTeX PDF), ImageMagick
|
||||||
|
(extra image formats), PlantUML + Java (local diagrams), Calibre (MOBI),
|
||||||
|
MarkItDown `[all]` extras (audio transcription / OCR).
|
||||||
|
|
||||||
|
### Credits
|
||||||
|
|
||||||
|
Built on open source: [Electron], [CodeMirror], [marked], [KaTeX],
|
||||||
|
[highlight.js], [DOMPurify](https://github.com/cure53/DOMPurify),
|
||||||
|
[mermaid](https://mermaid.js.org), [pdf-lib], [pdf.js](https://mozilla.github.io/pdf.js/),
|
||||||
|
[sharp]/libvips, [Pandoc], [FFmpeg], [MarkItDown] by Microsoft,
|
||||||
|
[simple-git], [JSZip], [JetBrains Mono], [Fira Code]. Thank you to all their
|
||||||
|
authors and maintainers.
|
||||||
|
|
||||||
## Open Source
|
## Open Source
|
||||||
|
|
||||||
MarkdownConverter is 100% open-source. All dependencies are permissively licensed:
|
MarkdownConverter is 100% open-source. All dependencies are permissively licensed:
|
||||||
@@ -162,4 +220,4 @@ Amit Haridas (amit.wh@gmail.com)
|
|||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
v4.5.0
|
v4.7.0
|
||||||
|
|||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
# Source Code Availability (GPL / LGPL Written Offer)
|
||||||
|
|
||||||
|
MarkdownConverter distributes the following binaries built from GPL-licensed
|
||||||
|
software. Per GPL §3(b), this document is the written offer: **corresponding
|
||||||
|
source code for the exact versions listed below is available on request for
|
||||||
|
at least three years from each release**, and permanently at the referenced
|
||||||
|
public locations. Write to: amit.wh@gmail.com (or open a GitHub issue at
|
||||||
|
https://github.com/amitwh/markdown-converter/issues).
|
||||||
|
|
||||||
|
## Pandoc — GPL-2.0-or-later
|
||||||
|
|
||||||
|
- Binary shipped: `bin/pandoc` (v3.9.0.2, official upstream release, unmodified)
|
||||||
|
- SHA-256 (linux): `7d124235998ecd3cdd9a463b1e5f6691a178b6461824c29a36170a0882f05597`
|
||||||
|
- Source: <https://github.com/jgm/pandoc/archive/refs/tags/3.9.0.2.tar.gz>
|
||||||
|
- Pandoc statically links Haskell libraries (GHC ecosystem, mostly BSD-3);
|
||||||
|
their sources are included in the upstream release tarball's dependency set.
|
||||||
|
|
||||||
|
## FFmpeg — GPL-3.0-or-later (build configuration)
|
||||||
|
|
||||||
|
- Binary shipped: `ffmpeg` provided by the npm package `ffmpeg-static@5.3.0`
|
||||||
|
(Linux: johnvansickle.com build; Windows: gyan.dev; macOS: evermeet.cx —
|
||||||
|
all `--enable-gpl` builds including x264/x265, per the build banner)
|
||||||
|
- Source:
|
||||||
|
- FFmpeg: <https://ffmpeg.org/releases/> (use the release matching
|
||||||
|
`ffmpeg -version` of the shipped binary)
|
||||||
|
- Build scripts & pinned versions: <https://github.com/eugeneware/ffmpeg-static>
|
||||||
|
- x264: <https://code.videolan.org/videolan/x264> ·
|
||||||
|
x265: <https://bitbucket.org/multicoreware/x265_git/> ·
|
||||||
|
other `--enable-lib*` components: their upstream sources (all free/open)
|
||||||
|
|
||||||
|
## PyInstaller bootloader (inside the bundled MarkItDown binary) — GPL-2.0 with boot-exception
|
||||||
|
|
||||||
|
- Binary shipped: `bin/markitdown` (MarkItDown 0.1.7 frozen with PyInstaller 6.x)
|
||||||
|
- PyInstaller grants a special exception allowing the bootloader to be
|
||||||
|
embedded in non-GPL frozen applications; source anyway:
|
||||||
|
<https://github.com/pyinstaller/pyinstaller>
|
||||||
|
- Everything frozen above the bootloader (markitdown + Python packages +
|
||||||
|
CPython runtime) is permissively licensed (MIT/Apache/BSD/PSF/MPL);
|
||||||
|
see THIRD-PARTY-NOTICES.md §2 for the list.
|
||||||
|
- CPython runtime source: <https://www.python.org/downloads/source/>
|
||||||
|
(PSF License — not GPL, listed here for completeness).
|
||||||
|
|
||||||
|
## libvips (via sharp prebuilt binaries) — LGPL-2.1-or-later
|
||||||
|
|
||||||
|
- Shipped as dynamically-loaded libraries from `@img/*` prebuilts for sharp 0.35.4
|
||||||
|
- Source: <https://github.com/libvips/libvips> · prebuilt bundle sources:
|
||||||
|
<https://github.com/lovell/sharp-builds>
|
||||||
|
- LGPL compliance: the app's own source is public (MIT) and the libraries
|
||||||
|
remain separately replaceable files in the installation directory
|
||||||
|
(`node_modules/@img/`), satisfying the relinking requirement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Versions and hashes above correspond to the release this file ships with;
|
||||||
|
update them when bumping bundled tool versions._
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Third-Party Notices & Licenses
|
||||||
|
|
||||||
|
MarkdownConverter (this app) is MIT-licensed. This document lists the
|
||||||
|
third-party components that are **distributed with** the application, their
|
||||||
|
licenses, and where to obtain source code. Full license texts for the
|
||||||
|
copyleft and font licenses referenced here are in the `third-party-licenses/`
|
||||||
|
folder shipped alongside this file (and in the source repository).
|
||||||
|
|
||||||
|
This product includes software developed by third parties under the licenses
|
||||||
|
below. Copyright and license notices are reproduced verbatim or referenced
|
||||||
|
per each license's terms.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. This application
|
||||||
|
|
||||||
|
**MarkdownConverter** — Copyright (C) 2024-2025 ConcreteInfo (Amit Haridas) —
|
||||||
|
MIT License. See the repository `LICENSE` file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Bundled external binaries
|
||||||
|
|
||||||
|
These run as separate operating-system processes, launched via `execFile`
|
||||||
|
with literal argv (never a shell, never linked into the app).
|
||||||
|
|
||||||
|
| Component | Version | License | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Pandoc | 3.9.0.2 | GPL-2.0-or-later | Downloaded at build time by `scripts/download-tools.js` (SHA-256 pinned); license: [GPL-2.0](third-party-licenses/GPL-2.0.txt) |
|
||||||
|
| FFmpeg | bundled by `ffmpeg-static` 5.3.0 | **GPL-3.0-or-later build** (`--enable-gpl --enable-libx264/x265`) | License text: <https://ffmpeg.org/legal.html>; source offer below |
|
||||||
|
| MarkItDown | 0.1.7 (+ Python deps) | MIT | Microsoft's any-file→Markdown importer, frozen with PyInstaller by `scripts/bundle-markitdown.js`; includes an embedded CPython runtime (PSF license) |
|
||||||
|
| sharp / libvips prebuilt binaries | 0.35.4 | Apache-2.0 / **LGPL-2.1-or-later** (libvips) | Dynamically loaded native addon; LGPL compliance: this app's full MIT source is public, enabling relinking; license: [LGPL-2.1](third-party-licenses/LGPL-2.1.txt) |
|
||||||
|
| KaTeX (CSS + fonts) | 0.18.5 | MIT | `assets/katex/` |
|
||||||
|
| JetBrains Mono, Fira Code fonts | — | SIL OFL 1.1 | `assets/fonts/`; license: [OFL-1.1](third-party-licenses/OFL-1.1.txt) |
|
||||||
|
| Electron | 41.x | MIT | and its bundled Chromium (BSD-style licenses) / Node.js (MIT) / OpenSSL (Apache-2.0) — see <https://www.electronjs.org/blog/electron-licensing> |
|
||||||
|
|
||||||
|
### GPL source availability (GPL §3 offer)
|
||||||
|
|
||||||
|
Corresponding source for every GPL-licensed binary distributed with this app
|
||||||
|
is available on written request and from these permanent locations — see
|
||||||
|
**[SOURCES.md](SOURCES.md)** for exact versions and URLs.
|
||||||
|
|
||||||
|
### Python packages inside the bundled MarkItDown binary
|
||||||
|
|
||||||
|
The frozen MarkItDown binary embeds CPython and (each MIT/Apache-2.0/BSD-3/
|
||||||
|
PSF/MPL-2.0 licensed unless noted): markitdown, onnxruntime (MIT), numpy
|
||||||
|
(BSD-3), magika (Apache-2.0), beautifulsoup4 / soupsieve (MIT), requests
|
||||||
|
(Apache-2.0) + urllib3/idna/charset-normalizer, certifi (MPL-2.0),
|
||||||
|
markdownify, defusedxml (PSF), protobuf (BSD-3), flatbuffers (Apache-2.0),
|
||||||
|
pdfminer.six (MIT), python-docx, python-pptx, openpyxl, extract-msg,
|
||||||
|
markdown-it-py / mdurl, pyinstaller (GPL-2.0-with-exception — build tool
|
||||||
|
only; its bootloader is embedded, source offer included in SOURCES.md),
|
||||||
|
Packaging, six, click, chardet (LGPL — dynamically loadable Python module).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. npm dependencies shipped in the app (runtime)
|
||||||
|
|
||||||
|
| Package | Version | License |
|
||||||
|
|---|---|---|
|
||||||
|
| @cantoo/pdf-lib | 2.9.1 | MIT |
|
||||||
|
| @codemirror/* (autocomplete, commands, lang-*, language, lint, search, state, theme-one-dark, view), codemirror | 6.x | MIT |
|
||||||
|
| @replit/codemirror-vim | 6.4.0 | MIT |
|
||||||
|
| core-util-is | 1.0.3 | MIT |
|
||||||
|
| docx | 9.6.1 | MIT |
|
||||||
|
| dompurify | 3.4.14 | (MPL-2.0 OR Apache-2.0) |
|
||||||
|
| electron-store | 10.1.0 | MIT |
|
||||||
|
| ffmpeg-static | 5.3.0 | GPL-3.0-or-later (binary; see §2) |
|
||||||
|
| highlight.js | 11.11.1 | BSD-3-Clause |
|
||||||
|
| html2pdf.js | 0.14.0 | MIT |
|
||||||
|
| jszip | 3.10.1 | (MIT OR GPL-3.0-or-later) |
|
||||||
|
| katex | 0.18.5 | MIT |
|
||||||
|
| marked, marked-footnote, marked-highlight | 17.x / 1.4 / 2.2 | MIT |
|
||||||
|
| mermaid | 11.17.2 | MIT |
|
||||||
|
| pdfjs-dist | 5.5.207 | Apache-2.0 |
|
||||||
|
| pdfkit | 0.17.2 | MIT |
|
||||||
|
| pizzip | 3.2.0 | (MIT OR GPL-3.0) |
|
||||||
|
| sharp | 0.35.4 | Apache-2.0 (+ LGPL libvips binaries; see §2) |
|
||||||
|
| simple-git | 3.36.0 | MIT |
|
||||||
|
| tslib | 2.8.1 | 0BSD |
|
||||||
|
|
||||||
|
(Development-only dependencies — electron-builder, eslint, prettier, jest,
|
||||||
|
cross-env, @testing-library/dom — are not distributed with the application.)
|
||||||
|
|
||||||
|
## 4. Optional external tools (NOT bundled)
|
||||||
|
|
||||||
|
These are detected and used when the user installs them; no copy is
|
||||||
|
distributed with this app, so no redistribution obligations arise:
|
||||||
|
|
||||||
|
- **LibreOffice** (MPL-2.0) — enhanced Office-format conversion
|
||||||
|
- **MiKTeX / TeX Live** (LPPL/GPL per component) — LaTeX PDF export
|
||||||
|
- **ImageMagick** (Apache-2.0-style) — extra image formats in the universal converter
|
||||||
|
- **PlantUML** (GPL-3.0) + a Java runtime — local diagram rendering
|
||||||
|
- **Calibre** (`ebook-convert`) — MOBI export
|
||||||
|
- **System MarkItDown with `[all]` extras** — audio transcription / OCR
|
||||||
|
|
||||||
|
## 5. Trademarks
|
||||||
|
|
||||||
|
Product names used to describe compatibility (Pandoc, FFmpeg, LibreOffice,
|
||||||
|
MarkItDown, Microsoft, Excel, Word, PowerPoint…) are trademarks of their
|
||||||
|
respective owners and are not affiliated with this project.
|
||||||
|
|
||||||
|
## 6. License texts
|
||||||
|
|
||||||
|
See the `third-party-licenses/` directory: `GPL-2.0.txt`, `LGPL-2.1.txt`,
|
||||||
|
`MPL-2.0.txt`, `Apache-2.0.txt`, `OFL-1.1.txt`, `PSF-Python.txt`. MIT and
|
||||||
|
BSD-3-Clause texts are short and reproduced in each package's own repository;
|
||||||
|
per-package LICENSE files also ship inside `node_modules/` in source form.
|
||||||
+122
@@ -1,5 +1,127 @@
|
|||||||
# PanConverter - Updates & Changelog
|
# PanConverter - Updates & Changelog
|
||||||
|
|
||||||
|
## Version 4.7.0 (2026-09-05)
|
||||||
|
|
||||||
|
### Bundling & Legal Compliance
|
||||||
|
- **MarkItDown is now bundled**: `npm run bundle:markitdown` freezes Microsoft's
|
||||||
|
markitdown (MIT) + embedded Python runtime into a single ~75MB per-platform
|
||||||
|
binary (`bin/<platform>/markitdown`) via PyInstaller (ML extras excluded);
|
||||||
|
the app prefers the bundled binary and falls back to system installs
|
||||||
|
- Packaging copies the bundled markitdown for Windows/macOS/Linux alongside Pandoc
|
||||||
|
- **THIRD-PARTY-NOTICES.md** — full license inventory of everything distributed
|
||||||
|
(bundled binaries, npm runtime deps, fonts, embedded Python packages)
|
||||||
|
- **SOURCES.md** — GPL §3(b) written source offer for Pandoc / FFmpeg (GPL build) /
|
||||||
|
PyInstaller bootloader, with pinned versions + SHA-256; LGPL relinking note for libvips
|
||||||
|
- **third-party-licenses/** — canonical texts: GPL-2.0, LGPL-2.1, MPL-2.0,
|
||||||
|
Apache-2.0, OFL-1.1, PSF-Python
|
||||||
|
- **Help → Third-Party Notices & Licenses** — in-app viewer for both documents
|
||||||
|
- **download-tools.js** now SHA-256 pins and verifies every downloaded artifact
|
||||||
|
(post-download and against the cache on every run; hard-fails on mismatch)
|
||||||
|
- README gains a "Bundled Dependencies, Legal Notices & Credits" section
|
||||||
|
- Large optional tools intentionally NOT bundled (documented): LibreOffice,
|
||||||
|
MiKTeX/TeX Live, ImageMagick, PlantUML+JRE, Calibre
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version 4.6.1 (2026-09-05)
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
- **MarkItDown import** — "File → Import with MarkItDown (Any Format)…" embeds
|
||||||
|
Microsoft's [markitdown](https://github.com/microsoft/markitdown) (MIT) as an
|
||||||
|
any-file → Markdown path: PDF, DOCX, PPTX, XLSX, Outlook .msg/.eml, EPUB,
|
||||||
|
images, CSV/JSON/XML, ZIP; audio transcription and OCR with the `[all]` extras
|
||||||
|
- Command auto-resolution: `markitdown` binary, then `python -m markitdown` /
|
||||||
|
`python3 -m markitdown` (probed once, cached)
|
||||||
|
- Same SEC-1 argv discipline as Pandoc (execFile only, paths never through a shell),
|
||||||
|
50MB input cap, 120s timeout, path-sanitized errors that surface markitdown's
|
||||||
|
actionable `pip install 'markitdown[...]'` hints
|
||||||
|
- Output written next to the source as `<name>.md` (numeric suffix instead of
|
||||||
|
overwriting) and opened in a new tab; `markitdown:available` / `markitdown:convert`
|
||||||
|
IPC for future renderer flows
|
||||||
|
- **AI Assistant: Anthropic-compatible provider** — any base URL speaking the
|
||||||
|
Anthropic messages schema (LiteLLM proxies, Bedrock gateways, local servers);
|
||||||
|
x-api-key + Bearer auth, keyless proxies supported, tolerates bases with or
|
||||||
|
without a trailing `/v1`
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- File → Open PDF crashed the PDF editor (null operation matched no section; now
|
||||||
|
defaults to Merge)
|
||||||
|
- Backlinks panel required the wrong module path (failed at registration)
|
||||||
|
- writing-studio engines/panels now await their IPC-backed settings/file backends
|
||||||
|
(eliminates `JSON.parse("[object Promise]")` crashes)
|
||||||
|
- Manuscript panel's window.prompt (unsupported in Electron) replaced with an
|
||||||
|
inline dialog; collaboration comment store made async to match its IO
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version 4.6.0 (2026-09-05)
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
#### AI Assistant Plugin (multi-provider)
|
||||||
|
- Chat sidebar panel with rolling conversation history and insert-reply-into-document
|
||||||
|
- Providers: OpenAI, Anthropic, Ollama, LM Studio, and any OpenAI-compatible endpoint
|
||||||
|
- All provider traffic proxied through the main process — API keys never enter the renderer and the CSP stays closed to AI endpoints
|
||||||
|
- Commands: AI Summarize / Improve / Explain / Translate selection
|
||||||
|
- Answers the writing-studio `ai:analyze` contract, finally enabling the Proofread panel
|
||||||
|
|
||||||
|
#### Collaboration Plugin (inline comments)
|
||||||
|
- Anchor-based comments stored in `.comments/` sidecar files (never exported, never committed)
|
||||||
|
- Comments sidebar panel: add at cursor, list, resolve, delete, jump to anchor
|
||||||
|
- Drift detection flags moved/edited anchors; F8 navigates to the next open comment
|
||||||
|
|
||||||
|
#### Local Knowledge Base (wiki-links + backlinks)
|
||||||
|
- `[[Note]]`, `[[Note|alias]]`, `[[Note#section]]` render as links in the preview (code blocks excluded)
|
||||||
|
- Clicking a wiki-link opens the note or offers to create it
|
||||||
|
- Backlinks sidebar panel scans the folder (bounded BFS) for documents linking to the current one
|
||||||
|
|
||||||
|
#### Crash Recovery / Session Restore
|
||||||
|
- Open tabs (paths + unsaved buffer content) snapshotted to localStorage, debounced on edits, on tab changes, on unload, and once a minute
|
||||||
|
- Restore prompt on launch with per-tab restore, clean-fresh option, and 2MB content budget
|
||||||
|
|
||||||
|
#### Document Version History
|
||||||
|
- Every save snapshots the previous on-disk content to `<userData>/versions/`
|
||||||
|
- History sidebar panel: list, restore (with safety snapshot), unified diff vs. current, delete, manual "save version now"
|
||||||
|
- Per-document pruning (20 versions), path-hash storage, id-validated reads
|
||||||
|
|
||||||
|
#### Editor
|
||||||
|
- Vim keybindings (View → Vim Mode, persisted, live toggle via CodeMirror Compartment)
|
||||||
|
- Snippet Tab-expansion: type a snippet name and press Tab to insert it
|
||||||
|
- Zen Mode word-goal setter (the HUD progress bar finally has UI)
|
||||||
|
|
||||||
|
#### Export / Conversion
|
||||||
|
- **Real PDF encryption**: pdf-lib swapped for @cantoo/pdf-lib — encrypt/decrypt/permissions now actually work (UI auto-enables via the capability probe)
|
||||||
|
- **XLSX export**: markdown tables → native Excel workbook, one sheet per table (no Pandoc needed)
|
||||||
|
- **ODT headers/footers + page size**: real ODF styles.xml patching replaces the empty stub
|
||||||
|
- **Local PlantUML rendering**: diagrams render on-machine via the `plantuml` CLI when installed; plantuml.com stays as fallback
|
||||||
|
- **KaTeX bundled locally** (CSS + fonts): math renders offline, no CDN calls
|
||||||
|
- Writing heatmap (GitHub-style 30-day grid) in the writing-studio Goals panel
|
||||||
|
|
||||||
|
#### Platform
|
||||||
|
- Quick Note global scratchpad (Ctrl+Alt+Q, works when unfocused; appends to `notes/quick-notes.md`)
|
||||||
|
- Deep link protocol `markdownconverter://open?path=…`
|
||||||
|
- REPL confirmation dialog before first code execution per language per session (unsandboxed-execution guard rail)
|
||||||
|
- Writing-studio's four sidebar panels (Manuscript/Goals/Snapshots/Proofread) are now actually wired with rail icons
|
||||||
|
- Plugin sidebar panels get automatic rail icons via `registerPanel({icon})`
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- `Ctrl+Shift+P` collision: PDF (Enhanced) export now `Ctrl+Alt+Shift+P`; Command Palette keeps `Ctrl+Shift+P`
|
||||||
|
- Universal Converter's Pandoc tool no longer always reports "not installed" (`checkConverterAvailable` gained a pandoc case with bundled-binary check)
|
||||||
|
- CLI headless export: removed dangling `--css` / `--reference-doc` flags that made pandoc exit with an error; `--self-contained` replaced with `--standalone` (Pandoc 3.x)
|
||||||
|
- Removed dead "Open Export Options Dialog…" button from the converter dialog
|
||||||
|
- Removed duplicate `styles-zen.css` include
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- AI provider requests carry size caps (200KB prompt), timeouts (120s), and user-safe error surfaces
|
||||||
|
- Version-history reads validate ids against traversal; history listing requires a valid document path
|
||||||
|
- PlantUML local rendering removes the diagram-text exfiltration path when a local CLI exists (CVE-MC-007 follow-up)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- New suites: OdtStyling, AiProviders, ai-assistant prompts, collaboration comment-store, wiki-links/backlinks, session-store, XlsxExporter, VersionHistory
|
||||||
|
- PDFOperations encryption tests rewritten for the real-encryption reality
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Version 4.0.0 (2026-03-04)
|
## Version 4.0.0 (2026-03-04)
|
||||||
|
|
||||||
### Major Changes
|
### Major Changes
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* electron-builder configuration (replaces the static package.json "build"
|
||||||
|
* section so it can be computed dynamically).
|
||||||
|
*
|
||||||
|
* Why a JS config: the bundled markitdown binary is OPTIONAL at build time —
|
||||||
|
* PyInstaller can only build for the host OS, so e.g. a Windows package
|
||||||
|
* built on a machine without bin/win32/markitdown.exe must simply omit it
|
||||||
|
* (the app falls back to system-installed markitdown at runtime) instead of
|
||||||
|
* failing on a missing extraFiles source.
|
||||||
|
*
|
||||||
|
* Everything else mirrors the previous static config verbatim.
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
/** Common extraFiles for a platform: pandoc always, markitdown when built. */
|
||||||
|
function toolExtraFiles(platform) {
|
||||||
|
const exe = platform === 'win32' ? '.exe' : '';
|
||||||
|
const files = [{ from: `bin/${platform}/pandoc${exe}`, to: `bin/pandoc${exe}` }];
|
||||||
|
const markitdown = `bin/${platform}/markitdown${exe}`;
|
||||||
|
if (fs.existsSync(path.join(__dirname, markitdown))) {
|
||||||
|
files.push({ from: markitdown, to: `bin/markitdown${exe}` });
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`[electron-builder.config] ${markitdown} not found — building WITHOUT the ` +
|
||||||
|
'bundled markitdown (the app will use a system install if present). ' +
|
||||||
|
'Run "npm run bundle:markitdown" on this platform to bundle it.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
appId: 'com.concreteinfo.markdownconverter',
|
||||||
|
productName: 'MarkdownConverter',
|
||||||
|
copyright: 'Copyright (C) 2024-2025 ConcreteInfo',
|
||||||
|
directories: { output: 'dist' },
|
||||||
|
icon: 'assets/icon',
|
||||||
|
files: [
|
||||||
|
'src/**/*',
|
||||||
|
'assets/**/*',
|
||||||
|
'scripts/**/*',
|
||||||
|
'node_modules/**/*',
|
||||||
|
'package.json',
|
||||||
|
'THIRD-PARTY-NOTICES.md',
|
||||||
|
'SOURCES.md',
|
||||||
|
'third-party-licenses/**/*',
|
||||||
|
],
|
||||||
|
asarUnpack: [
|
||||||
|
'node_modules/ffmpeg-static/**',
|
||||||
|
'node_modules/sharp/**',
|
||||||
|
'node_modules/@img/**',
|
||||||
|
'node_modules/@napi-rs/**',
|
||||||
|
'assets/fonts/**',
|
||||||
|
],
|
||||||
|
fileAssociations: [
|
||||||
|
{
|
||||||
|
ext: 'md',
|
||||||
|
name: 'Markdown Document',
|
||||||
|
description: 'Markdown Document',
|
||||||
|
mimeType: 'text/markdown',
|
||||||
|
role: 'Editor',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ext: 'markdown',
|
||||||
|
name: 'Markdown Document',
|
||||||
|
description: 'Markdown Document',
|
||||||
|
mimeType: 'text/markdown',
|
||||||
|
role: 'Editor',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ext: 'pdf',
|
||||||
|
name: 'PDF Document',
|
||||||
|
description: 'PDF Document',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
role: 'Editor',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mac: {
|
||||||
|
category: 'public.app-category.productivity',
|
||||||
|
identity: null,
|
||||||
|
extraFiles: toolExtraFiles('darwin'),
|
||||||
|
},
|
||||||
|
win: {
|
||||||
|
target: [
|
||||||
|
{ target: 'nsis', arch: ['x64'] },
|
||||||
|
{ target: 'portable', arch: ['x64'] },
|
||||||
|
{ target: 'zip', arch: ['x64'] },
|
||||||
|
],
|
||||||
|
artifactName: '${productName}-${version}-${arch}.${ext}',
|
||||||
|
requestedExecutionLevel: 'asInvoker',
|
||||||
|
legalTrademarks: 'Copyright (C) 2024-2025 ConcreteInfo',
|
||||||
|
verifyUpdateCodeSignature: false,
|
||||||
|
signAndEditExecutable: false,
|
||||||
|
extraFiles: toolExtraFiles('win32'),
|
||||||
|
},
|
||||||
|
nsis: {
|
||||||
|
oneClick: false,
|
||||||
|
perMachine: false,
|
||||||
|
allowToChangeInstallationDirectory: true,
|
||||||
|
displayLanguageSelector: true,
|
||||||
|
createDesktopShortcut: true,
|
||||||
|
createStartMenuShortcut: true,
|
||||||
|
shortcutName: 'MarkdownConverter',
|
||||||
|
runAfterFinish: true,
|
||||||
|
menuCategory: 'Productivity',
|
||||||
|
license: 'LICENSE',
|
||||||
|
warningsAsErrors: false,
|
||||||
|
artifactName: '${productName}-Setup-${version}.${ext}',
|
||||||
|
deleteAppDataOnUninstall: false,
|
||||||
|
differentialPackage: true,
|
||||||
|
},
|
||||||
|
linux: {
|
||||||
|
target: ['deb', 'AppImage', 'snap'],
|
||||||
|
category: 'Utility',
|
||||||
|
maintainer: 'ConcreteInfo <amit.wh@gmail.com>',
|
||||||
|
extraFiles: toolExtraFiles('linux'),
|
||||||
|
},
|
||||||
|
deb: {
|
||||||
|
depends: ['imagemagick', 'libreoffice-common'],
|
||||||
|
description: 'Professional Markdown editor and universal file converter',
|
||||||
|
maintainer: 'ConcreteInfo <amit.wh@gmail.com>',
|
||||||
|
},
|
||||||
|
};
|
||||||
Generated
+1099
-2163
File diff suppressed because it is too large
Load Diff
+16
-137
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "markdown-converter",
|
"name": "markdown-converter",
|
||||||
"version": "4.5.0",
|
"version": "4.7.0",
|
||||||
"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": {
|
||||||
@@ -12,18 +12,19 @@
|
|||||||
"lint:fix": "eslint src tests --fix",
|
"lint:fix": "eslint src tests --fix",
|
||||||
"format": "prettier --write src tests",
|
"format": "prettier --write src tests",
|
||||||
"format:check": "prettier --check src tests",
|
"format:check": "prettier --check src tests",
|
||||||
"build": "electron-builder",
|
"build": "electron-builder --config electron-builder.config.js",
|
||||||
"build:win": "electron-builder --win",
|
"build:win": "electron-builder --config electron-builder.config.js --win",
|
||||||
"build:win-signed": "cross-env CSC_LINK=code-signing-cert.pfx electron-builder --win",
|
"build:win-signed": "cross-env CSC_LINK=code-signing-cert.pfx electron-builder --config electron-builder.config.js --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 --config electron-builder.config.js --win",
|
||||||
"create-cert": "powershell -ExecutionPolicy Bypass -File scripts/create-selfsigned-cert.ps1",
|
"create-cert": "powershell -ExecutionPolicy Bypass -File scripts/create-selfsigned-cert.ps1",
|
||||||
"build:mac": "electron-builder --mac",
|
"build:mac": "electron-builder --config electron-builder.config.js --mac",
|
||||||
"build:linux": "electron-builder --linux",
|
"build:linux": "electron-builder --config electron-builder.config.js --linux",
|
||||||
"build:linux-ci": "electron-builder --linux deb AppImage",
|
"build:linux-ci": "electron-builder --config electron-builder.config.js --linux deb AppImage",
|
||||||
"build:local": "electron-builder --linux --win",
|
"build:local": "electron-builder --config electron-builder.config.js --linux --win",
|
||||||
"dist": "electron-builder --publish=never",
|
"dist": "electron-builder --config electron-builder.config.js --publish=never",
|
||||||
"dist:all": "electron-builder -mwl",
|
"dist:all": "electron-builder --config electron-builder.config.js -mwl",
|
||||||
"download-tools": "node scripts/download-tools.js",
|
"download-tools": "node scripts/download-tools.js",
|
||||||
|
"bundle:markitdown": "node scripts/bundle-markitdown.js",
|
||||||
"generate-icons": "node scripts/generate-icons.js"
|
"generate-icons": "node scripts/generate-icons.js"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -55,6 +56,7 @@
|
|||||||
"prettier": "^3.7.4"
|
"prettier": "^3.7.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@cantoo/pdf-lib": "^2.9.1",
|
||||||
"@codemirror/autocomplete": "^6.20.1",
|
"@codemirror/autocomplete": "^6.20.1",
|
||||||
"@codemirror/commands": "^6.10.2",
|
"@codemirror/commands": "^6.10.2",
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
@@ -69,25 +71,25 @@
|
|||||||
"@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",
|
||||||
|
"@replit/codemirror-vim": "^6.4.0",
|
||||||
"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": "^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",
|
"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",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
"katex": "^0.18.5",
|
||||||
"marked": "^17.0.3",
|
"marked": "^17.0.3",
|
||||||
"marked-footnote": "^1.4.0",
|
"marked-footnote": "^1.4.0",
|
||||||
"marked-highlight": "^2.2.3",
|
"marked-highlight": "^2.2.3",
|
||||||
"mermaid": "^11.12.3",
|
"mermaid": "^11.12.3",
|
||||||
"pdf-lib": "^1.17.1",
|
|
||||||
"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",
|
||||||
"sharp": "^0.34.3",
|
"sharp": "^0.35.4",
|
||||||
"simple-git": "^3.32.3",
|
"simple-git": "^3.32.3",
|
||||||
"tslib": "^2.8.1"
|
"tslib": "^2.8.1"
|
||||||
},
|
},
|
||||||
@@ -97,128 +99,5 @@
|
|||||||
"lodash.pick": "npm:lodash@^4.17.21",
|
"lodash.pick": "npm:lodash@^4.17.21",
|
||||||
"lodash-es": "^4.18.1",
|
"lodash-es": "^4.18.1",
|
||||||
"lodash": "^4.17.21"
|
"lodash": "^4.17.21"
|
||||||
},
|
|
||||||
"build": {
|
|
||||||
"appId": "com.concreteinfo.markdownconverter",
|
|
||||||
"productName": "MarkdownConverter",
|
|
||||||
"copyright": "Copyright (C) 2024-2025 ConcreteInfo",
|
|
||||||
"directories": {
|
|
||||||
"output": "dist"
|
|
||||||
},
|
|
||||||
"icon": "assets/icon",
|
|
||||||
"files": [
|
|
||||||
"src/**/*",
|
|
||||||
"assets/**/*",
|
|
||||||
"scripts/**/*",
|
|
||||||
"node_modules/**/*",
|
|
||||||
"package.json"
|
|
||||||
],
|
|
||||||
"asarUnpack": [
|
|
||||||
"node_modules/ffmpeg-static/**",
|
|
||||||
"node_modules/sharp/**",
|
|
||||||
"node_modules/@img/**",
|
|
||||||
"node_modules/@napi-rs/**",
|
|
||||||
"assets/fonts/**"
|
|
||||||
],
|
|
||||||
"extraFiles": [],
|
|
||||||
"fileAssociations": [
|
|
||||||
{
|
|
||||||
"ext": "md",
|
|
||||||
"name": "Markdown Document",
|
|
||||||
"description": "Markdown Document",
|
|
||||||
"mimeType": "text/markdown",
|
|
||||||
"role": "Editor"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ext": "markdown",
|
|
||||||
"name": "Markdown Document",
|
|
||||||
"description": "Markdown Document",
|
|
||||||
"mimeType": "text/markdown",
|
|
||||||
"role": "Editor"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ext": "pdf",
|
|
||||||
"name": "PDF Document",
|
|
||||||
"description": "PDF Document",
|
|
||||||
"mimeType": "application/pdf",
|
|
||||||
"role": "Editor"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"mac": {
|
|
||||||
"category": "public.app-category.productivity",
|
|
||||||
"identity": null
|
|
||||||
},
|
|
||||||
"win": {
|
|
||||||
"target": [
|
|
||||||
{
|
|
||||||
"target": "nsis",
|
|
||||||
"arch": [
|
|
||||||
"x64"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"target": "portable",
|
|
||||||
"arch": [
|
|
||||||
"x64"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"target": "zip",
|
|
||||||
"arch": [
|
|
||||||
"x64"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
|
||||||
"requestedExecutionLevel": "asInvoker",
|
|
||||||
"legalTrademarks": "Copyright (C) 2024-2025 ConcreteInfo",
|
|
||||||
"verifyUpdateCodeSignature": false,
|
|
||||||
"signAndEditExecutable": false,
|
|
||||||
"extraFiles": [
|
|
||||||
{
|
|
||||||
"from": "bin/win32/pandoc.exe",
|
|
||||||
"to": "bin/pandoc.exe"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nsis": {
|
|
||||||
"oneClick": false,
|
|
||||||
"perMachine": false,
|
|
||||||
"allowToChangeInstallationDirectory": true,
|
|
||||||
"displayLanguageSelector": true,
|
|
||||||
"createDesktopShortcut": true,
|
|
||||||
"createStartMenuShortcut": true,
|
|
||||||
"shortcutName": "MarkdownConverter",
|
|
||||||
"runAfterFinish": true,
|
|
||||||
"menuCategory": "Productivity",
|
|
||||||
"license": "LICENSE",
|
|
||||||
"warningsAsErrors": false,
|
|
||||||
"artifactName": "${productName}-Setup-${version}.${ext}",
|
|
||||||
"deleteAppDataOnUninstall": false,
|
|
||||||
"differentialPackage": true
|
|
||||||
},
|
|
||||||
"linux": {
|
|
||||||
"target": [
|
|
||||||
"deb",
|
|
||||||
"AppImage",
|
|
||||||
"snap"
|
|
||||||
],
|
|
||||||
"category": "Utility",
|
|
||||||
"maintainer": "ConcreteInfo <amit.wh@gmail.com>",
|
|
||||||
"extraFiles": [
|
|
||||||
{
|
|
||||||
"from": "bin/linux/pandoc",
|
|
||||||
"to": "bin/pandoc"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"deb": {
|
|
||||||
"depends": [
|
|
||||||
"imagemagick",
|
|
||||||
"libreoffice-common"
|
|
||||||
],
|
|
||||||
"description": "Professional Markdown editor and universal file converter",
|
|
||||||
"maintainer": "ConcreteInfo <amit.wh@gmail.com>"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Builds a self-contained MarkItDown binary into bin/<platform>/markitdown
|
||||||
|
* so the app can bundle Microsoft's markitdown (MIT) without requiring users
|
||||||
|
* to have Python installed. Run via `npm run bundle:markitdown` before
|
||||||
|
* packaging (skipped automatically when the binary already exists).
|
||||||
|
*
|
||||||
|
* How: creates a throwaway virtualenv, pip-installs markitdown (with the
|
||||||
|
* document extras) + PyInstaller, then freezes the CLI into a onefile
|
||||||
|
* executable. Heavy optional ML deps (torch/scipy/pandas/…) are excluded —
|
||||||
|
* the resulting ~75MB binary covers PDF/DOCX/PPTX/XLSX/Outlook/HTML/EPUB/
|
||||||
|
* images/CSV/JSON/XML/ZIP. Audio transcription and OCR intentionally stay
|
||||||
|
* unbundled (multi-GB); the app falls back to a system `markitdown[all]`
|
||||||
|
* install for those.
|
||||||
|
*
|
||||||
|
* Requires on the build machine: python3 (with venv + pip) — on Debian/
|
||||||
|
* Ubuntu that is `sudo apt install python3 python3-venv`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const { execFileSync } = require('child_process');
|
||||||
|
|
||||||
|
const platform = process.platform; // linux | win32 | darwin
|
||||||
|
const exeName = platform === 'win32' ? 'markitdown.exe' : 'markitdown';
|
||||||
|
const binDir = path.join(__dirname, '..', 'bin', platform);
|
||||||
|
const destFile = path.join(binDir, exeName);
|
||||||
|
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mid-bundle-'));
|
||||||
|
|
||||||
|
/** Run a command, inheriting stdout/stderr so build progress is visible. */
|
||||||
|
function run(cmd, args, opts = {}) {
|
||||||
|
execFileSync(cmd, args, { stdio: 'inherit', ...opts });
|
||||||
|
}
|
||||||
|
|
||||||
|
function pythonBin(venvDir, name) {
|
||||||
|
return path.join(venvDir, platform === 'win32' ? 'Scripts' : 'bin', name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
if (fs.existsSync(destFile)) {
|
||||||
|
console.log(`[bundle-markitdown] ${destFile} already present — skipping.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const venvDir = path.join(workDir, 'venv');
|
||||||
|
console.log('[bundle-markitdown] Creating virtualenv...');
|
||||||
|
run('python3', ['-m', 'venv', venvDir]);
|
||||||
|
|
||||||
|
console.log('[bundle-markitdown] Installing markitdown + PyInstaller...');
|
||||||
|
run(pythonBin(venvDir, 'pip'), [
|
||||||
|
'install', '--quiet', '--upgrade', 'pip',
|
||||||
|
]);
|
||||||
|
run(pythonBin(venvDir, 'pip'), [
|
||||||
|
'install', '--quiet',
|
||||||
|
// Document extras only — [all] would pull the ML stack (torch etc.)
|
||||||
|
'markitdown[pdf,docx,pptx,xlsx,outlook]',
|
||||||
|
'pyinstaller',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// PyInstaller entrypoint mirroring the markitdown CLI
|
||||||
|
const entry = path.join(workDir, 'entry.py');
|
||||||
|
fs.writeFileSync(
|
||||||
|
entry,
|
||||||
|
[
|
||||||
|
'# Generated by scripts/bundle-markitdown.js — frozen CLI entrypoint',
|
||||||
|
'from markitdown.__main__ import main',
|
||||||
|
"if __name__ == '__main__':",
|
||||||
|
' main()',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('[bundle-markitdown] Freezing with PyInstaller (this takes a minute)...');
|
||||||
|
run(pythonBin(venvDir, platform === 'win32' ? 'pyinstaller.exe' : 'pyinstaller'), [
|
||||||
|
'--onefile',
|
||||||
|
'--name', 'markitdown',
|
||||||
|
'--strip',
|
||||||
|
'--clean',
|
||||||
|
// magika ships its ML model as data files — must be collected into the bundle
|
||||||
|
'--collect-data', 'magika',
|
||||||
|
// Heavy optional scientific/ML stacks markitdown never imports for
|
||||||
|
// document conversion — excluding them keeps the binary ~75MB
|
||||||
|
'--exclude-module', 'torch',
|
||||||
|
'--exclude-module', 'scipy',
|
||||||
|
'--exclude-module', 'pandas',
|
||||||
|
'--exclude-module', 'matplotlib',
|
||||||
|
'--exclude-module', 'tkinter',
|
||||||
|
'--exclude-module', 'IPython',
|
||||||
|
'--exclude-module', 'pytest',
|
||||||
|
'--distpath', path.join(workDir, 'dist'),
|
||||||
|
'--workpath', path.join(workDir, 'build'),
|
||||||
|
'--specpath', workDir,
|
||||||
|
entry,
|
||||||
|
]);
|
||||||
|
|
||||||
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
|
fs.copyFileSync(path.join(workDir, 'dist', exeName), destFile);
|
||||||
|
if (platform !== 'win32') fs.chmodSync(destFile, 0o755);
|
||||||
|
|
||||||
|
// Smoke test before declaring success
|
||||||
|
const version = execFileSync(destFile, ['--version'], { encoding: 'utf-8' }).trim();
|
||||||
|
if (!version.toLowerCase().includes('markitdown')) {
|
||||||
|
throw new Error(`Unexpected --version output: ${version}`);
|
||||||
|
}
|
||||||
|
console.log(`[bundle-markitdown] Built ${destFile} (${version})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
main();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[bundle-markitdown] FAILED:', err.message);
|
||||||
|
console.error(
|
||||||
|
'\nPrerequisites: python3 with venv+pip on the PATH.\n' +
|
||||||
|
' Debian/Ubuntu: sudo apt install python3 python3-venv\n' +
|
||||||
|
' macOS: brew install python\n' +
|
||||||
|
'The app still works without the bundled binary — it falls back to a\n' +
|
||||||
|
'system-installed markitdown or python -m markitdown at runtime.'
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(workDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
@@ -3,6 +3,11 @@
|
|||||||
* Downloads pandoc binary for the current build platform.
|
* Downloads pandoc binary for the current build platform.
|
||||||
* Run automatically via `npm run download-tools` before building.
|
* Run automatically via `npm run download-tools` before building.
|
||||||
* Skips download if binary already exists (idempotent).
|
* Skips download if binary already exists (idempotent).
|
||||||
|
*
|
||||||
|
* Security: every artifact with a known hash is verified with SHA-256 after
|
||||||
|
* download AND on every run (defending the build against a tampered cache /
|
||||||
|
* compromised mirror). When a hash is missing for a platform, the computed
|
||||||
|
* hash is printed so CI can pin it — add it to KNOWN_SHA256 below.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
@@ -10,10 +15,57 @@ const http = require('http');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
|
const crypto = require('crypto');
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
const PANDOC_VERSION = '3.9.0.2';
|
const PANDOC_VERSION = '3.9.0.2';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SHA-256 of each downloaded artifact, keyed "<platform>:<file>" or
|
||||||
|
* "fonts:<file>". Computed with `sha256sum` from the pristine upstream
|
||||||
|
* artifacts; keep in sync when bumping versions.
|
||||||
|
*/
|
||||||
|
const KNOWN_SHA256 = {
|
||||||
|
'linux:pandoc': '7d124235998ecd3cdd9a463b1e5f6691a178b6461824c29a36170a0882f05597',
|
||||||
|
'win32:pandoc.exe': 'e83f8354c0f507222b5684797b9c5ae766f03889785995d14aac27816ec456ba',
|
||||||
|
// Fill these from a trusted machine after the first download of each
|
||||||
|
// platform (the script prints the computed hash):
|
||||||
|
// 'darwin:pandoc': '…',
|
||||||
|
'fonts:FiraCode-Regular.ttf': '3c79d234a9161c790410ebb2a80de7efb7c15f581062c130e0fa78503ccdd0da',
|
||||||
|
'fonts:FiraCode-Bold.ttf': '975f26779fac1029c2cbdac1e9fac7e9ddeec05e064675e4aac63bffa121742f',
|
||||||
|
'fonts:FiraCode-LICENSE.txt': '1d41e10031ab125302780a05ec4c91d218e47db0c7e37cf315cce5e608cdc25c',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** sha256 of a file's contents, hex-encoded. */
|
||||||
|
function sha256File(filePath) {
|
||||||
|
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a file against KNOWN_SHA256. Throws on mismatch; prints the computed
|
||||||
|
* hash with a pin-me hint when no hash is recorded yet (soft-fail so new
|
||||||
|
* platforms bootstrap, hard-fail so tampering never passes silently).
|
||||||
|
*/
|
||||||
|
function verifyArtifact(key, filePath) {
|
||||||
|
const actual = sha256File(filePath);
|
||||||
|
const expected = KNOWN_SHA256[key];
|
||||||
|
if (!expected) {
|
||||||
|
console.warn(
|
||||||
|
`[download-tools] WARNING: no pinned SHA-256 for "${key}".\n` +
|
||||||
|
` Computed: ${actual}\n` +
|
||||||
|
' Verify it against the upstream artifact and add it to KNOWN_SHA256.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(
|
||||||
|
`SHA-256 mismatch for ${key}!\n expected ${expected}\n actual ${actual}\n` +
|
||||||
|
'Delete the file and re-run; if the mismatch persists, do NOT ship it.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(`[download-tools] SHA-256 OK for ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
const PANDOC_CONFIG = {
|
const PANDOC_CONFIG = {
|
||||||
linux: {
|
linux: {
|
||||||
url: `https://github.com/jgm/pandoc/releases/download/${PANDOC_VERSION}/pandoc-${PANDOC_VERSION}-linux-amd64.tar.gz`,
|
url: `https://github.com/jgm/pandoc/releases/download/${PANDOC_VERSION}/pandoc-${PANDOC_VERSION}-linux-amd64.tar.gz`,
|
||||||
@@ -119,11 +171,14 @@ async function downloadFiraCode() {
|
|||||||
for (const t of targets) {
|
for (const t of targets) {
|
||||||
const destFile = path.join(destDir, t.out);
|
const destFile = path.join(destDir, t.out);
|
||||||
if (fs.existsSync(destFile)) {
|
if (fs.existsSync(destFile)) {
|
||||||
|
// Re-verify cached artifacts so a tampered cache never ships
|
||||||
|
verifyArtifact(`fonts:${t.out}`, destFile);
|
||||||
console.log(`[download-tools] ${t.out} already present — skipping.`);
|
console.log(`[download-tools] ${t.out} already present — skipping.`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
console.log(`[download-tools] Downloading ${t.out}...`);
|
console.log(`[download-tools] Downloading ${t.out}...`);
|
||||||
await download(t.url, destFile);
|
await download(t.url, destFile);
|
||||||
|
verifyArtifact(`fonts:${t.out}`, destFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +195,7 @@ async function downloadPandoc() {
|
|||||||
const destFile = path.join(destDir, config.destFile);
|
const destFile = path.join(destDir, config.destFile);
|
||||||
|
|
||||||
if (fs.existsSync(destFile)) {
|
if (fs.existsSync(destFile)) {
|
||||||
|
verifyArtifact(`${platform}:${config.destFile}`, destFile);
|
||||||
console.log(`[download-tools] pandoc already present at ${destFile} — skipping.`);
|
console.log(`[download-tools] pandoc already present at ${destFile} — skipping.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -153,6 +209,7 @@ async function downloadPandoc() {
|
|||||||
|
|
||||||
console.log(`[download-tools] Extracting to ${destDir}...`);
|
console.log(`[download-tools] Extracting to ${destDir}...`);
|
||||||
config.extract(tmpArchive, destDir);
|
config.extract(tmpArchive, destDir);
|
||||||
|
verifyArtifact(`${platform}:${config.destFile}`, destFile);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs.unlinkSync(tmpArchive);
|
fs.unlinkSync(tmpArchive);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const {
|
|||||||
highlightActiveLine,
|
highlightActiveLine,
|
||||||
drawSelection,
|
drawSelection,
|
||||||
} = require('@codemirror/view');
|
} = require('@codemirror/view');
|
||||||
const { EditorState } = require('@codemirror/state');
|
const { EditorState, Compartment } = require('@codemirror/state');
|
||||||
const { markdown, markdownLanguage } = require('@codemirror/lang-markdown');
|
const { markdown, markdownLanguage } = require('@codemirror/lang-markdown');
|
||||||
// Language extensions loaded lazily on first use
|
// Language extensions loaded lazily on first use
|
||||||
let _javascript, _html, _css, _json, _python;
|
let _javascript, _html, _css, _json, _python;
|
||||||
@@ -18,6 +18,30 @@ const { autocompletion, completionKeymap } = require('@codemirror/autocomplete')
|
|||||||
const { bracketMatching, foldGutter, indentOnInput } = require('@codemirror/language');
|
const { bracketMatching, foldGutter, indentOnInput } = require('@codemirror/language');
|
||||||
const { oneDark } = require('@codemirror/theme-one-dark');
|
const { oneDark } = require('@codemirror/theme-one-dark');
|
||||||
|
|
||||||
|
// Compartments allow toggling features (vim mode) on a live editor without
|
||||||
|
// recreating the view — CodeMirror 6's intended mechanism for reconfiguration.
|
||||||
|
const vimCompartment = new Compartment();
|
||||||
|
// Vim is loaded lazily: most users never enable it, and the module is heavy.
|
||||||
|
let _vimExtension = null;
|
||||||
|
function getVimExtension() {
|
||||||
|
if (!_vimExtension) {
|
||||||
|
_vimExtension = require('@replit/codemirror-vim').vim();
|
||||||
|
}
|
||||||
|
return _vimExtension;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle vim keybindings on an editor created by createEditor.
|
||||||
|
* @param {EditorView} view
|
||||||
|
* @param {boolean} enabled
|
||||||
|
*/
|
||||||
|
function setVimMode(view, enabled) {
|
||||||
|
if (!view) return;
|
||||||
|
view.dispatch({
|
||||||
|
effects: vimCompartment.reconfigure(enabled ? getVimExtension() : []),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Custom theme for JetBrains Mono font
|
// Custom theme for JetBrains Mono font
|
||||||
const jetBrainsMonoTheme = EditorView.theme({
|
const jetBrainsMonoTheme = EditorView.theme({
|
||||||
'&': {
|
'&': {
|
||||||
@@ -31,6 +55,37 @@ const jetBrainsMonoTheme = EditorView.theme({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the Tab keymap entry that expands snippets before falling through to
|
||||||
|
* indent. `getExpansion(prefix)` is synchronous and returns the replacement
|
||||||
|
* text (or null to decline), letting the host keep its snippets in a Map.
|
||||||
|
*/
|
||||||
|
function snippetTabKeymap(getExpansion) {
|
||||||
|
return {
|
||||||
|
key: 'Tab',
|
||||||
|
run: (view) => {
|
||||||
|
if (typeof getExpansion !== 'function') return false;
|
||||||
|
const { head } = view.state.selection.main;
|
||||||
|
const line = view.state.doc.lineAt(head);
|
||||||
|
// Word-before-cursor: letters, digits, underscore and hyphen
|
||||||
|
const before = line.text.slice(0, head - line.from);
|
||||||
|
const match = /[\w-]+$/.exec(before);
|
||||||
|
if (!match) return false;
|
||||||
|
const expansion = getExpansion(match[0].toLowerCase());
|
||||||
|
if (typeof expansion !== 'string') return false;
|
||||||
|
view.dispatch({
|
||||||
|
changes: {
|
||||||
|
from: head - match[0].length,
|
||||||
|
to: head,
|
||||||
|
insert: expansion,
|
||||||
|
},
|
||||||
|
selection: { anchor: head - match[0].length + expansion.length },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a CodeMirror 6 editor instance.
|
* Create a CodeMirror 6 editor instance.
|
||||||
*
|
*
|
||||||
@@ -41,6 +96,8 @@ const jetBrainsMonoTheme = EditorView.theme({
|
|||||||
* @param {Function} options.onUpdate - called with the EditorView on every update (selection, doc change, etc.)
|
* @param {Function} options.onUpdate - called with the EditorView on every update (selection, doc change, etc.)
|
||||||
* @param {boolean} options.isDark - apply oneDark theme when true (default false)
|
* @param {boolean} options.isDark - apply oneDark theme when true (default false)
|
||||||
* @param {boolean} options.showLineNumbers - show line-number gutter (default true)
|
* @param {boolean} options.showLineNumbers - show line-number gutter (default true)
|
||||||
|
* @param {boolean} options.vimMode - start with vim keybindings (default false; toggle later via setVimMode)
|
||||||
|
* @param {Function} options.getTabExpansion - (prefix)=>string|null; Tab expands a matching snippet (default null)
|
||||||
* @returns {EditorView} the created editor view
|
* @returns {EditorView} the created editor view
|
||||||
*/
|
*/
|
||||||
function createEditor(parentElement, options = {}) {
|
function createEditor(parentElement, options = {}) {
|
||||||
@@ -62,6 +119,8 @@ function createEditor(parentElement, options = {}) {
|
|||||||
onUpdate = null,
|
onUpdate = null,
|
||||||
isDark = false,
|
isDark = false,
|
||||||
showLineNumbers = true,
|
showLineNumbers = true,
|
||||||
|
vimMode = false,
|
||||||
|
getTabExpansion = null,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const extensions = [
|
const extensions = [
|
||||||
@@ -75,13 +134,16 @@ function createEditor(parentElement, options = {}) {
|
|||||||
autocompletion(),
|
autocompletion(),
|
||||||
foldGutter(),
|
foldGutter(),
|
||||||
jetBrainsMonoTheme,
|
jetBrainsMonoTheme,
|
||||||
|
// Snippet Tab expansion must precede indentWithTab so it wins when matched
|
||||||
keymap.of([
|
keymap.of([
|
||||||
|
...(getTabExpansion ? [snippetTabKeymap(getTabExpansion)] : []),
|
||||||
...defaultKeymap,
|
...defaultKeymap,
|
||||||
...historyKeymap,
|
...historyKeymap,
|
||||||
...searchKeymap,
|
...searchKeymap,
|
||||||
...completionKeymap,
|
...completionKeymap,
|
||||||
indentWithTab,
|
indentWithTab,
|
||||||
]),
|
]),
|
||||||
|
vimCompartment.of(vimMode ? getVimExtension() : []),
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (update.docChanged) {
|
if (update.docChanged) {
|
||||||
onChange(update.state.doc.toString());
|
onChange(update.state.doc.toString());
|
||||||
@@ -146,4 +208,4 @@ function getLanguageExtension(lang) {
|
|||||||
return loader ? loader() : markdown({ base: markdownLanguage });
|
return loader ? loader() : markdown({ base: markdownLanguage });
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createEditor, getLanguageExtension };
|
module.exports = { createEditor, getLanguageExtension, setVimMode, snippetTabKeymap };
|
||||||
|
|||||||
+36
-4
@@ -22,6 +22,8 @@
|
|||||||
<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="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" />
|
||||||
|
<!-- KaTeX math rendering — bundled locally (fonts included) for offline use -->
|
||||||
|
<link rel="stylesheet" href="../assets/katex/katex.min.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Skip link for keyboard navigation -->
|
<!-- Skip link for keyboard navigation -->
|
||||||
@@ -1256,11 +1258,9 @@
|
|||||||
<div class="converter-options pandoc-options hidden">
|
<div class="converter-options pandoc-options hidden">
|
||||||
<div class="export-section">
|
<div class="export-section">
|
||||||
<small
|
<small
|
||||||
>Pandoc conversions can use advanced export options from the Export menu.</small
|
>Pandoc conversions use the same engine as the Export menu. For advanced options
|
||||||
|
(metadata, TOC, templates, bibliography), use File → Export instead.</small
|
||||||
>
|
>
|
||||||
<button id="pandoc-show-export-options" type="button">
|
|
||||||
Open Export Options Dialog...
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2506,6 +2506,38 @@
|
|||||||
<line x1="3" y1="18" x2="3.01" y2="18" />
|
<line x1="3" y1="18" x2="3.01" y2="18" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Backlinks: local knowledge-base panel (wiki-link graph) -->
|
||||||
|
<button class="sidebar-icon" data-panel="backlinks" title="Backlinks">
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<!-- History: local version history (snapshots before saves) -->
|
||||||
|
<button class="sidebar-icon" data-panel="history" title="Version History">
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="12 6 12 12 16 14" />
|
||||||
|
</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">
|
||||||
|
|||||||
+619
-16
@@ -27,11 +27,27 @@ if (process.platform === 'win32') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directory of the bundled external tools (pandoc/markitdown) in a PACKAGED
|
||||||
|
* app. electron-builder extraFiles land next to the executable on Linux and
|
||||||
|
* Windows, and inside Contents/ on macOS — NOT in resources/bin (which is
|
||||||
|
* where the lookup incorrectly pointed before v4.7, making packaged builds
|
||||||
|
* silently fall back to system-installed tools).
|
||||||
|
*/
|
||||||
|
function bundledToolDir() {
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
// resourcesPath = …/Contents/Resources → tools live in …/Contents/bin
|
||||||
|
return path.join(path.dirname(process.resourcesPath), 'bin');
|
||||||
|
}
|
||||||
|
// exePath = …/MarkdownConverter(.exe) → tools live in …/bin
|
||||||
|
return path.join(path.dirname(process.execPath), 'bin');
|
||||||
|
}
|
||||||
|
|
||||||
// Returns path to pandoc: bundled binary when packaged, dev bin or system fallback otherwise.
|
// Returns path to pandoc: bundled binary when packaged, dev bin or system fallback otherwise.
|
||||||
function getPandocPath() {
|
function getPandocPath() {
|
||||||
if (app.isPackaged) {
|
if (app.isPackaged) {
|
||||||
const ext = process.platform === 'win32' ? '.exe' : '';
|
const ext = process.platform === 'win32' ? '.exe' : '';
|
||||||
return path.join(process.resourcesPath, 'bin', `pandoc${ext}`);
|
return path.join(bundledToolDir(), `pandoc${ext}`);
|
||||||
}
|
}
|
||||||
// Development: prefer locally-downloaded binary in bin/<platform>/
|
// Development: prefer locally-downloaded binary in bin/<platform>/
|
||||||
const devBin = path.join(
|
const devBin = path.join(
|
||||||
@@ -347,10 +363,17 @@ ipcMain.handle('set-monospace-settings', (_event, partial) => {
|
|||||||
store.set('monospaceLigatures', safe.monospaceLigatures === true);
|
store.set('monospaceLigatures', safe.monospaceLigatures === true);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
monospaceFont: readSettingsJsonCached().monospaceFont || 'jetbrains-mono',
|
monospaceFont: store.get('monospaceFont', 'jetbrains-mono'),
|
||||||
monospaceLigatures: readSettingsJsonCached().monospaceLigatures === true,
|
monospaceLigatures: store.get('monospaceLigatures', true),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Vim keybinding mode — same read/toggle/persist flow as the monospace font
|
||||||
|
ipcMain.handle('get-vim-mode', () => store.get('vimMode', false) === true);
|
||||||
|
ipcMain.handle('set-vim-mode', (_event, enabled) => {
|
||||||
|
store.set('vimMode', enabled === true);
|
||||||
|
return enabled === true;
|
||||||
|
});
|
||||||
ipcMain.handle('get-app-version', () => app.getVersion());
|
ipcMain.handle('get-app-version', () => app.getVersion());
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -509,6 +532,11 @@ if (!gotTheLock) {
|
|||||||
mainWindow.focus();
|
mainWindow.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deep link: markdownconverter://open?path=<encoded absolute path>
|
||||||
|
// (Windows/Linux deliver protocol URLs through the command line)
|
||||||
|
const deepLink = commandLine.find((arg) => arg.startsWith('markdownconverter://'));
|
||||||
|
if (deepLink && handleDeepLink(deepLink)) return;
|
||||||
|
|
||||||
// Check if a file was passed to the second instance
|
// Check if a file was passed to the second instance
|
||||||
// commandLine is an array like: ['PanConverter.exe', 'file.md']
|
// commandLine is an array like: ['PanConverter.exe', 'file.md']
|
||||||
|
|
||||||
@@ -828,6 +856,12 @@ function createMenu() {
|
|||||||
accelerator: 'CmdOrCtrl+I',
|
accelerator: 'CmdOrCtrl+I',
|
||||||
click: importDocument,
|
click: importDocument,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Microsoft MarkItDown: any file → Markdown (PDF/DOCX/PPTX/XLSX/
|
||||||
|
// MSG/EPUB/images/ZIP/…, audio/OCR with the [all] extras)
|
||||||
|
label: 'Import with MarkItDown (Any Format)...',
|
||||||
|
click: importWithMarkItDown,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Export',
|
label: 'Export',
|
||||||
submenu: [
|
submenu: [
|
||||||
@@ -842,7 +876,8 @@ function createMenu() {
|
|||||||
{
|
{
|
||||||
label: 'PDF (Enhanced)',
|
label: 'PDF (Enhanced)',
|
||||||
click: () => exportPDFViaWordTemplate(),
|
click: () => exportPDFViaWordTemplate(),
|
||||||
accelerator: 'Ctrl+Shift+P',
|
// Ctrl+Shift+P is the Command Palette; use Alt to avoid the collision
|
||||||
|
accelerator: 'Ctrl+Alt+Shift+P',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'DOCX',
|
label: 'DOCX',
|
||||||
@@ -887,6 +922,10 @@ function createMenu() {
|
|||||||
label: 'CSV (Tables)',
|
label: 'CSV (Tables)',
|
||||||
click: () => exportSpreadsheet('csv'),
|
click: () => exportSpreadsheet('csv'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Excel Spreadsheet (.xlsx)',
|
||||||
|
click: () => exportSpreadsheet('xlsx'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'separator',
|
type: 'separator',
|
||||||
},
|
},
|
||||||
@@ -1073,6 +1112,18 @@ function createMenu() {
|
|||||||
label: 'Templates',
|
label: 'Templates',
|
||||||
click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'templates'),
|
click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'templates'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Outline',
|
||||||
|
click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'outline'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Backlinks',
|
||||||
|
click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'backlinks'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Version History',
|
||||||
|
click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'history'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1264,6 +1315,15 @@ function createMenu() {
|
|||||||
mainWindow.webContents.session.setSpellCheckerEnabled(menuItem.checked);
|
mainWindow.webContents.session.setSpellCheckerEnabled(menuItem.checked);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Vim keybindings for the editor (toggled live via setVimMode)
|
||||||
|
label: 'Vim Mode',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: store.get('vimMode', false) === true,
|
||||||
|
click: (menuItem) => {
|
||||||
|
mainWindow.webContents.send('vim-setting-change', menuItem.checked);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'separator',
|
type: 'separator',
|
||||||
},
|
},
|
||||||
@@ -1453,7 +1513,7 @@ function createMenu() {
|
|||||||
title: 'About PDF Editor',
|
title: 'About PDF Editor',
|
||||||
message: 'PDF Editor',
|
message: 'PDF Editor',
|
||||||
detail:
|
detail:
|
||||||
'Comprehensive PDF editing capabilities powered by pdf-lib and pdfjs-dist.\n\nFeatures:\n• Merge multiple PDF files\n• Split PDF into separate files\n• Compress PDF to reduce file size\n• Rotate pages (90°, 180°, 270°)\n• Delete unwanted pages\n• Reorder pages\n• Add text watermarks\n• Add page numbers\n• Crop pages\n• Extract text\n• Extract embedded images\n\nSecurity Features:\n• Password protection (encryption)\n• Remove passwords (decryption)\n• Set document permissions\n\n100% offline and open-source.',
|
'Comprehensive PDF editing capabilities powered by @cantoo/pdf-lib and pdfjs-dist.\n\nFeatures:\n• Merge multiple PDF files\n• Split PDF into separate files\n• Compress PDF to reduce file size\n• Rotate pages (90°, 180°, 270°)\n• Delete unwanted pages\n• Reorder pages\n• Add text watermarks\n• Add page numbers\n• Crop pages\n• Extract text\n• Extract embedded images\n\nSecurity Features:\n• Password protection (encryption)\n• Remove passwords (decryption)\n• Set document permissions\n\n100% offline and open-source.',
|
||||||
buttons: ['OK'],
|
buttons: ['OK'],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -1476,6 +1536,12 @@ function createMenu() {
|
|||||||
{
|
{
|
||||||
type: 'separator',
|
type: 'separator',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Global scratchpad (Ctrl+Alt+Q works even when the app is unfocused)
|
||||||
|
label: 'Quick Note',
|
||||||
|
accelerator: 'CmdOrCtrl+Alt+Q',
|
||||||
|
click: () => openQuickNoteWindow(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Document Compare',
|
label: 'Document Compare',
|
||||||
click: () => mainWindow.webContents.send('show-document-compare'),
|
click: () => mainWindow.webContents.send('show-document-compare'),
|
||||||
@@ -1511,6 +1577,12 @@ function createMenu() {
|
|||||||
label: 'Dependencies & Requirements',
|
label: 'Dependencies & Requirements',
|
||||||
click: () => showDependenciesDialog(),
|
click: () => showDependenciesDialog(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Legal: bundled-component licenses + GPL source offer (in-app copy
|
||||||
|
// of THIRD-PARTY-NOTICES.md and SOURCES.md)
|
||||||
|
label: 'Third-Party Notices & Licenses',
|
||||||
|
click: () => showThirdPartyNoticesWindow(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'separator',
|
type: 'separator',
|
||||||
},
|
},
|
||||||
@@ -1635,6 +1707,68 @@ function showAboutDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show Dependencies Dialog
|
// Show Dependencies Dialog
|
||||||
|
/**
|
||||||
|
* In-app legal window: renders THIRD-PARTY-NOTICES.md and SOURCES.md (both
|
||||||
|
* ship inside the app — dev: repo root, packaged: asar root via build.files).
|
||||||
|
* The markdown is shown verbatim in a <pre> with light styling rather than
|
||||||
|
* rendered, so license texts stay exactly as written.
|
||||||
|
*/
|
||||||
|
function showThirdPartyNoticesWindow() {
|
||||||
|
const noticesWindow = new BrowserWindow({
|
||||||
|
width: 820,
|
||||||
|
height: 640,
|
||||||
|
parent: mainWindow,
|
||||||
|
title: 'Third-Party Notices & Licenses',
|
||||||
|
icon: path.join(__dirname, '../assets/icon.png'),
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
noticesWindow.setMenuBarVisibility(false);
|
||||||
|
|
||||||
|
// Both files are read defensively so a packaging slip degrades gracefully
|
||||||
|
const readDoc = (file) => {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(path.join(__dirname, '..', file), 'utf-8');
|
||||||
|
} catch {
|
||||||
|
return `(Could not read ${file} in this installation — see the source repository.)`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const esc = (s) =>
|
||||||
|
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
const html =
|
||||||
|
'<!doctype html><html><head><meta charset="utf-8"><title>Third-Party Notices</title><style>' +
|
||||||
|
'body{margin:0;font:13px/1.6 system-ui,sans-serif;background:#fafafa;color:#222}' +
|
||||||
|
'header{position:sticky;top:0;background:#fff;border-bottom:1px solid #e5e7eb;padding:10px 20px;display:flex;gap:16px;align-items:center;z-index:1}' +
|
||||||
|
'header h1{font-size:15px;margin:0;flex:1}' +
|
||||||
|
'header button{padding:6px 14px;cursor:pointer;border:1px solid #d1d5db;border-radius:4px;background:#fff}' +
|
||||||
|
'header button.active{background:#4a90d9;color:#fff;border-color:#4a90d9}' +
|
||||||
|
'pre{white-space:pre-wrap;word-break:break-word;padding:20px 24px;margin:0;font:12px/1.65 ui-monospace,Menlo,Consolas,monospace}' +
|
||||||
|
'body.dark pre{background:#1f2937;color:#e5e7eb}body.dark{background:#111}' +
|
||||||
|
'</style></head><body>' +
|
||||||
|
'<header><h1>MarkdownConverter — Third-Party Notices</h1>' +
|
||||||
|
'<button id="tab-notices" class="active">Notices</button>' +
|
||||||
|
'<button id="tab-sources">Source Offers</button></header>' +
|
||||||
|
`<pre id="content"></pre>` +
|
||||||
|
'<script>' +
|
||||||
|
'const notices=' +
|
||||||
|
JSON.stringify(esc(readDoc('THIRD-PARTY-NOTICES.md'))) +
|
||||||
|
';' +
|
||||||
|
'const sources=' +
|
||||||
|
JSON.stringify(esc(readDoc('SOURCES.md'))) +
|
||||||
|
';' +
|
||||||
|
'const c=document.getElementById("content");' +
|
||||||
|
'c.textContent=notices;' +
|
||||||
|
'document.getElementById("tab-notices").onclick=e=>{c.textContent=notices;swap(e)};' +
|
||||||
|
'document.getElementById("tab-sources").onclick=e=>{c.textContent=sources;swap(e)};' +
|
||||||
|
'function swap(e){document.querySelectorAll("header button").forEach(b=>b.classList.remove("active"));e.target.classList.add("active")}' +
|
||||||
|
'</script></body></html>';
|
||||||
|
|
||||||
|
noticesWindow.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html));
|
||||||
|
}
|
||||||
|
|
||||||
function showDependenciesDialog() {
|
function showDependenciesDialog() {
|
||||||
const depsWindow = new BrowserWindow({
|
const depsWindow = new BrowserWindow({
|
||||||
width: 600,
|
width: 600,
|
||||||
@@ -1719,10 +1853,16 @@ function showDependenciesDialog() {
|
|||||||
<a class="dep-link" href="https://miktex.org/download" target="_blank">https://miktex.org/download</a>
|
<a class="dep-link" href="https://miktex.org/download" target="_blank">https://miktex.org/download</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="dep-card optional">
|
||||||
|
<div class="dep-name">MarkItDown <span class="tag tag-optional">Optional</span></div>
|
||||||
|
<div class="dep-desc">Microsoft's any-file-to-Markdown importer (PDF, DOCX, PPTX, XLSX, Outlook .msg, EPUB, images, ZIP). Install with: pip install "markitdown[all]"</div>
|
||||||
|
<a class="dep-link" href="https://github.com/microsoft/markitdown" target="_blank">https://github.com/microsoft/markitdown</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2>Bundled Libraries</h2>
|
<h2>Bundled Libraries</h2>
|
||||||
|
|
||||||
<div class="dep-card">
|
<div class="dep-card">
|
||||||
<div class="dep-name">pdf-lib</div>
|
<div class="dep-name">@cantoo/pdf-lib</div>
|
||||||
<div class="dep-desc">PDF manipulation library for merge, split, watermark, and encryption features.</div>
|
<div class="dep-desc">PDF manipulation library for merge, split, watermark, and encryption features.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2142,7 +2282,7 @@ function processDynamicFields(text, metadata = {}) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add headers/footers to DOCX file using PizZip and docx4js
|
// Add headers/footers to DOCX file using PizZip
|
||||||
// Function to set page size in DOCX files
|
// Function to set page size in DOCX files
|
||||||
async function setDocxPageSize(docxPath) {
|
async function setDocxPageSize(docxPath) {
|
||||||
try {
|
try {
|
||||||
@@ -2487,6 +2627,17 @@ function checkConverterAvailable(tool) {
|
|||||||
resolve(fs.existsSync(ffmpegBin) || ffmpegBin === 'ffmpeg');
|
resolve(fs.existsSync(ffmpegBin) || ffmpegBin === 'ffmpeg');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case 'pandoc': {
|
||||||
|
// Pandoc is bundled (bin/<platform>/pandoc) with a system-PATH fallback
|
||||||
|
const pandocBin = getPandocPath();
|
||||||
|
if (pandocBin && pandocBin !== 'pandoc') {
|
||||||
|
resolve(fs.existsSync(pandocBin));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Bundled binary missing — fall through to a PATH lookup
|
||||||
|
toolName = 'pandoc';
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
resolve(false);
|
resolve(false);
|
||||||
return;
|
return;
|
||||||
@@ -3218,17 +3369,46 @@ function exportWithPandoc(pandocArgs, outputFile, format, onComplete) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set page size for ODT
|
// Set page size for ODT (ODF styles.xml — the DOCX-oriented
|
||||||
|
// setDocxPageSize cannot patch an ODT package)
|
||||||
if (format === 'odt') {
|
if (format === 'odt') {
|
||||||
try {
|
try {
|
||||||
await setDocxPageSize(outputFile); // ODT has similar structure
|
const { setOdtPageSize } = require('./main/OdtStyling');
|
||||||
|
await setOdtPageSize(outputFile, { ...pageSettings, pageSizes: PAGE_SIZES });
|
||||||
} catch (pageSizeError) {
|
} catch (pageSizeError) {
|
||||||
console.error('Error setting page size for ODT:', pageSizeError);
|
console.error('Error setting page size for ODT:', pageSizeError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add headers/footers to ODT if enabled
|
// Add headers/footers to ODT if enabled (mirrors the DOCX path above)
|
||||||
if (format === 'odt' && headerFooterSettings.enabled) {
|
if (format === 'odt' && headerFooterSettings.enabled) {
|
||||||
|
try {
|
||||||
|
const filename = currentFile
|
||||||
|
? path.basename(currentFile, path.extname(currentFile))
|
||||||
|
: 'document';
|
||||||
|
const metadata = {
|
||||||
|
filename: filename,
|
||||||
|
title: filename,
|
||||||
|
author: '',
|
||||||
|
};
|
||||||
|
const { addHeaderFooterToOdt } = require('./main/OdtStyling');
|
||||||
|
await addHeaderFooterToOdt(outputFile, {
|
||||||
|
enabled: headerFooterSettings.enabled,
|
||||||
|
header: {
|
||||||
|
left: processDynamicFields(headerFooterSettings.header.left, metadata),
|
||||||
|
center: processDynamicFields(headerFooterSettings.header.center, metadata),
|
||||||
|
right: processDynamicFields(headerFooterSettings.header.right, metadata),
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
left: processDynamicFields(headerFooterSettings.footer.left, metadata),
|
||||||
|
center: processDynamicFields(headerFooterSettings.footer.center, metadata),
|
||||||
|
right: processDynamicFields(headerFooterSettings.footer.right, metadata),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (hfError) {
|
||||||
|
console.error('Error adding headers/footers to ODT:', hfError);
|
||||||
|
// Continue with success message even if header/footer fails
|
||||||
|
}
|
||||||
}
|
}
|
||||||
showExportSuccess(outputFile);
|
showExportSuccess(outputFile);
|
||||||
}
|
}
|
||||||
@@ -3633,6 +3813,97 @@ function importDocument() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MarkItDown import (Microsoft markitdown, any file → Markdown)
|
||||||
|
// ============================================
|
||||||
|
const MarkItDown = require('./main/MarkItDown');
|
||||||
|
|
||||||
|
// Probed once on first use; {command, argsPrefix, version} or null
|
||||||
|
let markItDownResolved = undefined; // undefined = not probed yet
|
||||||
|
|
||||||
|
/** Resolve (and cache) the markitdown command via the bridge module. */
|
||||||
|
async function getMarkItDown() {
|
||||||
|
if (markItDownResolved === undefined) {
|
||||||
|
markItDownResolved = await MarkItDown.resolveMarkItDown(require('child_process').execFile);
|
||||||
|
}
|
||||||
|
return markItDownResolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** IPC: availability probe for renderer hints (no version spam, cached). */
|
||||||
|
ipcMain.handle('markitdown:available', async () => {
|
||||||
|
const resolved = await getMarkItDown();
|
||||||
|
return {
|
||||||
|
available: Boolean(resolved),
|
||||||
|
version: resolved?.version || null,
|
||||||
|
// How the tool was found, e.g. "markitdown" or "python3 -m markitdown"
|
||||||
|
via: resolved ? [resolved.command, ...resolved.argsPrefix].join(' ') : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/** IPC: convert one file to markdown (renderer-driven flows). */
|
||||||
|
ipcMain.handle('markitdown:convert', async (_event, { path: inputPath } = {}) => {
|
||||||
|
const validation = validatePath(inputPath);
|
||||||
|
if (!validation.valid) throw new Error('Invalid file path');
|
||||||
|
const stats = fs.statSync(validation.resolved);
|
||||||
|
if (stats.size > MAX_FILE_SIZE) {
|
||||||
|
throw new Error(`File exceeds the ${MAX_FILE_SIZE_MB}MB size limit.`);
|
||||||
|
}
|
||||||
|
const resolved = await getMarkItDown();
|
||||||
|
return MarkItDown.convertToMarkdown(validation.resolved, { resolved });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Menu flow: pick any file, convert with markitdown, write <name>.md next to
|
||||||
|
* the source (mirroring importDocument's UX), and open it in a new tab.
|
||||||
|
* Existing outputs are never overwritten — a numeric suffix is appended.
|
||||||
|
*/
|
||||||
|
async function importWithMarkItDown() {
|
||||||
|
const files = dialog.showOpenDialogSync(mainWindow, {
|
||||||
|
properties: ['openFile'],
|
||||||
|
title: 'Import with MarkItDown (any format)',
|
||||||
|
});
|
||||||
|
if (!files || !files[0]) return;
|
||||||
|
const inputFile = files[0];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = fs.statSync(inputFile);
|
||||||
|
if (stats.size > MAX_FILE_SIZE) {
|
||||||
|
dialog.showErrorBox('File Too Large', `File exceeds the ${MAX_FILE_SIZE_MB}MB size limit.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = await getMarkItDown();
|
||||||
|
const { content } = await MarkItDown.convertToMarkdown(inputFile, { resolved });
|
||||||
|
|
||||||
|
// <name>.md, then <name>-1.md, <name>-2.md, … when it already exists
|
||||||
|
const base = inputFile.replace(/\.[^/.]+$/, '');
|
||||||
|
let outputFile = `${base}.md`;
|
||||||
|
for (let i = 1; fs.existsSync(outputFile); i++) outputFile = `${base}-${i}.md`;
|
||||||
|
fs.writeFileSync(outputFile, content, 'utf-8');
|
||||||
|
|
||||||
|
currentFile = outputFile;
|
||||||
|
mainWindow.webContents.send('file-opened', { path: outputFile, content });
|
||||||
|
dialog.showMessageBox(mainWindow, {
|
||||||
|
type: 'info',
|
||||||
|
title: 'Import Complete',
|
||||||
|
message: `Imported as ${path.basename(outputFile)} via MarkItDown\n\nOriginal: ${path.basename(inputFile)}`,
|
||||||
|
buttons: ['OK'],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
dialog.showErrorBox(
|
||||||
|
'MarkItDown Import',
|
||||||
|
sanitizeErrorMessage(
|
||||||
|
(error.code === 'not_installed'
|
||||||
|
? error.message
|
||||||
|
: `Import failed: ${error.message}`) +
|
||||||
|
'\n\nMarkItDown is an optional Python tool from Microsoft (MIT):\n' +
|
||||||
|
' pip install "markitdown[all]"'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setTheme(theme) {
|
function setTheme(theme) {
|
||||||
store.set('theme', theme);
|
store.set('theme', theme);
|
||||||
mainWindow.webContents.send('theme-changed', theme);
|
mainWindow.webContents.send('theme-changed', theme);
|
||||||
@@ -3640,6 +3911,24 @@ function setTheme(theme) {
|
|||||||
|
|
||||||
// IPC handlers
|
// IPC handlers
|
||||||
ipcMain.on('save-file', (event, { path, content }) => {
|
ipcMain.on('save-file', (event, { path, content }) => {
|
||||||
|
// Version history: snapshot the on-disk content before it is replaced so
|
||||||
|
// the user can roll back from the History panel. First save of a new file
|
||||||
|
// (or unchanged content) skips the snapshot to keep noise out of the list.
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(path)) {
|
||||||
|
const previous = fs.readFileSync(path, 'utf-8');
|
||||||
|
if (previous !== content) {
|
||||||
|
VersionHistory.saveVersion({
|
||||||
|
docPath: path,
|
||||||
|
content: previous,
|
||||||
|
label: 'before save',
|
||||||
|
io: versionHistoryIo(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (historyError) {
|
||||||
|
console.error('Version history snapshot failed:', historyError);
|
||||||
|
}
|
||||||
fs.writeFileSync(path, content, 'utf-8');
|
fs.writeFileSync(path, content, 'utf-8');
|
||||||
currentFile = path;
|
currentFile = path;
|
||||||
});
|
});
|
||||||
@@ -3663,6 +3952,51 @@ ipcMain.on('set-current-file', (event, filePath) => {
|
|||||||
currentFile = filePath;
|
currentFile = filePath;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Version History (local rollback without Git)
|
||||||
|
// ============================================
|
||||||
|
const VersionHistory = require('./main/VersionHistory');
|
||||||
|
|
||||||
|
/** IO bundle for VersionHistory bound to <userData>/versions. */
|
||||||
|
function versionHistoryIo() {
|
||||||
|
return {
|
||||||
|
rootDir: path.join(app.getPath('userData'), 'versions'),
|
||||||
|
fs,
|
||||||
|
pathUtil: path,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle('version-history:list', (_event, docPath) => {
|
||||||
|
if (typeof docPath !== 'string' || !docPath) return [];
|
||||||
|
// Never leak other documents' history via a guessed path
|
||||||
|
const validation = validatePath(docPath);
|
||||||
|
if (!validation.valid) return [];
|
||||||
|
return VersionHistory.listVersions({ docPath, io: versionHistoryIo() });
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('version-history:read', (_event, { docPath, id } = {}) => {
|
||||||
|
const validation = typeof docPath === 'string' ? validatePath(docPath) : { valid: false };
|
||||||
|
if (!validation.valid) throw new Error('Invalid document path');
|
||||||
|
return VersionHistory.readVersion({ docPath, id, io: versionHistoryIo() });
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('version-history:save', (_event, { docPath, content, label } = {}) => {
|
||||||
|
const validation = typeof docPath === 'string' ? validatePath(docPath) : { valid: false };
|
||||||
|
if (!validation.valid) throw new Error('Invalid document path');
|
||||||
|
return VersionHistory.saveVersion({
|
||||||
|
docPath,
|
||||||
|
content,
|
||||||
|
label: typeof label === 'string' ? label : 'manual',
|
||||||
|
io: versionHistoryIo(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('version-history:delete', (_event, { docPath, id } = {}) => {
|
||||||
|
const validation = typeof docPath === 'string' ? validatePath(docPath) : { valid: false };
|
||||||
|
if (!validation.valid) return false;
|
||||||
|
return VersionHistory.deleteVersion({ docPath, id, io: versionHistoryIo() });
|
||||||
|
});
|
||||||
|
|
||||||
// Handle actual printing when renderer is ready
|
// Handle actual printing when renderer is ready
|
||||||
ipcMain.on('do-print', (event, { withStyles }) => {
|
ipcMain.on('do-print', (event, { withStyles }) => {
|
||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
@@ -3800,6 +4134,11 @@ ipcMain.on('export-spreadsheet', (event, { content, format }) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
fs.writeFileSync(outputFile, csvContent, 'utf-8');
|
fs.writeFileSync(outputFile, csvContent, 'utf-8');
|
||||||
|
} else if (format === 'xlsx') {
|
||||||
|
// Native Excel workbook: one sheet per markdown table (no Pandoc needed)
|
||||||
|
const { buildXlsx } = require('./main/XlsxExporter');
|
||||||
|
const buffer = buildXlsx(tables);
|
||||||
|
fs.writeFileSync(outputFile, buffer);
|
||||||
}
|
}
|
||||||
dialog.showMessageBox(mainWindow, {
|
dialog.showMessageBox(mainWindow, {
|
||||||
type: 'info',
|
type: 'info',
|
||||||
@@ -4441,13 +4780,13 @@ function buildCLIConversionArgs(content, format, outputPath) {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'html':
|
case 'html':
|
||||||
args.push('--self-contained', '--css');
|
// Standalone HTML; a bare --css (or --self-contained, removed in Pandoc 3.x)
|
||||||
|
// without a value is invalid argv and makes pandoc exit with an error.
|
||||||
|
args.push('--standalone');
|
||||||
break;
|
break;
|
||||||
case 'docx':
|
case 'docx':
|
||||||
args.push('--reference-doc');
|
// DOCX headers/footers are applied via addHeaderFooterToDocx() post-processing
|
||||||
|
// after export; a bare --reference-doc without a value would be invalid argv.
|
||||||
// For DOCX, header/footer are handled via reference document or separate processing
|
|
||||||
// We'll add a note that DOCX headers/footers require reference doc or post-processing
|
|
||||||
break;
|
break;
|
||||||
case 'odt':
|
case 'odt':
|
||||||
// ODT headers/footers are handled via reference document
|
// ODT headers/footers are handled via reference document
|
||||||
@@ -4608,6 +4947,58 @@ app.on('open-file', (event, filePath) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Deep link protocol: markdownconverter://open?path=<encoded abs path>
|
||||||
|
// ============================================
|
||||||
|
// Lets external tools (browsers, launchers, note-taking scripts) hand a
|
||||||
|
// document straight to a running instance. Only the 'open' action exists;
|
||||||
|
// the path must be absolute, exist, and pass the same size guard as normal
|
||||||
|
// opens. Returns true when the link was consumed.
|
||||||
|
function handleDeepLink(urlString) {
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(urlString);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (url.protocol !== 'markdownconverter:') return false;
|
||||||
|
|
||||||
|
const action = url.hostname || url.pathname.replace(/^\/+/, '');
|
||||||
|
if (action !== 'open') {
|
||||||
|
dialog.showErrorBox('Deep Link', `Unknown action "${action}". Only "open" is supported.`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = url.searchParams.get('path');
|
||||||
|
if (!target || !path.isAbsolute(target)) {
|
||||||
|
dialog.showErrorBox('Deep Link', 'The link must include an absolute ?path=… parameter.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const validation = validatePath(target);
|
||||||
|
if (!validation.valid || !fs.existsSync(validation.resolved)) {
|
||||||
|
dialog.showErrorBox('Deep Link', 'The linked file does not exist or is not accessible.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rendererReady) {
|
||||||
|
openFileFromPath(validation.resolved);
|
||||||
|
} else {
|
||||||
|
app.pendingFile = validation.resolved;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS delivers protocol clicks as open-url events
|
||||||
|
app.on('open-url', (event, urlString) => {
|
||||||
|
event.preventDefault();
|
||||||
|
handleDeepLink(urlString);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register the custom protocol once the app is ready (idempotent)
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
app.setAsDefaultProtocolClient('markdownconverter');
|
||||||
|
});
|
||||||
|
|
||||||
// Handle file opening from command line or file association
|
// Handle file opening from command line or file association
|
||||||
function openFileFromPath(filePath) {
|
function openFileFromPath(filePath) {
|
||||||
if (fs.existsSync(filePath)) {
|
if (fs.existsSync(filePath)) {
|
||||||
@@ -4654,12 +5045,77 @@ ipcMain.on('process-pdf-operation', async (event, data) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Reports pdf-lib's encryption capability (Task 27) so the renderer can
|
// Reports the PDF library's encryption capability so the renderer can
|
||||||
// disable the password-protection controls instead of letting the user
|
// disable the password-protection controls instead of letting the user
|
||||||
// fill the form only to see the operation fail.
|
// fill the form only to see the operation fail.
|
||||||
ipcMain.handle('get-pdf-capabilities', async () => ({
|
ipcMain.handle('get-pdf-capabilities', async () => ({
|
||||||
passwordProtection: await PDFOperations.pdfEncryptionSupported,
|
passwordProtection: await PDFOperations.pdfEncryptionSupported,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// AI Assistant plugin (main-process side)
|
||||||
|
// ================================
|
||||||
|
// All AI provider traffic flows through the main process: the renderer CSP
|
||||||
|
// stays closed to AI endpoints and API keys never cross the IPC boundary.
|
||||||
|
// Provider settings live in settings.json under plugins.ai-assistant.*
|
||||||
|
// (written by the plugin settings store), so the handlers below read them
|
||||||
|
// with the same store the rest of the app uses.
|
||||||
|
const AiProviders = require('./main/AiProviders');
|
||||||
|
|
||||||
|
/** Read the AI Assistant plugin's provider settings from the settings store. */
|
||||||
|
function getAiAssistantSettings() {
|
||||||
|
return {
|
||||||
|
provider: store.get('plugins.ai-assistant.provider', ''),
|
||||||
|
model: store.get('plugins.ai-assistant.model', ''),
|
||||||
|
baseUrl: store.get('plugins.ai-assistant.baseUrl', ''),
|
||||||
|
apiKey: store.get('plugins.ai-assistant.apiKey', ''),
|
||||||
|
temperature: store.get('plugins.ai-assistant.temperature', 0.7),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report provider/model and whether a usable configuration exists — without
|
||||||
|
* ever returning the key material to the renderer.
|
||||||
|
*/
|
||||||
|
ipcMain.handle('ai-assistant:status', async () => {
|
||||||
|
const settings = getAiAssistantSettings();
|
||||||
|
let configured = false;
|
||||||
|
try {
|
||||||
|
// resolveSettings throws when the provider is unknown or a required key
|
||||||
|
// is missing — exactly the "not configured" definition we want.
|
||||||
|
AiProviders.resolveSettings(settings);
|
||||||
|
configured = true;
|
||||||
|
} catch {
|
||||||
|
configured = false;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
configured,
|
||||||
|
provider: settings.provider,
|
||||||
|
model: settings.model || AiProviders.PROVIDER_DEFAULTS[settings.provider]?.defaultModel || '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a chat completion. The payload is {system?, messages:[{role,content}]}
|
||||||
|
* with provider settings taken from the store, never from the caller — a
|
||||||
|
* compromised renderer cannot redirect requests to an attacker endpoint.
|
||||||
|
*/
|
||||||
|
ipcMain.handle('ai-assistant:complete', async (_event, request) => {
|
||||||
|
const settings = getAiAssistantSettings();
|
||||||
|
try {
|
||||||
|
return await AiProviders.complete({ ...request, ...settings });
|
||||||
|
} catch (error) {
|
||||||
|
// AiProviderError messages are already user-safe; wrap anything else
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
error.code === undefined
|
||||||
|
? 'AI request failed. Check the provider settings and your connection.'
|
||||||
|
: error.message,
|
||||||
|
code: error.code || 'failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.on('get-pdf-page-count', async (event, filePath) => {
|
ipcMain.on('get-pdf-page-count', async (event, filePath) => {
|
||||||
try {
|
try {
|
||||||
const count = await PDFOperations.getPageCount(filePath);
|
const count = await PDFOperations.getPageCount(filePath);
|
||||||
@@ -5073,6 +5529,108 @@ ipcMain.on('open-table-generator', () => {
|
|||||||
openTableGenerator();
|
openTableGenerator();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Quick Note — global scratchpad window (Ctrl+Alt+Q)
|
||||||
|
// ============================================
|
||||||
|
// A small always-quick-to-open window for capturing thoughts without
|
||||||
|
// disturbing the main workspace. Notes append to <userData>/notes/quick-notes.md
|
||||||
|
// with a timestamp header, so nothing is ever lost and the file is plain
|
||||||
|
// markdown the user can open in the main editor later.
|
||||||
|
let quickNoteWindow = null;
|
||||||
|
const quickNotesPath = () => path.join(app.getPath('userData'), 'notes', 'quick-notes.md');
|
||||||
|
|
||||||
|
function openQuickNoteWindow() {
|
||||||
|
if (quickNoteWindow) {
|
||||||
|
quickNoteWindow.show();
|
||||||
|
quickNoteWindow.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
quickNoteWindow = new BrowserWindow({
|
||||||
|
width: 420,
|
||||||
|
height: 320,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
skipTaskbar: false,
|
||||||
|
title: 'Quick Note',
|
||||||
|
icon: path.join(__dirname, '../assets/icon.png'),
|
||||||
|
webPreferences: {
|
||||||
|
// Small self-contained page; nodeIntegration lets it save via IPC
|
||||||
|
// without shipping a dedicated preload for one textarea.
|
||||||
|
nodeIntegration: true,
|
||||||
|
contextIsolation: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
quickNoteWindow.setMenuBarVisibility(false);
|
||||||
|
quickNoteWindow.loadURL(
|
||||||
|
'data:text/html;charset=utf-8,' +
|
||||||
|
encodeURIComponent(`<!doctype html>
|
||||||
|
<html><head><meta charset="utf-8"><title>Quick Note</title>
|
||||||
|
<style>
|
||||||
|
body { margin:0; font: 13px system-ui, sans-serif; display:flex; flex-direction:column; height:100vh; }
|
||||||
|
textarea { flex:1; border:0; resize:none; padding:12px; font:inherit; box-sizing:border-box; outline:none; }
|
||||||
|
footer { display:flex; justify-content:space-between; align-items:center; padding:6px 12px;
|
||||||
|
border-top:1px solid #ddd; color:#666; font-size:11px; }
|
||||||
|
button { padding:4px 12px; cursor:pointer; }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<textarea id="note" placeholder="Type a note… (Ctrl+Enter saves & clears, Esc hides)"></textarea>
|
||||||
|
<footer>
|
||||||
|
<span id="status">Appends to notes/quick-notes.md</span>
|
||||||
|
<button id="save" type="button">Save note</button>
|
||||||
|
</footer>
|
||||||
|
<script>
|
||||||
|
const { ipcRenderer } = require('electron');
|
||||||
|
const note = document.getElementById('note');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
function save() {
|
||||||
|
const text = note.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
ipcRenderer.invoke('quick-note:save', text).then(() => {
|
||||||
|
note.value = '';
|
||||||
|
status.textContent = 'Saved ' + new Date().toLocaleTimeString();
|
||||||
|
}).catch((e) => { status.textContent = 'Save failed: ' + e.message; });
|
||||||
|
}
|
||||||
|
document.getElementById('save').addEventListener('click', save);
|
||||||
|
note.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); save(); }
|
||||||
|
if (e.key === 'Escape') ipcRenderer.send('quick-note:hide');
|
||||||
|
});
|
||||||
|
note.focus();
|
||||||
|
</script>
|
||||||
|
</body></html>`)
|
||||||
|
);
|
||||||
|
quickNoteWindow.on('closed', () => {
|
||||||
|
quickNoteWindow = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle('quick-note:save', async (_event, text) => {
|
||||||
|
// Append with a timestamp header; folder is created on first save
|
||||||
|
const file = quickNotesPath();
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
const stamp = new Date().toISOString().replace('T', ' ').slice(0, 16);
|
||||||
|
fs.appendFileSync(file, `\n\n## ${stamp}\n\n${String(text)}\n`, 'utf-8');
|
||||||
|
return { path: file };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Esc in the note window hides instead of closing (keeps it one keystroke away)
|
||||||
|
ipcMain.on('quick-note:hide', () => {
|
||||||
|
if (quickNoteWindow) quickNoteWindow.hide();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global shortcut works even when the app is not focused — the point of a
|
||||||
|
// scratchpad. Registered on ready, unregistered on quit.
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
const { globalShortcut } = require('electron');
|
||||||
|
const registered = globalShortcut.register('CommandOrControl+Alt+Q', () => {
|
||||||
|
openQuickNoteWindow();
|
||||||
|
});
|
||||||
|
if (!registered) console.warn('Quick Note shortcut Ctrl+Alt+Q could not be registered');
|
||||||
|
});
|
||||||
|
app.on('will-quit', () => {
|
||||||
|
const { globalShortcut } = require('electron');
|
||||||
|
globalShortcut.unregister('CommandOrControl+Alt+Q');
|
||||||
|
});
|
||||||
|
|
||||||
// IPC Handler for loading document templates
|
// IPC Handler for loading document templates
|
||||||
ipcMain.handle('load-template', async (event, filename) => {
|
ipcMain.handle('load-template', async (event, filename) => {
|
||||||
try {
|
try {
|
||||||
@@ -5084,6 +5642,51 @@ ipcMain.handle('load-template', async (event, filename) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PlantUML local rendering (optional CLI)
|
||||||
|
// ============================================
|
||||||
|
// When a local `plantuml` executable is installed, diagrams render on the
|
||||||
|
// user's machine via `-pipe` mode (diagram source on stdin, PNG on stdout)
|
||||||
|
// instead of POSTing them to plantuml.com (the CVE-MC-007 data-exposure
|
||||||
|
// concern). Availability is probed once and cached.
|
||||||
|
let plantumlAvailableCache = null;
|
||||||
|
ipcMain.handle('plantuml:available', async () => {
|
||||||
|
if (plantumlAvailableCache !== null) return plantumlAvailableCache;
|
||||||
|
plantumlAvailableCache = await new Promise((resolve) => {
|
||||||
|
const isWin = process.platform === 'win32';
|
||||||
|
execFile(isWin ? 'where' : 'which', [isWin ? 'plantuml.exe' : 'plantuml'], (error) => {
|
||||||
|
resolve(!error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return plantumlAvailableCache;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('plantuml:render', async (_event, { text } = {}) => {
|
||||||
|
if (typeof text !== 'string' || text.length > 1024 * 1024) {
|
||||||
|
throw new Error('Invalid PlantUML input');
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// -pipe reads the diagram from stdin and writes PNG bytes to stdout
|
||||||
|
const child = execFile(
|
||||||
|
'plantuml',
|
||||||
|
['-tpng', '-pipe', '-charset', 'UTF-8'],
|
||||||
|
{ timeout: 30000, maxBuffer: 20 * 1024 * 1024, encoding: 'buffer' },
|
||||||
|
(error, stdout) => {
|
||||||
|
if (error) {
|
||||||
|
reject(new Error('Local PlantUML rendering failed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({ dataUrl: 'data:image/png;base64,' + stdout.toString('base64') });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (child.stdin) {
|
||||||
|
child.stdin.on('error', () => {}); // EPIPE if plantuml dies early
|
||||||
|
child.stdin.write(text, 'utf-8');
|
||||||
|
child.stdin.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// IPC Handler for saving pasted/dropped images
|
// IPC Handler for saving pasted/dropped images
|
||||||
ipcMain.handle('save-pasted-image', async (event, { base64, ext }) => {
|
ipcMain.handle('save-pasted-image', async (event, { base64, ext }) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
/**
|
||||||
|
* AI provider adapters for the AI Assistant plugin (main process side).
|
||||||
|
*
|
||||||
|
* All network calls happen here in the main process, never in the renderer:
|
||||||
|
* - the renderer's CSP does not need to whitelist AI endpoints
|
||||||
|
* - API keys never cross the IPC boundary into the renderer
|
||||||
|
* - every provider gets the same timeout/size/error sanitization rules
|
||||||
|
*
|
||||||
|
* Supported providers:
|
||||||
|
* - `openai` → https://api.openai.com/v1 (chat completions)
|
||||||
|
* - `anthropic` → https://api.anthropic.com (messages API)
|
||||||
|
* - `ollama` → http://localhost:11434/v1 (OpenAI-compatible)
|
||||||
|
* - `lmstudio` → http://localhost:1234/v1 (OpenAI-compatible)
|
||||||
|
* - `openai-compatible`→ any baseUrl speaking the OpenAI chat schema
|
||||||
|
* - `anthropic-compatible` → any baseUrl speaking the Anthropic messages
|
||||||
|
* schema (LiteLLM proxies, Bedrock gateways, local Claude-compatible
|
||||||
|
* servers); baseUrl is required, the API key is optional because many
|
||||||
|
* proxies are keyless or front their own auth
|
||||||
|
*
|
||||||
|
* The module takes an injectable `fetchImpl` (defaulting to global fetch) so
|
||||||
|
* tests can stub the network without monkey-patching.
|
||||||
|
*
|
||||||
|
* @module AiProviders
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* global AbortController */
|
||||||
|
|
||||||
|
// Hard caps shared by every provider: a runaway selection (or a hostile
|
||||||
|
// plugin caller) must not be able to push a 50MB "prompt" at an API.
|
||||||
|
const MAX_PROMPT_CHARS = 200 * 1024;
|
||||||
|
const DEFAULT_TIMEOUT_MS = 120000;
|
||||||
|
|
||||||
|
// Sensible defaults per provider; every field is overridable via settings.
|
||||||
|
const PROVIDER_DEFAULTS = {
|
||||||
|
openai: { baseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-4o-mini' },
|
||||||
|
anthropic: { baseUrl: 'https://api.anthropic.com', defaultModel: 'claude-3-5-sonnet-latest' },
|
||||||
|
ollama: { baseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3.1' },
|
||||||
|
lmstudio: { baseUrl: 'http://localhost:1234/v1', defaultModel: 'local-model' },
|
||||||
|
'openai-compatible': { baseUrl: '', defaultModel: '' },
|
||||||
|
'anthropic-compatible': { baseUrl: '', defaultModel: 'claude-3-5-sonnet-latest' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Provider ids that speak the OpenAI chat-completions schema. */
|
||||||
|
const OPENAI_STYLE = new Set(['openai', 'ollama', 'lmstudio', 'openai-compatible']);
|
||||||
|
|
||||||
|
class AiProviderError extends Error {
|
||||||
|
/**
|
||||||
|
* @param {string} message User-safe message (never include keys or raw HTML)
|
||||||
|
* @param {string} code Machine-readable code for the renderer
|
||||||
|
*/
|
||||||
|
constructor(message, code) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AiProviderError';
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and normalize a completion request shared by all providers.
|
||||||
|
* @returns {{provider:string, baseUrl:string, apiKey:string, model:string,
|
||||||
|
* temperature:number}} resolved settings
|
||||||
|
* @throws {AiProviderError} on unknown provider / missing key / bad URL
|
||||||
|
*/
|
||||||
|
function resolveSettings({
|
||||||
|
provider,
|
||||||
|
baseUrl,
|
||||||
|
apiKey,
|
||||||
|
model,
|
||||||
|
temperature,
|
||||||
|
requireKey = true,
|
||||||
|
} = {}) {
|
||||||
|
const defaults = PROVIDER_DEFAULTS[provider];
|
||||||
|
if (!defaults) {
|
||||||
|
throw new AiProviderError(
|
||||||
|
`Unknown AI provider "${provider}". Configure the AI Assistant plugin first.`,
|
||||||
|
'not_configured'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local providers (ollama/lmstudio) and self-hosted *-compatible proxies
|
||||||
|
// don't need a key; branded cloud endpoints do.
|
||||||
|
const needsKey =
|
||||||
|
requireKey &&
|
||||||
|
provider !== 'ollama' &&
|
||||||
|
provider !== 'lmstudio' &&
|
||||||
|
provider !== 'anthropic-compatible';
|
||||||
|
if (needsKey && !apiKey) {
|
||||||
|
throw new AiProviderError(
|
||||||
|
`The "${provider}" provider needs an API key. Add one in AI Assistant settings.`,
|
||||||
|
'missing_key'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow http(s) base URLs to avoid file:// and other exotic schemes
|
||||||
|
const resolvedBase = (baseUrl || defaults.baseUrl || '').replace(/\/+$/, '');
|
||||||
|
if (!/^https?:\/\//.test(resolvedBase)) {
|
||||||
|
throw new AiProviderError(`Invalid API base URL for provider "${provider}".`, 'bad_base_url');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
baseUrl: resolvedBase,
|
||||||
|
apiKey: apiKey || '',
|
||||||
|
model: model || defaults.defaultModel,
|
||||||
|
temperature: typeof temperature === 'number' ? Math.max(0, Math.min(2, temperature)) : 0.7,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call a chat-completion style provider (OpenAI schema).
|
||||||
|
* @returns {Promise<string>} assistant message text
|
||||||
|
*/
|
||||||
|
async function callOpenAiStyle(settings, { system, messages }, fetchImpl, timeoutMs) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
model: settings.model,
|
||||||
|
temperature: settings.temperature,
|
||||||
|
messages: [
|
||||||
|
...(system ? [{ role: 'system', content: system }] : []),
|
||||||
|
...messages.map((m) => ({ role: m.role, content: m.content })),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
// Local servers accept (and ignore) bearer keys; harmless to send always
|
||||||
|
if (settings.apiKey) headers.Authorization = `Bearer ${settings.apiKey}`;
|
||||||
|
|
||||||
|
const response = await fetchImpl(`${settings.baseUrl}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new AiProviderError(
|
||||||
|
`AI request failed (HTTP ${response.status}). Check the model name, API key, and base URL.`,
|
||||||
|
`http_${response.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const text = data?.choices?.[0]?.message?.content;
|
||||||
|
if (typeof text !== 'string') {
|
||||||
|
throw new AiProviderError(
|
||||||
|
'AI provider returned an unexpected response shape.',
|
||||||
|
'bad_response'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call Anthropic's messages API.
|
||||||
|
* @returns {Promise<string>} first text block of the reply
|
||||||
|
*/
|
||||||
|
async function callAnthropic(settings, { system, messages }, fetchImpl, timeoutMs) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
// Anthropic splits system prompts out of the message list
|
||||||
|
const body = {
|
||||||
|
model: settings.model,
|
||||||
|
max_tokens: 4096,
|
||||||
|
temperature: settings.temperature,
|
||||||
|
system: system || undefined,
|
||||||
|
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
||||||
|
};
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
};
|
||||||
|
if (settings.apiKey) {
|
||||||
|
// Official API keys travel via x-api-key; many compatible gateways
|
||||||
|
// (LiteLLM proxies, Bedrock fronts) expect a Bearer token instead.
|
||||||
|
// Sending both is harmless for the official endpoint and maximizes
|
||||||
|
// proxy compatibility.
|
||||||
|
headers['x-api-key'] = settings.apiKey;
|
||||||
|
headers.Authorization = `Bearer ${settings.apiKey}`;
|
||||||
|
}
|
||||||
|
// Bases may or may not already carry the /v1 prefix — handle both so a
|
||||||
|
// "http://host:4000" proxy base and an "http://host:4000/v1" style base
|
||||||
|
// both land on a single /v1/messages path.
|
||||||
|
const messagesUrl = settings.baseUrl.endsWith('/v1')
|
||||||
|
? `${settings.baseUrl}/messages`
|
||||||
|
: `${settings.baseUrl}/v1/messages`;
|
||||||
|
const response = await fetchImpl(messagesUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new AiProviderError(
|
||||||
|
`AI request failed (HTTP ${response.status}). Check the model name, API key, and base URL.`,
|
||||||
|
`http_${response.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const text = Array.isArray(data?.content)
|
||||||
|
? data.content
|
||||||
|
.filter((b) => b.type === 'text')
|
||||||
|
.map((b) => b.text)
|
||||||
|
.join('')
|
||||||
|
: '';
|
||||||
|
if (!text) {
|
||||||
|
throw new AiProviderError(
|
||||||
|
'AI provider returned an unexpected response shape.',
|
||||||
|
'bad_response'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a chat completion against the configured provider.
|
||||||
|
*
|
||||||
|
* @param {object} request - { provider, baseUrl, apiKey, model, temperature,
|
||||||
|
* system, messages: [{role, content}] }
|
||||||
|
* @param {object} [options] - { fetchImpl, timeoutMs }
|
||||||
|
* @returns {Promise<{content: string}>}
|
||||||
|
* @throws {AiProviderError} with user-safe messages (code field for UI logic)
|
||||||
|
*/
|
||||||
|
async function complete(request, options = {}) {
|
||||||
|
const fetchImpl = options.fetchImpl || global.fetch;
|
||||||
|
if (typeof fetchImpl !== 'function') {
|
||||||
|
throw new AiProviderError('No fetch implementation available.', 'no_fetch');
|
||||||
|
}
|
||||||
|
const timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT_MS;
|
||||||
|
|
||||||
|
if (!Array.isArray(request?.messages) || request.messages.length === 0) {
|
||||||
|
throw new AiProviderError('No messages provided.', 'no_messages');
|
||||||
|
}
|
||||||
|
const totalChars =
|
||||||
|
(request.system?.length || 0) +
|
||||||
|
request.messages.reduce((n, m) => n + (m?.content?.length || 0), 0);
|
||||||
|
if (totalChars > MAX_PROMPT_CHARS) {
|
||||||
|
throw new AiProviderError(
|
||||||
|
'Prompt is too large (over 200KB). Try a smaller selection.',
|
||||||
|
'prompt_too_large'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = resolveSettings(request);
|
||||||
|
if (OPENAI_STYLE.has(settings.provider)) {
|
||||||
|
const content = await callOpenAiStyle(settings, request, fetchImpl, timeoutMs);
|
||||||
|
return { content };
|
||||||
|
}
|
||||||
|
const content = await callAnthropic(settings, request, fetchImpl, timeoutMs);
|
||||||
|
return { content };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
complete,
|
||||||
|
resolveSettings,
|
||||||
|
AiProviderError,
|
||||||
|
PROVIDER_DEFAULTS,
|
||||||
|
MAX_PROMPT_CHARS,
|
||||||
|
};
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* MarkItDown bridge — "any file → Markdown" import via Microsoft's
|
||||||
|
* markitdown Python tool (https://github.com/microsoft/markitdown, MIT).
|
||||||
|
*
|
||||||
|
* markitdown is a Python CLI, so like Pandoc/LibreOffice/FFmpeg it is used
|
||||||
|
* when installed rather than bundled: the module probes for it once
|
||||||
|
* (`markitdown` binary, then `python -m markitdown` / `python3 -m markitdown`)
|
||||||
|
* and caches the resolved command. Every invocation goes through an argv
|
||||||
|
* array via execFile — user-controlled paths are never passed through a
|
||||||
|
* shell (same SEC-1 discipline as the Pandoc path).
|
||||||
|
*
|
||||||
|
* Supported by markitdown (core install): PDF, DOCX, PPTX, XLSX, Outlook
|
||||||
|
* .msg/.eml, HTML, EPUB, images (EXIF), CSV/JSON/XML, ZIP archives, YouTube
|
||||||
|
* URLs. Audio transcription and image OCR need the `[all]` extras:
|
||||||
|
* pip install 'markitdown[all]'
|
||||||
|
*
|
||||||
|
* The execFile runner is injectable so tests can stub process spawning.
|
||||||
|
*
|
||||||
|
* @module MarkItDown
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CONVERT_TIMEOUT_MS = 120000;
|
||||||
|
const MAX_OUTPUT_BUFFER = 20 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path to the bundled markitdown binary (built by scripts/bundle-markitdown.js
|
||||||
|
* via PyInstaller), when the app ships one. Layout matches getPandocPath:
|
||||||
|
* dev: bin/<platform>/markitdown · packaged: <app dir>/bin/markitdown (next
|
||||||
|
* to the executable on Linux/Windows, Contents/bin on macOS — the same
|
||||||
|
* extraFiles destination). Returns null when absent (PATH/python fallbacks).
|
||||||
|
*/
|
||||||
|
function getBundledMarkItDownPath() {
|
||||||
|
const pathUtil = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const exe = process.platform === 'win32' ? 'markitdown.exe' : 'markitdown';
|
||||||
|
try {
|
||||||
|
// Only meaningful under Electron; tests import this module under Node
|
||||||
|
const electron = require('electron');
|
||||||
|
if (electron.app?.isPackaged) {
|
||||||
|
const base =
|
||||||
|
process.platform === 'darwin'
|
||||||
|
? pathUtil.dirname(electron.app.getPath('exe')) // …/Contents/MacOS
|
||||||
|
: pathUtil.dirname(process.execPath);
|
||||||
|
const packaged =
|
||||||
|
process.platform === 'darwin'
|
||||||
|
? pathUtil.join(base, '..', 'bin', exe) // …/Contents/bin
|
||||||
|
: pathUtil.join(base, 'bin', exe);
|
||||||
|
if (fs.existsSync(packaged)) return packaged;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* not running under Electron (tests) — use the dev layout below */
|
||||||
|
}
|
||||||
|
const dev = pathUtil.join(__dirname, '..', '..', 'bin', process.platform, exe);
|
||||||
|
return fs.existsSync(dev) ? dev : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Candidate command templates probed in order (bundled binary first). The
|
||||||
|
* bundled candidate is verified with the same --version probe as the rest.
|
||||||
|
*/
|
||||||
|
function commandCandidates() {
|
||||||
|
const bundled = getBundledMarkItDownPath();
|
||||||
|
const candidates = [];
|
||||||
|
if (bundled) candidates.push({ command: bundled, argsPrefix: [], bundled: true });
|
||||||
|
candidates.push({ command: 'markitdown', argsPrefix: [] });
|
||||||
|
candidates.push({
|
||||||
|
command: process.platform === 'win32' ? 'python' : 'python3',
|
||||||
|
argsPrefix: ['-m', 'markitdown'],
|
||||||
|
});
|
||||||
|
candidates.push({ command: 'python3', argsPrefix: ['-m', 'markitdown'] });
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run one probe: `--version` exits 0 when the tool is importable. */
|
||||||
|
function probeCandidate(runner, candidate) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
runner(
|
||||||
|
candidate.command,
|
||||||
|
[...candidate.argsPrefix, '--version'],
|
||||||
|
{ timeout: 15000 },
|
||||||
|
(error, stdout) => {
|
||||||
|
if (error) return resolve(null);
|
||||||
|
const match = /(\d+\.\d+(?:\.\d+)?)/.exec(String(stdout || ''));
|
||||||
|
resolve({
|
||||||
|
command: candidate.command,
|
||||||
|
argsPrefix: candidate.argsPrefix,
|
||||||
|
version: match ? match[1] : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the markitdown invocation. Probes candidates in order and returns
|
||||||
|
* the first that answers `--version`, or null when none is installed.
|
||||||
|
*
|
||||||
|
* @param {Function} runner execFile-style (cmd, args, opts, cb)
|
||||||
|
* @returns {Promise<{command: string, argsPrefix: string[], version: string|null}|null>}
|
||||||
|
*/
|
||||||
|
async function resolveMarkItDown(runner) {
|
||||||
|
for (const candidate of commandCandidates()) {
|
||||||
|
const resolved = await probeCandidate(runner, candidate);
|
||||||
|
if (resolved) return resolved;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert any supported file to Markdown.
|
||||||
|
*
|
||||||
|
* @param {string} inputPath Absolute path to the source file
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {Function} [options.runner] injectable execFile (defaults to child_process)
|
||||||
|
* @param {Function} [options.pathUtil] injected path module
|
||||||
|
* @param {{command: string, argsPrefix: string[]}} [options.resolved] skip
|
||||||
|
* probing when the caller already knows the command (main caches it)
|
||||||
|
* @returns {Promise<{content: string}>} Markdown printed by markitdown on stdout
|
||||||
|
* @throws {Error} with a user-safe message when the tool is missing or fails
|
||||||
|
*/
|
||||||
|
async function convertToMarkdown(inputPath, options = {}) {
|
||||||
|
const runner = options.runner || require('child_process').execFile;
|
||||||
|
const pathUtil = options.pathUtil || require('path');
|
||||||
|
|
||||||
|
// Validate the path BEFORE probing for the tool: a bad path is the user's
|
||||||
|
// most actionable error and shouldn't be masked by an install hint
|
||||||
|
if (typeof inputPath !== 'string' || !pathUtil.isAbsolute(inputPath)) {
|
||||||
|
const err = new Error('MarkItDown import needs an absolute file path.');
|
||||||
|
err.code = 'bad_path';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = options.resolved || (await resolveMarkItDown(runner));
|
||||||
|
if (!resolved) {
|
||||||
|
const err = new Error(
|
||||||
|
'MarkItDown is not installed. Install it with:\n\npip install "markitdown[all]"\n\n' +
|
||||||
|
'(or the lighter core: pip install markitdown)'
|
||||||
|
);
|
||||||
|
err.code = 'not_installed';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// markitdown prints the converted Markdown to stdout; keep everything in
|
||||||
|
// argv so the path is never re-interpreted by a shell.
|
||||||
|
runner(
|
||||||
|
resolved.command,
|
||||||
|
[...resolved.argsPrefix, inputPath],
|
||||||
|
{ timeout: CONVERT_TIMEOUT_MS, maxBuffer: MAX_OUTPUT_BUFFER },
|
||||||
|
(error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
// Strip absolute paths, then keep the first informative line from
|
||||||
|
// either the error or stderr — markitdown writes tracebacks there
|
||||||
|
// (e.g. a missing optional dependency like pdfminer.six for PDFs).
|
||||||
|
const safe = (text) =>
|
||||||
|
String(text || '')
|
||||||
|
.replace(/[A-Z]:\\[^\s"']+/gi, '(path)')
|
||||||
|
.replace(/\/(?:home|Users|tmp|mnt)\/[^\s"']+/g, '(path)');
|
||||||
|
// Prefer the LAST informative stderr line: markitdown ends its
|
||||||
|
// output with the actionable install hint ("pip install
|
||||||
|
// 'markitdown[pdf]'"), while the first lines are a traceback.
|
||||||
|
const stderrLines = safe(stderr).split('\n').filter((l) => l.trim().length > 0);
|
||||||
|
const detail =
|
||||||
|
stderrLines[stderrLines.length - 1] ||
|
||||||
|
safe(error.message).split('\n')[0] ||
|
||||||
|
'unknown error';
|
||||||
|
const err2 = new Error(`MarkItDown conversion failed: ${detail}`);
|
||||||
|
err2.code = String(error.code || '').startsWith('ETIMEDOUT') ? 'timeout' : 'failed';
|
||||||
|
err2.stderr = safe(stderr).slice(0, 2000);
|
||||||
|
reject(err2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const content = String(stdout || '');
|
||||||
|
if (!content.trim()) {
|
||||||
|
const err3 = new Error(
|
||||||
|
'MarkItDown produced no output — the file type may be unsupported ' +
|
||||||
|
'(core install covers PDF/DOCX/PPTX/XLSX/MSG/HTML/EPUB/images/CSV/JSON/XML/ZIP; ' +
|
||||||
|
"audio/OCR need pip install 'markitdown[all]')."
|
||||||
|
);
|
||||||
|
err3.code = 'empty_output';
|
||||||
|
reject(err3);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Strip a UTF-8 BOM if present so downstream markdown tooling is happy
|
||||||
|
resolve({ content: content.replace(/^\uFEFF/, '') });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
resolveMarkItDown,
|
||||||
|
convertToMarkdown,
|
||||||
|
commandCandidates,
|
||||||
|
getBundledMarkItDownPath,
|
||||||
|
};
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
/**
|
||||||
|
* ODT post-export styling: page size + headers/footers.
|
||||||
|
*
|
||||||
|
* Pandoc's ODT writer always emits a `styles.xml` containing:
|
||||||
|
* - an automatic-styles section with a page layout (typically "pm1") holding
|
||||||
|
* `<style:page-layout-properties>` (width/height/margins), and
|
||||||
|
* - `<office:master-styles>` with a `Standard` master page that references
|
||||||
|
* that page layout. Page headers/footers in ODF attach to the master page
|
||||||
|
* as `<style:header>` / `<style:footer>` children, each split into
|
||||||
|
* `<style:region-left|center|right>` regions.
|
||||||
|
*
|
||||||
|
* This module patches those two spots in-place with PizZip string surgery
|
||||||
|
* (the same technique `addHeaderFooterToDocx`/`setDocxPageSize` use for DOCX).
|
||||||
|
* All edits are best-effort: a malformed or unexpected styles.xml logs and
|
||||||
|
* leaves the file untouched rather than corrupting the export.
|
||||||
|
*
|
||||||
|
* Exposed for tests: `parseDimensionsMm`, `escapeOdtText`.
|
||||||
|
*
|
||||||
|
* @module OdtStyling
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape text for safe embedding inside ODF XML text nodes.
|
||||||
|
* ODF uses standard XML escaping; quotes are escaped too so values can also be
|
||||||
|
* used inside attribute values safely.
|
||||||
|
*
|
||||||
|
* @param {string} text Raw user text
|
||||||
|
* @returns {string} XML-safe text
|
||||||
|
*/
|
||||||
|
function escapeOdtText(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
return String(text)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a PAGE_SIZES `dimensions` string ("210×297mm" — U+00D7 separator)
|
||||||
|
* into millimeter numbers. Returns null when the string is unparseable so
|
||||||
|
* callers can fall back to A4.
|
||||||
|
*
|
||||||
|
* @param {string} dimensions e.g. "210×297mm"
|
||||||
|
* @returns {{widthMm: number, heightMm: number}|null}
|
||||||
|
*/
|
||||||
|
function parseDimensionsMm(dimensions) {
|
||||||
|
if (typeof dimensions !== 'string') return null;
|
||||||
|
const match = /^(\d+(?:\.\d+)?)\s*[×x]\s*(\d+(?:\.\d+)?)\s*mm$/i.exec(dimensions.trim());
|
||||||
|
if (!match) return null;
|
||||||
|
return { widthMm: parseFloat(match[1]), heightMm: parseFloat(match[2]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read `styles.xml` out of an ODT (zip) file as text.
|
||||||
|
*
|
||||||
|
* @param {string} odtPath Path to the .odt file
|
||||||
|
* @param {Function} PizZipUtil Injected PizZip constructor (keeps the module
|
||||||
|
* lazy-load friendly and lets tests pass the real one)
|
||||||
|
* @returns {{zip: object, stylesXml: string}}
|
||||||
|
*/
|
||||||
|
function openOdtStyles(odtPath, PizZipUtil) {
|
||||||
|
const zip = new PizZipUtil(fs.readFileSync(odtPath));
|
||||||
|
const stylesFile = zip.file('styles.xml');
|
||||||
|
if (!stylesFile) {
|
||||||
|
throw new Error('styles.xml not found in ODT package');
|
||||||
|
}
|
||||||
|
return { zip, stylesXml: stylesFile.asText() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the page size/orientation on every `<style:page-layout-properties>`
|
||||||
|
* element in styles.xml (an ODT normally has exactly one). Width/height are
|
||||||
|
* written as `fo:page-width`/`fo:page-height` in mm, orientation as
|
||||||
|
* `style:print-orientation`, and landscape swaps width/height (ODF expects
|
||||||
|
* portrait-orientation dimensions plus the print-orientation flag, matching
|
||||||
|
* how LibreOffice itself saves landscape documents).
|
||||||
|
*
|
||||||
|
* @param {string} odtPath Path to the .odt to patch (modified in place)
|
||||||
|
* @param {{size?: string, customWidth?: string|number, customHeight?: string|number,
|
||||||
|
* orientation?: string, pageSizes?: object}} pageSettings App page
|
||||||
|
* settings; `pageSizes` injects the app's PAGE_SIZES map (main.js owns it).
|
||||||
|
* @returns {Promise<void>} Resolves when written; errors are logged, never thrown
|
||||||
|
*/
|
||||||
|
async function setOdtPageSize(odtPath, pageSettings = {}) {
|
||||||
|
try {
|
||||||
|
const PizZipUtil = require('pizzip');
|
||||||
|
const { zip, stylesXml } = openOdtStyles(odtPath, PizZipUtil);
|
||||||
|
|
||||||
|
// Resolve width/height in mm, mirroring setDocxPageSize's priority:
|
||||||
|
// named size → custom → A4 default.
|
||||||
|
const sizes = pageSettings.pageSizes || {};
|
||||||
|
let widthMm;
|
||||||
|
let heightMm;
|
||||||
|
const named = sizes[pageSettings.size];
|
||||||
|
const parsed = named ? parseDimensionsMm(named.dimensions) : null;
|
||||||
|
if (parsed) {
|
||||||
|
widthMm = parsed.widthMm;
|
||||||
|
heightMm = parsed.heightMm;
|
||||||
|
} else if (pageSettings.customWidth && pageSettings.customHeight) {
|
||||||
|
// Custom sizes are entered in mm in the export dialog
|
||||||
|
widthMm = parseFloat(pageSettings.customWidth) || 210;
|
||||||
|
heightMm = parseFloat(pageSettings.customHeight) || 297;
|
||||||
|
} else {
|
||||||
|
widthMm = 210;
|
||||||
|
heightMm = 297;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orientation = pageSettings.orientation === 'landscape' ? 'landscape' : 'portrait';
|
||||||
|
if (orientation === 'landscape') {
|
||||||
|
[widthMm, heightMm] = [heightMm, widthMm];
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated = stylesXml;
|
||||||
|
let patched = false;
|
||||||
|
|
||||||
|
// Replace attributes on every existing page-layout-properties element
|
||||||
|
updated = updated.replace(/<style:page-layout-properties\b[^>]*>/g, (tag) => {
|
||||||
|
patched = true;
|
||||||
|
let next = tag;
|
||||||
|
// Drop any prior size/orientation attributes so re-exports stay correct
|
||||||
|
next = next.replace(
|
||||||
|
/\s(?:fo:page-width|fo:page-height|style:print-orientation)="[^"]*"/g,
|
||||||
|
''
|
||||||
|
);
|
||||||
|
return next.replace(
|
||||||
|
/<style:page-layout-properties\b/,
|
||||||
|
`<style:page-layout-properties fo:page-width="${widthMm}mm" fo:page-height="${heightMm}mm"` +
|
||||||
|
` style:print-orientation="${orientation}"`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pandoc always emits a page layout, but if one is missing (self-closing
|
||||||
|
// <style:page-layout .../> with no properties), inject the properties in
|
||||||
|
if (!patched) {
|
||||||
|
updated = updated.replace(
|
||||||
|
/<style:page-layout\b([^>]*)\/>/g,
|
||||||
|
(_m, attrs) =>
|
||||||
|
`<style:page-layout${attrs}>` +
|
||||||
|
`<style:page-layout-properties fo:page-width="${widthMm}mm" fo:page-height="${heightMm}mm"` +
|
||||||
|
` style:print-orientation="${orientation}"` +
|
||||||
|
`/></style:page-layout>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
zip.file('styles.xml', updated);
|
||||||
|
fs.writeFileSync(odtPath, zip.generate({ type: 'nodebuffer' }));
|
||||||
|
} catch (error) {
|
||||||
|
// Best-effort: a failed style patch must never lose the user's export
|
||||||
|
console.error('Failed to set ODT page size:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the XML for one header/footer region triple. $PAGE$/$TOTAL$ become ODF
|
||||||
|
* fields (`<text:page-number>` / `<text:page-count>`), matching the DOCX
|
||||||
|
* exporter's PAGE/NUMPAGES field handling.
|
||||||
|
*
|
||||||
|
* @param {{left?: string, center?: string, right?: string}} regions
|
||||||
|
* @param {'header'|'footer'} kind Only used for the element name
|
||||||
|
* @returns {string} `<style:header>…</style:header>` XML, or '' when all regions empty
|
||||||
|
*/
|
||||||
|
function buildHeaderFooterXml(regions, kind) {
|
||||||
|
const regionNames = ['left', 'center', 'right'];
|
||||||
|
const used = regionNames.some((name) => regions[name] && regions[name].trim());
|
||||||
|
if (!used) return '';
|
||||||
|
|
||||||
|
const fields = {
|
||||||
|
$PAGE$: '<text:page-number>1</text:page-number>',
|
||||||
|
$TOTAL$: '<text:page-count>1</text:page-count>',
|
||||||
|
};
|
||||||
|
|
||||||
|
const regionXml = regionNames
|
||||||
|
.map((name) => {
|
||||||
|
const raw = regions[name] || '';
|
||||||
|
if (!raw.trim()) return '';
|
||||||
|
// Split on the field markers and emit literal runs / field elements
|
||||||
|
const parts = raw.split(/(\$PAGE\$|\$TOTAL\$)/);
|
||||||
|
const inner = parts
|
||||||
|
.map((part) => (fields[part] ? fields[part] : escapeOdtText(part)))
|
||||||
|
.join('');
|
||||||
|
return `<style:region-${name}><text:p text:style-name="HeaderAndFooter">${inner}</text:p></style:region-${name}>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
return `<style:${kind}>${regionXml}</style:${kind}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add headers/footers to an ODT by injecting `<style:header>`/`<style:footer>`
|
||||||
|
* into the `Standard` master page in styles.xml. Existing header/footer
|
||||||
|
* elements on that master page are removed first so re-exports don't nest.
|
||||||
|
* The `settings` shape mirrors `headerFooterSettings` in main.js; dynamic
|
||||||
|
* fields ($DATE$, $TITLE$, …) are expected to be resolved by the caller via
|
||||||
|
* processDynamicFields before this runs (mirroring addHeaderFooterToDocx).
|
||||||
|
*
|
||||||
|
* @param {string} odtPath Path to the .odt to patch (modified in place)
|
||||||
|
* @param {{enabled?: boolean, header?: object, footer?: object}} settings
|
||||||
|
* @returns {Promise<void>} Resolves when written; errors are logged, never thrown
|
||||||
|
*/
|
||||||
|
async function addHeaderFooterToOdt(odtPath, settings = {}) {
|
||||||
|
if (!settings.enabled) return;
|
||||||
|
try {
|
||||||
|
const PizZipUtil = require('pizzip');
|
||||||
|
const { zip, stylesXml } = openOdtStyles(odtPath, PizZipUtil);
|
||||||
|
|
||||||
|
const headerXml = buildHeaderFooterXml(settings.header || {}, 'header');
|
||||||
|
const footerXml = buildHeaderFooterXml(settings.footer || {}, 'footer');
|
||||||
|
if (!headerXml && !footerXml) return;
|
||||||
|
|
||||||
|
let updated = stylesXml;
|
||||||
|
|
||||||
|
if (/<style:master-page\b[^>]*style:name="Standard"/.test(updated)) {
|
||||||
|
// Paired <style:master-page ...>…</style:master-page>: strip any existing
|
||||||
|
// header/footer from the inner content, then prepend the new ones.
|
||||||
|
// Self-closing <style:master-page ... /> (Pandoc's usual output) has no
|
||||||
|
// inner content, so it is expanded into a paired tag instead.
|
||||||
|
const paired =
|
||||||
|
/(<style:master-page\b[^>]*style:name="Standard"[^>]*>)([\s\S]*?)(<\/style:master-page>)/.exec(
|
||||||
|
updated
|
||||||
|
);
|
||||||
|
if (paired) {
|
||||||
|
updated = updated.replace(paired[0], (_m, openTag, inner) => {
|
||||||
|
const cleaned = inner
|
||||||
|
.replace(/<style:header\b[\s\S]*?<\/style:header>/g, '')
|
||||||
|
.replace(/<style:footer\b[\s\S]*?<\/style:footer>/g, '')
|
||||||
|
.replace(/<style:header\b[^>]*\/>/g, '')
|
||||||
|
.replace(/<style:footer\b[^>]*\/>/g, '');
|
||||||
|
return `${openTag}${headerXml}${footerXml}${cleaned}</style:master-page>`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updated = updated.replace(
|
||||||
|
/<style:master-page\b([^>]*?)\s*\/>/,
|
||||||
|
(_m, attrs) => `<style:master-page${attrs}>${headerXml}${footerXml}</style:master-page>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (/<office:master-styles\b/.test(updated)) {
|
||||||
|
// Master-styles section exists but no Standard page: append one that
|
||||||
|
// uses the document's first page layout (Pandoc's "pm1" convention).
|
||||||
|
const layoutName = /<style:page-layout\b[^>]*style:name="([^"]+)"/.exec(updated);
|
||||||
|
const ref = layoutName ? ` style:page-layout-name="${layoutName[1]}"` : '';
|
||||||
|
updated = updated.replace(
|
||||||
|
/<office:master-styles\b[^>]*>/,
|
||||||
|
(openTag) =>
|
||||||
|
`${openTag}<style:master-page style:name="Standard"${ref}>${headerXml}${footerXml}</style:master-page>`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// No master-styles at all: master-styles is the last child element of
|
||||||
|
// office:document-styles, so inserting before its close tag is valid ODF
|
||||||
|
updated = updated.replace(
|
||||||
|
/<\/office:document-styles>/,
|
||||||
|
`<office:master-styles><style:master-page style:name="Standard">${headerXml}${footerXml}</style:master-page></office:master-styles></office:document-styles>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
zip.file('styles.xml', updated);
|
||||||
|
fs.writeFileSync(odtPath, zip.generate({ type: 'nodebuffer' }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add headers/footers to ODT:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
setOdtPageSize,
|
||||||
|
addHeaderFooterToOdt,
|
||||||
|
// Exported for unit tests
|
||||||
|
parseDimensionsMm,
|
||||||
|
escapeOdtText,
|
||||||
|
};
|
||||||
@@ -22,10 +22,9 @@
|
|||||||
* cannot supply (merge takes many inputs in one op; reorder needs each
|
* cannot supply (merge takes many inputs in one op; reorder needs each
|
||||||
* file's full page order; fillForm's field values differ per file).
|
* file's full page order; fillForm's field values differ per file).
|
||||||
* - formFields — a read-only query returning data, not a transform.
|
* - formFields — a read-only query returning data, not a transform.
|
||||||
* - encrypt / decrypt / permissions — pdf-lib 1.17.1 (bundled) lacks
|
* - encrypt / decrypt / permissions — these need per-file secrets (passwords)
|
||||||
* encryption support, so since Task 27 these ops fail honestly with an
|
* that the folder-batch flow cannot supply meaningfully, so they stay
|
||||||
* "unavailable" result instead of silently writing unprotected files;
|
* single-file operations in the PDF Editor dialog.
|
||||||
* a batch run would deterministically fail every file.
|
|
||||||
*
|
*
|
||||||
* @module PDFBatchOperations
|
* @module PDFBatchOperations
|
||||||
*/
|
*/
|
||||||
|
|||||||
+26
-16
@@ -1,20 +1,20 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { PDFDocument, rgb, degrees, StandardFonts } = require('pdf-lib');
|
// @cantoo/pdf-lib is a drop-in pdf-lib fork that adds real encryption support
|
||||||
|
// (doc.encrypt() + password-protected loading), which upstream pdf-lib 1.17.1
|
||||||
|
// lacks. Rather than trusting a pinned version string, probe the installed
|
||||||
|
// library once at module load: encrypt a tiny in-memory document and check the
|
||||||
|
// raw bytes for an /Encrypt dictionary (which an unencrypted document never
|
||||||
|
// contains). A library that supports encryption passes the probe and the
|
||||||
|
// password ops re-enable automatically. Probe errors fail closed (treated as
|
||||||
|
// unsupported).
|
||||||
|
const { PDFDocument, rgb, degrees, StandardFonts } = require('@cantoo/pdf-lib');
|
||||||
|
|
||||||
// pdf-lib 1.17.1 cannot encrypt: SaveOptions has no userPassword/ownerPassword/
|
|
||||||
// permissions fields, so save() silently ignores them and writes an unprotected
|
|
||||||
// file, and PDFDocument.load() cannot open password-protected input (verified
|
|
||||||
// empirically in Task 22's review). Rather than trusting a pinned version
|
|
||||||
// string, probe the installed library once at module load: save a tiny
|
|
||||||
// in-memory document with a userPassword and check the raw bytes for an
|
|
||||||
// /Encrypt dictionary (which an unencrypted document never contains). A library
|
|
||||||
// that supports encryption passes the probe and the password ops re-enable
|
|
||||||
// automatically. Probe errors fail closed (treated as unsupported).
|
|
||||||
const pdfEncryptionSupported = (async () => {
|
const pdfEncryptionSupported = (async () => {
|
||||||
try {
|
try {
|
||||||
const probeDoc = await PDFDocument.create();
|
const probeDoc = await PDFDocument.create();
|
||||||
const probeBytes = await probeDoc.save({ userPassword: 'encryption-capability-probe' });
|
probeDoc.encrypt({ userPassword: 'encryption-capability-probe' });
|
||||||
|
const probeBytes = await probeDoc.save();
|
||||||
return Buffer.from(probeBytes).includes('/Encrypt');
|
return Buffer.from(probeBytes).includes('/Encrypt');
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -335,7 +335,7 @@ async function pdfEncrypt(data) {
|
|||||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
const pdf = await PDFDocument.load(pdfBytes);
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
|
||||||
const encryptedPdfBytes = await pdf.save({
|
pdf.encrypt({
|
||||||
userPassword: data.userPassword,
|
userPassword: data.userPassword,
|
||||||
ownerPassword: data.ownerPassword || data.userPassword,
|
ownerPassword: data.ownerPassword || data.userPassword,
|
||||||
permissions: {
|
permissions: {
|
||||||
@@ -349,6 +349,7 @@ async function pdfEncrypt(data) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const encryptedPdfBytes = await pdf.save();
|
||||||
fs.writeFileSync(data.outputPath, encryptedPdfBytes);
|
fs.writeFileSync(data.outputPath, encryptedPdfBytes);
|
||||||
|
|
||||||
return { success: true, message: 'Successfully added password protection to PDF' };
|
return { success: true, message: 'Successfully added password protection to PDF' };
|
||||||
@@ -357,7 +358,7 @@ async function pdfEncrypt(data) {
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error:
|
||||||
'PDF encryption requires pdf-lib with encryption support. This feature may not be available in the current version.',
|
'PDF encryption requires @cantoo/pdf-lib with encryption support. This feature may not be available in the current version.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
@@ -372,7 +373,14 @@ async function pdfDecrypt(data) {
|
|||||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
const pdf = await PDFDocument.load(pdfBytes, { password: data.password });
|
const pdf = await PDFDocument.load(pdfBytes, { password: data.password });
|
||||||
|
|
||||||
const decryptedPdfBytes = await pdf.save();
|
// The loaded context retains its /Encrypt security object, so re-saving it
|
||||||
|
// would keep the document encrypted. Copy the pages into a fresh document
|
||||||
|
// instead, which produces a clean, unencrypted file.
|
||||||
|
const decrypted = await PDFDocument.create();
|
||||||
|
const copiedPages = await decrypted.copyPages(pdf, pdf.getPageIndices());
|
||||||
|
copiedPages.forEach((page) => decrypted.addPage(page));
|
||||||
|
|
||||||
|
const decryptedPdfBytes = await decrypted.save();
|
||||||
fs.writeFileSync(data.outputPath, decryptedPdfBytes);
|
fs.writeFileSync(data.outputPath, decryptedPdfBytes);
|
||||||
|
|
||||||
return { success: true, message: 'Successfully removed password protection from PDF' };
|
return { success: true, message: 'Successfully removed password protection from PDF' };
|
||||||
@@ -393,7 +401,7 @@ async function pdfSetPermissions(data) {
|
|||||||
const loadOptions = data.currentPassword ? { password: data.currentPassword } : {};
|
const loadOptions = data.currentPassword ? { password: data.currentPassword } : {};
|
||||||
const pdf = await PDFDocument.load(pdfBytes, loadOptions);
|
const pdf = await PDFDocument.load(pdfBytes, loadOptions);
|
||||||
|
|
||||||
const newPdfBytes = await pdf.save({
|
pdf.encrypt({
|
||||||
ownerPassword: data.ownerPassword,
|
ownerPassword: data.ownerPassword,
|
||||||
permissions: {
|
permissions: {
|
||||||
printing: data.permissions.printing ? 'highResolution' : 'lowResolution',
|
printing: data.permissions.printing ? 'highResolution' : 'lowResolution',
|
||||||
@@ -406,6 +414,8 @@ async function pdfSetPermissions(data) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const newPdfBytes = await pdf.save();
|
||||||
|
|
||||||
fs.writeFileSync(data.outputPath, newPdfBytes);
|
fs.writeFileSync(data.outputPath, newPdfBytes);
|
||||||
|
|
||||||
return { success: true, message: 'Successfully updated PDF permissions' };
|
return { success: true, message: 'Successfully updated PDF permissions' };
|
||||||
@@ -414,7 +424,7 @@ async function pdfSetPermissions(data) {
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error:
|
||||||
'PDF permissions require pdf-lib with encryption support. This feature may not be available in the current version.',
|
'PDF permissions require @cantoo/pdf-lib with encryption support. This feature may not be available in the current version.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* Local document version history ("timemachine lite").
|
||||||
|
*
|
||||||
|
* Every time a file is saved, the previous on-disk content is snapshotted to
|
||||||
|
* <userData>/versions/<hash-of-path>/<timestamp>.md plus a meta.json index.
|
||||||
|
* Users browse/restore/diff versions from the History sidebar panel —
|
||||||
|
* recovery from a bad edit no longer depends on Git.
|
||||||
|
*
|
||||||
|
* Design notes:
|
||||||
|
* - Versions store the content that is about to be REPLACED (pre-save), so
|
||||||
|
* the newest version is always the last state before the current one.
|
||||||
|
* - `maxKeep` prunes oldest entries per document (default 20).
|
||||||
|
* - Version ids are `<ms-timestamp>-<suffix>`; strict format validation on
|
||||||
|
* read prevents path traversal via crafted ids.
|
||||||
|
* - All fs/path access is injectable for unit tests.
|
||||||
|
*
|
||||||
|
* @module VersionHistory
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULT_MAX_KEEP = 20;
|
||||||
|
|
||||||
|
/** Inject a crypto-like object (Node's crypto by default). */
|
||||||
|
function defaultCrypto() {
|
||||||
|
return require('crypto');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable per-document storage folder name: first 16 hex chars of the sha1 of
|
||||||
|
* the absolute path. Avoids OS path-length and illegal-character issues.
|
||||||
|
*/
|
||||||
|
function folderFor(docPath, pathUtil, crypto) {
|
||||||
|
const hash = crypto.createHash('sha1').update(String(docPath)).digest('hex').slice(0, 16);
|
||||||
|
return pathUtil.join('by-path', hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a fresh version id for "now" (sortable + filesystem-safe). */
|
||||||
|
function newVersionId(now = Date.now()) {
|
||||||
|
return `${now.toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Version ids must be strictly alphanumeric-dash to stay inside the folder. */
|
||||||
|
function isValidVersionId(id) {
|
||||||
|
return typeof id === 'string' && /^[a-z0-9-]{6,64}$/.test(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot one version of a document.
|
||||||
|
*
|
||||||
|
* @param {object} args
|
||||||
|
* @param {string} args.docPath Absolute path of the document
|
||||||
|
* @param {string} args.content The content to snapshot
|
||||||
|
* @param {string} [args.label='auto-save'] How it was captured
|
||||||
|
* @param {number} [args.maxKeep] Prune to this many versions per document
|
||||||
|
* @param {object} args.io { rootDir, fs, pathUtil, crypto } — rootDir is the
|
||||||
|
* versions root (main passes <userData>/versions)
|
||||||
|
* @returns {{id, createdAt, label, wordCount}} the stored version meta
|
||||||
|
*/
|
||||||
|
function saveVersion({ docPath, content, label = 'auto-save', maxKeep = DEFAULT_MAX_KEEP, io }) {
|
||||||
|
const { rootDir, fs, pathUtil, crypto = defaultCrypto() } = io;
|
||||||
|
const dir = pathUtil.join(rootDir, folderFor(docPath, pathUtil, crypto));
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
|
const id = newVersionId();
|
||||||
|
const createdAt = Date.now();
|
||||||
|
const wordCount = String(content || '')
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean).length;
|
||||||
|
|
||||||
|
// meta.json is the index; the .md blob is the content
|
||||||
|
const metaPath = pathUtil.join(dir, 'meta.json');
|
||||||
|
let meta = { doc: docPath, versions: [] };
|
||||||
|
try {
|
||||||
|
meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
/* first version for this document */
|
||||||
|
}
|
||||||
|
if (!Array.isArray(meta.versions)) meta.versions = [];
|
||||||
|
|
||||||
|
const entry = { id, createdAt, label, wordCount };
|
||||||
|
meta.versions.unshift(entry);
|
||||||
|
// Prune oldest beyond maxKeep (entries are newest-first)
|
||||||
|
if (meta.versions.length > maxKeep) {
|
||||||
|
for (const removed of meta.versions.slice(maxKeep)) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(pathUtil.join(dir, `${removed.id}.md`));
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
meta.versions = meta.versions.slice(0, maxKeep);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(pathUtil.join(dir, `${id}.md`), String(content ?? ''), 'utf-8');
|
||||||
|
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf-8');
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List versions for a document, newest first.
|
||||||
|
* @returns {Array<{id, createdAt, label, wordCount}>}
|
||||||
|
*/
|
||||||
|
function listVersions({ docPath, io }) {
|
||||||
|
const { rootDir, fs, pathUtil, crypto = defaultCrypto() } = io;
|
||||||
|
const metaPath = pathUtil.join(rootDir, folderFor(docPath, pathUtil, crypto), 'meta.json');
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
||||||
|
return Array.isArray(meta.versions) ? meta.versions : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read one version's content.
|
||||||
|
* @throws when the id is malformed or the version is missing
|
||||||
|
*/
|
||||||
|
function readVersion({ docPath, id, io }) {
|
||||||
|
if (!isValidVersionId(id)) throw new Error('Invalid version id');
|
||||||
|
const { rootDir, fs, pathUtil, crypto = defaultCrypto() } = io;
|
||||||
|
const file = pathUtil.join(rootDir, folderFor(docPath, pathUtil, crypto), `${id}.md`);
|
||||||
|
return fs.readFileSync(file, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete one version (also repairs the index if a blob is already gone).
|
||||||
|
* @returns {boolean} whether the version was found and removed
|
||||||
|
*/
|
||||||
|
function deleteVersion({ docPath, id, io }) {
|
||||||
|
if (!isValidVersionId(id)) return false;
|
||||||
|
const { rootDir, fs, pathUtil, crypto = defaultCrypto() } = io;
|
||||||
|
const dir = pathUtil.join(rootDir, folderFor(docPath, pathUtil, crypto));
|
||||||
|
const metaPath = pathUtil.join(dir, 'meta.json');
|
||||||
|
let meta;
|
||||||
|
try {
|
||||||
|
meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const before = meta.versions?.length || 0;
|
||||||
|
meta.versions = (meta.versions || []).filter((v) => v.id !== id);
|
||||||
|
if (meta.versions.length === before) return false;
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(pathUtil.join(dir, `${id}.md`));
|
||||||
|
} catch {
|
||||||
|
/* blob already missing — index repair is the important part */
|
||||||
|
}
|
||||||
|
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf-8');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULT_MAX_KEEP,
|
||||||
|
saveVersion,
|
||||||
|
listVersions,
|
||||||
|
readVersion,
|
||||||
|
deleteVersion,
|
||||||
|
newVersionId,
|
||||||
|
isValidVersionId,
|
||||||
|
folderFor,
|
||||||
|
};
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* Minimal XLSX (Office Open XML spreadsheet) writer for markdown table export.
|
||||||
|
*
|
||||||
|
* Builds a valid multi-sheet workbook entirely in-memory with JSZip — no
|
||||||
|
* spreadsheet library needed. Each markdown table becomes one sheet ("Table 1",
|
||||||
|
* "Table 2", …) using inlineStr cells (no sharedStrings part), and cells that
|
||||||
|
* look numeric are emitted as real numbers so Excel can compute on them.
|
||||||
|
*
|
||||||
|
* Structure of the produced package:
|
||||||
|
* [Content_Types].xml
|
||||||
|
* _rels/.rels → points at xl/workbook.xml
|
||||||
|
* xl/workbook.xml → sheet list
|
||||||
|
* xl/_rels/workbook.xml.rels → sheet relationship ids
|
||||||
|
* xl/styles.xml → minimal valid styles part
|
||||||
|
* xl/worksheets/sheet<N>.xml → one per table
|
||||||
|
*
|
||||||
|
* @module XlsxExporter
|
||||||
|
*/
|
||||||
|
|
||||||
|
const JSZip = require('jszip');
|
||||||
|
|
||||||
|
/** Excel column letter for a 0-based column index (0→A, 25→Z, 26→AA…). */
|
||||||
|
function columnLetter(index) {
|
||||||
|
let letters = '';
|
||||||
|
let n = index;
|
||||||
|
while (n >= 0) {
|
||||||
|
letters = String.fromCharCode((n % 26) + 65) + letters;
|
||||||
|
n = Math.floor(n / 26) - 1;
|
||||||
|
}
|
||||||
|
return letters;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape text for XML content/attributes. */
|
||||||
|
function escapeXml(text) {
|
||||||
|
return String(text ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain integers/decimals become numeric cells (preserves leading zeros as text). */
|
||||||
|
function looksNumeric(value) {
|
||||||
|
return /^-?\d+(\.\d+)?$/.test(value) && !/^0\d/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build one sheet's XML from a table (array of rows of strings).
|
||||||
|
* The first row is emitted as a regular row — headers stay text so sorting
|
||||||
|
* and filters behave predictably.
|
||||||
|
*/
|
||||||
|
function buildSheetXml(table) {
|
||||||
|
const rows = table
|
||||||
|
.map((row, rowIndex) => {
|
||||||
|
const cells = row
|
||||||
|
.map((cell, colIndex) => {
|
||||||
|
const ref = `${columnLetter(colIndex)}${rowIndex + 1}`;
|
||||||
|
if (looksNumeric(cell)) {
|
||||||
|
return `<c r="${ref}" t="n"><v>${cell}</v></c>`;
|
||||||
|
}
|
||||||
|
// xml:space="preserve" keeps leading/trailing spaces users wrote
|
||||||
|
return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${escapeXml(cell)}</t></is></c>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
return `<row r="${rowIndex + 1}">${cells}</row>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||||
|
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
|
||||||
|
`<sheetData>${rows}</sheetData>` +
|
||||||
|
'</worksheet>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble an XLSX workbook buffer from extracted markdown tables.
|
||||||
|
*
|
||||||
|
* @param {Array<string[][]>} tables Tables as rows of cell strings
|
||||||
|
* @returns {Promise<Buffer>} .xlsx file contents
|
||||||
|
*/
|
||||||
|
async function buildXlsx(tables) {
|
||||||
|
if (!Array.isArray(tables) || tables.length === 0) {
|
||||||
|
throw new Error('No tables to export');
|
||||||
|
}
|
||||||
|
|
||||||
|
const zip = new JSZip();
|
||||||
|
|
||||||
|
// Sheet names: "Table 1"… — Excel caps names at 31 chars (ours are short)
|
||||||
|
const sheetNames = tables.map((_, i) => `Table ${i + 1}`);
|
||||||
|
|
||||||
|
zip.file(
|
||||||
|
'[Content_Types].xml',
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||||
|
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
|
||||||
|
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
|
||||||
|
'<Default Extension="xml" ContentType="application/xml"/>' +
|
||||||
|
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
|
||||||
|
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' +
|
||||||
|
tables
|
||||||
|
.map(
|
||||||
|
(_, i) =>
|
||||||
|
`<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ` +
|
||||||
|
'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
||||||
|
)
|
||||||
|
.join('') +
|
||||||
|
'</Types>'
|
||||||
|
);
|
||||||
|
|
||||||
|
zip.file(
|
||||||
|
'_rels/.rels',
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||||
|
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
||||||
|
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
|
||||||
|
'</Relationships>'
|
||||||
|
);
|
||||||
|
|
||||||
|
zip.file(
|
||||||
|
'xl/workbook.xml',
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||||
|
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' +
|
||||||
|
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
|
||||||
|
'<sheets>' +
|
||||||
|
sheetNames
|
||||||
|
.map(
|
||||||
|
(name, i) => `<sheet name="${escapeXml(name)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`
|
||||||
|
)
|
||||||
|
.join('') +
|
||||||
|
'</sheets></workbook>'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Relationship ids rId1..rIdN map to sheets; styles get the next free id
|
||||||
|
zip.file(
|
||||||
|
'xl/_rels/workbook.xml.rels',
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||||
|
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
||||||
|
tables
|
||||||
|
.map(
|
||||||
|
(_, i) =>
|
||||||
|
`<Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`
|
||||||
|
)
|
||||||
|
.join('') +
|
||||||
|
`<Relationship Id="rId${tables.length + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>` +
|
||||||
|
'</Relationships>'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Minimal styles part: Excel accepts workbooks without styling, but some
|
||||||
|
// viewers are picky, so ship the empty default.
|
||||||
|
zip.file(
|
||||||
|
'xl/styles.xml',
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||||
|
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
|
||||||
|
'<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>' +
|
||||||
|
'<fills count="1"><fill><patternFill patternType="none"/></fill></fills>' +
|
||||||
|
'<borders count="1"><border/></borders>' +
|
||||||
|
'<cellStyleXfs count="1"><xf/></cellStyleXfs>' +
|
||||||
|
'<cellXfs count="1"><xf xfId="0"/></cellXfs>' +
|
||||||
|
'</styleSheet>'
|
||||||
|
);
|
||||||
|
|
||||||
|
tables.forEach((table, i) => {
|
||||||
|
zip.file(`xl/worksheets/sheet${i + 1}.xml`, buildSheetXml(table));
|
||||||
|
});
|
||||||
|
|
||||||
|
// JSZip 3.x: generateAsync is the API (generate() was removed)
|
||||||
|
return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { buildXlsx, buildSheetXml, columnLetter, looksNumeric };
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* AI Assistant chat sidebar panel.
|
||||||
|
*
|
||||||
|
* Renders a compact chat interface (message list, composer, status badge) and
|
||||||
|
* wires it to the plugin's IPC-backed completion helper. All network work
|
||||||
|
* happens in the main process; this file only manages conversation state
|
||||||
|
* (history is kept in memory for the session, not persisted).
|
||||||
|
*
|
||||||
|
* @module AiChatPanel
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Simple HTML escape for all user/model text before insertion. */
|
||||||
|
function esc(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the chat panel into its sidebar container.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} container Sidebar panel content element
|
||||||
|
* @param {object} deps
|
||||||
|
* @param {Function} deps.complete async ({system, messages}) => {content}
|
||||||
|
* @param {Function} deps.insertAtCursor (text) => void — insert reply in doc
|
||||||
|
* @param {Function} deps.getSelection () => string — for "use selection"
|
||||||
|
* @param {{provider:string, model:string, configured:boolean}} deps.statusInfo
|
||||||
|
* @param {Function} deps.onOpenSettings () => void — jump to provider config
|
||||||
|
*/
|
||||||
|
function renderChatPanel(container, deps) {
|
||||||
|
container.replaceChildren();
|
||||||
|
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.className = 'ws-panel ai-chat-panel';
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="ws-section">
|
||||||
|
<div class="ai-chat-status">
|
||||||
|
<span class="ai-chat-badge" data-role="badge"></span>
|
||||||
|
<button class="ws-btn ws-btn-sm" data-role="settings" type="button">Setup…</button>
|
||||||
|
</div>
|
||||||
|
<div class="ai-chat-messages" data-role="messages" aria-live="polite"></div>
|
||||||
|
<div class="ai-chat-composer">
|
||||||
|
<textarea data-role="input" rows="3" placeholder="Ask about your document…
|
||||||
|
(Shift+Enter for newline)"></textarea>
|
||||||
|
<div class="ai-chat-actions">
|
||||||
|
<button class="ws-btn" data-role="use-selection" type="button"
|
||||||
|
title="Insert the current editor selection into the composer">Use selection</button>
|
||||||
|
<button class="ws-btn ws-btn-primary" data-role="send" type="button">Send</button>
|
||||||
|
</div>
|
||||||
|
<div class="ai-chat-footer">
|
||||||
|
<button class="ws-btn ws-btn-sm" data-role="insert-last" type="button"
|
||||||
|
title="Insert the last assistant reply at the cursor">Insert last reply</button>
|
||||||
|
<button class="ws-btn ws-btn-sm" data-role="clear" type="button">Clear chat</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
container.appendChild(panel);
|
||||||
|
|
||||||
|
// --- Element handles -----------------------------------------------------
|
||||||
|
const badge = panel.querySelector('[data-role="badge"]');
|
||||||
|
const messagesEl = panel.querySelector('[data-role="messages"]');
|
||||||
|
const inputEl = panel.querySelector('[data-role="input"]');
|
||||||
|
const sendBtn = panel.querySelector('[data-role="send"]');
|
||||||
|
const insertLastBtn = panel.querySelector('[data-role="insert-last"]');
|
||||||
|
const useSelectionBtn = panel.querySelector('[data-role="use-selection"]');
|
||||||
|
const clearBtn = panel.querySelector('[data-role="clear"]');
|
||||||
|
const settingsBtn = panel.querySelector('[data-role="settings"]');
|
||||||
|
|
||||||
|
// --- Conversation state (session-scoped) ---------------------------------
|
||||||
|
const history = []; // {role: 'user'|'assistant', content}
|
||||||
|
let lastAssistantReply = '';
|
||||||
|
let busy = false;
|
||||||
|
|
||||||
|
function renderBadge() {
|
||||||
|
const { provider, model, configured } = deps.statusInfo;
|
||||||
|
badge.textContent = configured ? `${provider} · ${model}` : 'Not configured';
|
||||||
|
badge.classList.toggle('ai-chat-badge-ok', Boolean(configured));
|
||||||
|
badge.classList.toggle('ai-chat-badge-off', !configured);
|
||||||
|
sendBtn.disabled = !configured || busy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendMessage(role, text) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = `ai-chat-msg ai-chat-msg-${role}`;
|
||||||
|
item.innerHTML = `<div class="ai-chat-msg-role">${role === 'user' ? 'You' : 'AI'}</div>
|
||||||
|
<div class="ai-chat-msg-body">${esc(text)}</div>`;
|
||||||
|
messagesEl.appendChild(item);
|
||||||
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendBusy() {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'ai-chat-msg ai-chat-msg-assistant ai-chat-msg-busy';
|
||||||
|
item.dataset.role = 'busy';
|
||||||
|
item.textContent = 'Thinking…';
|
||||||
|
messagesEl.appendChild(item);
|
||||||
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
const text = inputEl.value.trim();
|
||||||
|
if (!text || busy || !deps.statusInfo.configured) return;
|
||||||
|
|
||||||
|
busy = true;
|
||||||
|
renderBadge();
|
||||||
|
inputEl.value = '';
|
||||||
|
appendMessage('user', text);
|
||||||
|
history.push({ role: 'user', content: text });
|
||||||
|
const busyEl = appendBusy();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Keep a rolling window of the last 12 turns so long chats don't grow
|
||||||
|
// request payloads unboundedly.
|
||||||
|
const window = history.slice(-12);
|
||||||
|
const { content } = await deps.complete({ messages: window });
|
||||||
|
lastAssistantReply = content;
|
||||||
|
history.push({ role: 'assistant', content });
|
||||||
|
appendMessage('assistant', content);
|
||||||
|
} catch (error) {
|
||||||
|
appendMessage('assistant', `Error: ${error.message || 'request failed'}`);
|
||||||
|
} finally {
|
||||||
|
busyEl.remove();
|
||||||
|
busy = false;
|
||||||
|
renderBadge();
|
||||||
|
inputEl.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Wire events ----------------------------------------------------------
|
||||||
|
sendBtn.addEventListener('click', send);
|
||||||
|
inputEl.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
send();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
useSelectionBtn.addEventListener('click', () => {
|
||||||
|
const sel = deps.getSelection();
|
||||||
|
if (sel) inputEl.value += (inputEl.value ? '\n\n' : '') + sel;
|
||||||
|
inputEl.focus();
|
||||||
|
});
|
||||||
|
insertLastBtn.addEventListener('click', () => {
|
||||||
|
if (lastAssistantReply) deps.insertAtCursor(lastAssistantReply);
|
||||||
|
});
|
||||||
|
clearBtn.addEventListener('click', () => {
|
||||||
|
history.length = 0;
|
||||||
|
lastAssistantReply = '';
|
||||||
|
messagesEl.replaceChildren();
|
||||||
|
});
|
||||||
|
settingsBtn.addEventListener('click', () => deps.onOpenSettings());
|
||||||
|
|
||||||
|
renderBadge();
|
||||||
|
if (!deps.statusInfo.configured) {
|
||||||
|
appendMessage(
|
||||||
|
'assistant',
|
||||||
|
'Set up a provider first (OpenAI, Anthropic, or a local Ollama/LM Studio server) via the Setup button.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { renderChatPanel };
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
/**
|
||||||
|
* AI Assistant built-in plugin.
|
||||||
|
*
|
||||||
|
* Brings LLM assistance into the editor with multi-provider support:
|
||||||
|
* - OpenAI / Anthropic (cloud, API key required)
|
||||||
|
* - Ollama / LM Studio (local, no key)
|
||||||
|
* - Any OpenAI-compatible or Anthropic-compatible endpoint (custom base
|
||||||
|
* URL; Anthropic-compatible gateways like LiteLLM proxies work with or
|
||||||
|
* without a key)
|
||||||
|
*
|
||||||
|
* All provider traffic is proxied through the main process
|
||||||
|
* (ai-assistant:complete / ai-assistant:status IPC) so API keys never enter
|
||||||
|
* the renderer and the renderer CSP stays closed to AI endpoints.
|
||||||
|
*
|
||||||
|
* Extension points:
|
||||||
|
* - Sidebar "AI Chat" panel (session-scoped conversation, insert replies)
|
||||||
|
* - Commands: Summarize / Improve / Explain / Translate selection
|
||||||
|
* - EventBus `ai:analyze` handler — powers the writing-studio Proofread
|
||||||
|
* panel, which was built waiting for exactly this plugin
|
||||||
|
*
|
||||||
|
* @module ai-assistant
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { PluginAPI } = require('../../../plugins/plugin-api');
|
||||||
|
const { renderChatPanel } = require('./chat-panel');
|
||||||
|
const { buildTaskPrompt, buildProofreadPrompt, parseProofreadIssues } = require('./prompts');
|
||||||
|
|
||||||
|
class AiAssistantPlugin extends PluginAPI {
|
||||||
|
init(context) {
|
||||||
|
this.context = context;
|
||||||
|
|
||||||
|
// Cached provider status; refreshed from main whenever settings change
|
||||||
|
this._statusInfo = { provider: '', model: '', configured: false };
|
||||||
|
this._settingsModalOpen = false;
|
||||||
|
|
||||||
|
this._refreshStatus();
|
||||||
|
|
||||||
|
this._registerSidebar(context);
|
||||||
|
this._registerCommands(context);
|
||||||
|
this._registerAnalyzeHandler(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch provider/model/configured from the main process (no key material). */
|
||||||
|
async _refreshStatus() {
|
||||||
|
try {
|
||||||
|
const status = await this.context.ipc.invoke('ai-assistant:status', null);
|
||||||
|
this._statusInfo = {
|
||||||
|
provider: status.provider || '',
|
||||||
|
model: status.model || '',
|
||||||
|
configured: Boolean(status.configured),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
this._statusInfo = { provider: '', model: '', configured: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_registerSidebar(context) {
|
||||||
|
// Panel body + rail icon: PluginContext namespaces the panel id to
|
||||||
|
// "ai-assistant:chat"; SidebarManager creates the rail icon from the
|
||||||
|
// inline SVG so the plugin needs no app-shell edits.
|
||||||
|
context.sidebar.registerPanel('chat', {
|
||||||
|
title: 'AI Chat',
|
||||||
|
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"></path>
|
||||||
|
<path d="M19 15l.9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9z"></path>
|
||||||
|
</svg>`,
|
||||||
|
render: (container) =>
|
||||||
|
renderChatPanel(container, {
|
||||||
|
complete: (request) => this._complete(request),
|
||||||
|
insertAtCursor: (text) => context.editor.insertAtCursor(text),
|
||||||
|
getSelection: () => context.editor.getSelection() || '',
|
||||||
|
statusInfo: this._statusInfo,
|
||||||
|
onOpenSettings: () => this._openSettingsModal(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_registerCommands(context) {
|
||||||
|
const runOnSelection = async (action, extra) => {
|
||||||
|
const selection = context.editor.getSelection();
|
||||||
|
const text = selection && selection.trim() ? selection : context.editor.getContent() || '';
|
||||||
|
if (!text.trim()) return;
|
||||||
|
try {
|
||||||
|
const { system, user } = buildTaskPrompt(action, text, extra);
|
||||||
|
const { content } = await this._complete({
|
||||||
|
system,
|
||||||
|
messages: [{ role: 'user', content: user }],
|
||||||
|
});
|
||||||
|
if (action === 'improve' && selection && selection.trim()) {
|
||||||
|
// Improving an explicit selection replaces it at the cursor
|
||||||
|
context.editor.insertAtCursor(content);
|
||||||
|
} else {
|
||||||
|
context.editor.insertAtCursor(`\n\n${content}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ai-assistant] task failed:', error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
context.commands.register('summarize-selection', 'AI: Summarize Selection', () =>
|
||||||
|
runOnSelection('summarize')
|
||||||
|
);
|
||||||
|
context.commands.register('improve-selection', 'AI: Improve Writing', () =>
|
||||||
|
runOnSelection('improve')
|
||||||
|
);
|
||||||
|
context.commands.register('explain-selection', 'AI: Explain Selection', () =>
|
||||||
|
runOnSelection('explain')
|
||||||
|
);
|
||||||
|
context.commands.register('translate-selection', 'AI: Translate Selection', () => {
|
||||||
|
// window.prompt() is not supported in Electron renderers — use a small
|
||||||
|
// inline dialog instead. Cancelling leaves everything untouched.
|
||||||
|
this._inputBox('Translate to which language?', 'e.g. French, Japanese').then((target) => {
|
||||||
|
if (target) runOnSelection('translate', target);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
context.commands.register('open-chat', 'AI: Open Chat', () => {
|
||||||
|
this._openSettingsModalRefresh();
|
||||||
|
// Ask the host to expand our sidebar panel (wired via the event bus)
|
||||||
|
this.context.events.emit('sidebar:open-panel', { panel: 'ai-assistant:chat' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal promise-based text input dialog (Electron has no window.prompt).
|
||||||
|
* Resolves with the text, or null when cancelled.
|
||||||
|
* @returns {Promise<string|null>}
|
||||||
|
*/
|
||||||
|
_inputBox(label, placeholder) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'ai-settings-overlay';
|
||||||
|
overlay.setAttribute('role', 'dialog');
|
||||||
|
overlay.setAttribute('aria-modal', 'true');
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="ai-settings-dialog">
|
||||||
|
<h3>${label}</h3>
|
||||||
|
<input type="text" data-role="input" placeholder="${placeholder || ''}" />
|
||||||
|
<div class="ai-settings-actions">
|
||||||
|
<button data-role="ok" type="button" class="ws-btn ws-btn-primary">OK</button>
|
||||||
|
<button data-role="cancel" type="button" class="ws-btn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
const input = overlay.querySelector('[data-role="input"]');
|
||||||
|
const done = (value) => {
|
||||||
|
overlay.remove();
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
overlay
|
||||||
|
.querySelector('[data-role="ok"]')
|
||||||
|
.addEventListener('click', () => done(input.value.trim() || null));
|
||||||
|
overlay.querySelector('[data-role="cancel"]').addEventListener('click', () => done(null));
|
||||||
|
overlay.addEventListener('click', (event) => {
|
||||||
|
if (event.target === overlay) done(null);
|
||||||
|
});
|
||||||
|
input.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter') done(input.value.trim() || null);
|
||||||
|
if (event.key === 'Escape') done(null);
|
||||||
|
});
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fulfill the writing-studio proofread contract: listen for `ai:analyze`
|
||||||
|
* events ({text, type, callback}) and answer with {issues: [...]} parsed
|
||||||
|
* from the model's JSON reply.
|
||||||
|
*/
|
||||||
|
_registerAnalyzeHandler(context) {
|
||||||
|
context.events.on('ai:analyze', async (payload) => {
|
||||||
|
if (!payload || typeof payload.callback !== 'function') return;
|
||||||
|
if (!this._statusInfo.configured) {
|
||||||
|
payload.callback({
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
type: 'setup',
|
||||||
|
message: 'AI Assistant is not configured. Set a provider via AI: Open Chat → Setup.',
|
||||||
|
suggestion: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { system, user } = buildProofreadPrompt(String(payload.text || ''));
|
||||||
|
const { content } = await this._complete({
|
||||||
|
system,
|
||||||
|
messages: [{ role: 'user', content: user }],
|
||||||
|
});
|
||||||
|
payload.callback({ issues: parseProofreadIssues(content) });
|
||||||
|
} catch (error) {
|
||||||
|
payload.callback({ issues: [{ type: 'error', message: error.message, suggestion: '' }] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared completion helper: main process owns provider settings, so the
|
||||||
|
* plugin only sends the prompt payload.
|
||||||
|
*/
|
||||||
|
async _complete(request) {
|
||||||
|
await this._refreshStatus();
|
||||||
|
if (!this._statusInfo.configured) {
|
||||||
|
throw new Error('AI Assistant is not configured (see AI Chat → Setup)');
|
||||||
|
}
|
||||||
|
return this.context.ipc.invoke('ai-assistant:complete', request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tiny settings modal rendered by the plugin itself (plugin settings have
|
||||||
|
* no host-provided UI yet). Persists through the plugin settings store,
|
||||||
|
* which lands in settings.json main-side.
|
||||||
|
*/
|
||||||
|
_openSettingsModalRefresh() {
|
||||||
|
this._refreshStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
_openSettingsModal() {
|
||||||
|
if (this._settingsModalOpen) return;
|
||||||
|
this._settingsModalOpen = true;
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'ai-settings-overlay';
|
||||||
|
overlay.setAttribute('role', 'dialog');
|
||||||
|
overlay.setAttribute('aria-modal', 'true');
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="ai-settings-dialog">
|
||||||
|
<h3>AI Assistant Settings</h3>
|
||||||
|
<label>Provider
|
||||||
|
<select data-role="provider">
|
||||||
|
<option value="ollama">Ollama (local)</option>
|
||||||
|
<option value="lmstudio">LM Studio (local)</option>
|
||||||
|
<option value="openai">OpenAI</option>
|
||||||
|
<option value="anthropic">Anthropic</option>
|
||||||
|
<option value="openai-compatible">OpenAI-compatible…</option>
|
||||||
|
<option value="anthropic-compatible">Anthropic-compatible…</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Model <input data-role="model" type="text" placeholder="e.g. llama3.1, gpt-4o-mini" /></label>
|
||||||
|
<label>Base URL
|
||||||
|
<input
|
||||||
|
data-role="baseUrl"
|
||||||
|
type="text"
|
||||||
|
placeholder="Required for …-compatible providers, e.g. http://localhost:4000"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>API Key <input data-role="apiKey" type="password" placeholder="(not needed for local providers)" /></label>
|
||||||
|
<label>Temperature <input data-role="temperature" type="number" min="0" max="2" step="0.1" /></label>
|
||||||
|
<p class="ai-settings-note">Settings are stored locally in settings.json. API keys never leave this machine.</p>
|
||||||
|
<div class="ai-settings-actions">
|
||||||
|
<button data-role="save" type="button">Save</button>
|
||||||
|
<button data-role="cancel" type="button">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
overlay.remove();
|
||||||
|
this._settingsModalOpen = false;
|
||||||
|
};
|
||||||
|
overlay.addEventListener('click', (event) => {
|
||||||
|
if (event.target === overlay) close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const $ = (role) => overlay.querySelector(`[data-role="${role}"]`);
|
||||||
|
|
||||||
|
// Pre-fill from current settings
|
||||||
|
Promise.all([
|
||||||
|
this.context.settings.get('provider'),
|
||||||
|
this.context.settings.get('model'),
|
||||||
|
this.context.settings.get('baseUrl'),
|
||||||
|
this.context.settings.get('apiKey'),
|
||||||
|
this.context.settings.get('temperature'),
|
||||||
|
]).then(([provider, model, baseUrl, apiKey, temperature]) => {
|
||||||
|
if (provider) $('provider').value = provider;
|
||||||
|
if (model) $('model').value = model;
|
||||||
|
if (baseUrl) $('baseUrl').value = baseUrl;
|
||||||
|
if (apiKey) $('apiKey').value = apiKey;
|
||||||
|
if (temperature !== undefined && temperature !== null) {
|
||||||
|
$('temperature').value = temperature;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('cancel').addEventListener('click', close);
|
||||||
|
$('save').addEventListener('click', async () => {
|
||||||
|
await this.context.settings.set('provider', $('provider').value);
|
||||||
|
await this.context.settings.set('model', $('model').value.trim());
|
||||||
|
await this.context.settings.set('baseUrl', $('baseUrl').value.trim());
|
||||||
|
await this.context.settings.set('apiKey', $('apiKey').value.trim());
|
||||||
|
await this.context.settings.set('temperature', parseFloat($('temperature').value) || 0.7);
|
||||||
|
await this._refreshStatus();
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate() {
|
||||||
|
/* no long-lived timers to clean up */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { Plugin: AiAssistantPlugin };
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"id": "ai-assistant",
|
||||||
|
"name": "AI Assistant",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Multi-provider AI help: chat panel, summarize/improve/explain/translate commands, and grammar proofreading via the writing-studio Proofread panel",
|
||||||
|
"icon": "sparkles",
|
||||||
|
"extensionPoints": {
|
||||||
|
"sidebar": [{ "id": "chat", "title": "AI Chat", "order": 40 }],
|
||||||
|
"commands": [
|
||||||
|
{ "id": "summarize-selection", "label": "AI: Summarize Selection", "shortcut": "" },
|
||||||
|
{ "id": "improve-selection", "label": "AI: Improve Writing", "shortcut": "" },
|
||||||
|
{ "id": "explain-selection", "label": "AI: Explain Selection", "shortcut": "" },
|
||||||
|
{ "id": "translate-selection", "label": "AI: Translate Selection", "shortcut": "" },
|
||||||
|
{ "id": "open-chat", "label": "AI: Open Chat", "shortcut": "" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"settings": [
|
||||||
|
{
|
||||||
|
"key": "provider",
|
||||||
|
"type": "string",
|
||||||
|
"default": "",
|
||||||
|
"label": "Provider (openai|anthropic|ollama|lmstudio|openai-compatible|anthropic-compatible)"
|
||||||
|
},
|
||||||
|
{ "key": "model", "type": "string", "default": "", "label": "Model name" },
|
||||||
|
{
|
||||||
|
"key": "baseUrl",
|
||||||
|
"type": "string",
|
||||||
|
"default": "",
|
||||||
|
"label": "API base URL (optional override)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "apiKey",
|
||||||
|
"type": "string",
|
||||||
|
"default": "",
|
||||||
|
"label": "API key (not required for local providers)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "temperature",
|
||||||
|
"type": "number",
|
||||||
|
"default": 0.7,
|
||||||
|
"label": "Sampling temperature (0-2)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Prompt builders and output parsers for the AI Assistant plugin.
|
||||||
|
*
|
||||||
|
* Kept as a pure module (no DOM, no IPC) so prompt construction and the
|
||||||
|
* proofread-issue parser can be unit-tested directly.
|
||||||
|
*
|
||||||
|
* @module AiPrompts
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task prompt templates. Each entry maps an assistant action to a system
|
||||||
|
* prompt (behavior contract) and a user-prompt builder over the document
|
||||||
|
* text. Markdown output is requested for editor-facing actions so results
|
||||||
|
* can be inserted straight into the document.
|
||||||
|
*/
|
||||||
|
const TASKS = {
|
||||||
|
summarize: {
|
||||||
|
system:
|
||||||
|
'You are a concise writing assistant. Summarize the user text in clear markdown. ' +
|
||||||
|
'Use a short paragraph followed by 3-5 bullet points with the key ideas.',
|
||||||
|
user: (text, extra) => `Summarize the following${extra ? ` (${extra})` : ''}:\n\n${text}`,
|
||||||
|
},
|
||||||
|
improve: {
|
||||||
|
system:
|
||||||
|
'You are a professional editor. Improve the user text for clarity, flow, and correctness. ' +
|
||||||
|
'Return ONLY the rewritten markdown — no preamble, no explanations, no code fences ' +
|
||||||
|
'around the whole answer.',
|
||||||
|
user: (text) => `Rewrite and improve this text:\n\n${text}`,
|
||||||
|
},
|
||||||
|
explain: {
|
||||||
|
system:
|
||||||
|
'You are a patient technical explainer. Explain the user text in plain language, ' +
|
||||||
|
'using short markdown sections and examples where helpful.',
|
||||||
|
user: (text) => `Explain the following:\n\n${text}`,
|
||||||
|
},
|
||||||
|
translate: {
|
||||||
|
system:
|
||||||
|
'You are a careful translator. Translate the user text, preserving markdown formatting, ' +
|
||||||
|
'tone, and technical terminology. Return ONLY the translation.',
|
||||||
|
user: (text, targetLanguage) =>
|
||||||
|
`Translate the following to ${targetLanguage || 'English'}:\n\n${text}`,
|
||||||
|
},
|
||||||
|
chat: {
|
||||||
|
// Free-form conversation; the panel supplies its own message history
|
||||||
|
system:
|
||||||
|
'You are a helpful writing and markdown assistant inside a desktop editor. ' +
|
||||||
|
'Answer in markdown. Be concise unless asked for detail.',
|
||||||
|
user: (text) => text,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a {system, user} prompt pair for a known task action.
|
||||||
|
*
|
||||||
|
* @param {string} action Task id (summarize|improve|explain|translate|chat)
|
||||||
|
* @param {string} text Document text or selection
|
||||||
|
* @param {string} [extra] e.g. target language for translate
|
||||||
|
* @returns {{system: string, user: string}}
|
||||||
|
* @throws {Error} on unknown action
|
||||||
|
*/
|
||||||
|
function buildTaskPrompt(action, text, extra) {
|
||||||
|
const task = TASKS[action];
|
||||||
|
if (!task) throw new Error(`Unknown AI task "${action}"`);
|
||||||
|
return { system: task.system, user: task.user(String(text || ''), extra) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System+user prompts for grammar proofreading. The writing-studio proofread
|
||||||
|
* panel expects a callback with `{issues: [{type, message, suggestion}]}`,
|
||||||
|
* so the model is asked for strict JSON.
|
||||||
|
*/
|
||||||
|
const PROOFREAD_SYSTEM =
|
||||||
|
'You are a strict proofreader. Find grammar, spelling, and punctuation issues in the text. ' +
|
||||||
|
'Respond with ONLY a JSON array — no prose, no code fences. Each element must be an object: ' +
|
||||||
|
'{"type": "grammar"|"spelling"|"punctuation"|"style", "message": string, "suggestion": string}. ' +
|
||||||
|
'The message should quote or describe the problematic fragment; the suggestion is the fix. ' +
|
||||||
|
'If there are no issues, respond with [].';
|
||||||
|
|
||||||
|
/** @returns {{system: string, user: string}} */
|
||||||
|
function buildProofreadPrompt(text) {
|
||||||
|
return {
|
||||||
|
system: PROOFREAD_SYSTEM,
|
||||||
|
user: `Proofread the following text and list its issues as the JSON array described:\n\n${text}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a model's proofread answer into an issues array. Tolerates the usual
|
||||||
|
* LLM quirks: code fences around the JSON, leading prose, trailing commas,
|
||||||
|
* and single-quoted keys. Returns [] when nothing parseable is found rather
|
||||||
|
* than throwing — a chatty model must not break the panel.
|
||||||
|
*
|
||||||
|
* @param {string} modelOutput Raw assistant text
|
||||||
|
* @returns {Array<{type: string, message: string, suggestion: string}>}
|
||||||
|
*/
|
||||||
|
function parseProofreadIssues(modelOutput) {
|
||||||
|
const raw = String(modelOutput || '').trim();
|
||||||
|
if (!raw) return [];
|
||||||
|
|
||||||
|
// Strip markdown code fences the model may have added despite instructions
|
||||||
|
const unfenced = raw
|
||||||
|
.replace(/^```(?:json)?\s*/i, '')
|
||||||
|
.replace(/\s*```$/i, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Grab the outermost [...] block; ignores any leading/trailing prose
|
||||||
|
const start = unfenced.indexOf('[');
|
||||||
|
const end = unfenced.lastIndexOf(']');
|
||||||
|
if (start === -1 || end === -1 || end <= start) return [];
|
||||||
|
let jsonSlice = unfenced.slice(start, end + 1);
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(jsonSlice);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
// Retry after trimming trailing commas (a common LLM artifact)
|
||||||
|
jsonSlice = jsonSlice.replace(/,\s*([\]}])/g, '$1');
|
||||||
|
parsed = JSON.parse(jsonSlice);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
// Normalize/whitelist fields so the panel always gets a stable shape
|
||||||
|
return parsed
|
||||||
|
.filter((item) => item && typeof item === 'object' && (item.message || item.suggestion))
|
||||||
|
.slice(0, 100)
|
||||||
|
.map((item) => ({
|
||||||
|
type: typeof item.type === 'string' ? item.type.toLowerCase() : 'grammar',
|
||||||
|
message: String(item.message || item.suggestion || ''),
|
||||||
|
suggestion: item.suggestion === undefined ? '' : String(item.suggestion),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildTaskPrompt,
|
||||||
|
buildProofreadPrompt,
|
||||||
|
parseProofreadIssues,
|
||||||
|
TASKS,
|
||||||
|
};
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Comment store for the Collaboration plugin.
|
||||||
|
*
|
||||||
|
* Inline document comments live in a sidecar folder (`.comments/`) next to the
|
||||||
|
* document they annotate, one JSON file per document (`<name>.json`), matching
|
||||||
|
* the v5 collaboration design. Keeping comments out of the document itself
|
||||||
|
* means they never ship in exports or leak into Git commits of the prose.
|
||||||
|
*
|
||||||
|
* A comment anchors to a line number plus the anchor line's text snippet; the
|
||||||
|
* snippet lets the UI detect drift ("line moved / text changed") after edits
|
||||||
|
* without storing full document positions.
|
||||||
|
*
|
||||||
|
* All IO is injected, so the module is unit-testable and works from both the
|
||||||
|
* renderer (via IPC file helpers) and tests (via in-memory fakes).
|
||||||
|
*
|
||||||
|
* @module CommentStore
|
||||||
|
*/
|
||||||
|
|
||||||
|
const COMMENT_FOLDER = '.comments';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path of the sidecar comments file for a document path.
|
||||||
|
* Pure string surgery — no IO, easy to test.
|
||||||
|
*
|
||||||
|
* @param {string} docPath Absolute path to the markdown document
|
||||||
|
* @param {Function} pathUtil path module (or equivalent) — injected for tests
|
||||||
|
* @returns {string} e.g. /docs/.comments/notes.md.json
|
||||||
|
*/
|
||||||
|
function commentsFilePathFor(docPath, pathUtil) {
|
||||||
|
const p = pathUtil;
|
||||||
|
return p.join(p.dirname(docPath), COMMENT_FOLDER, p.basename(docPath) + '.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a reasonably unique comment id without external deps.
|
||||||
|
*/
|
||||||
|
function newCommentId(now = Date.now()) {
|
||||||
|
return 'c' + now.toString(36) + Math.random().toString(36).slice(2, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all comments for a document. Missing file/corrupt JSON → [] (a fresh
|
||||||
|
* document simply has no comments yet; a corrupt store must not crash the app).
|
||||||
|
*
|
||||||
|
* Async: `io.readFile`/`io.fileExists` are IPC-backed Promises in the app
|
||||||
|
* (plain values in tests — `await` handles both).
|
||||||
|
*
|
||||||
|
* @param {string} docPath Document path
|
||||||
|
* @param {object} io - { readFile: (path) => string|null, fileExists: (path) => bool }
|
||||||
|
* @param {Function} pathUtil injected path module
|
||||||
|
* @returns {Promise<Array<object>>} comments sorted by line then createdAt
|
||||||
|
*/
|
||||||
|
async function loadComments(docPath, io, pathUtil) {
|
||||||
|
if (!docPath) return [];
|
||||||
|
const file = commentsFilePathFor(docPath, pathUtil);
|
||||||
|
try {
|
||||||
|
if (!(await io.fileExists(file))) return [];
|
||||||
|
const raw = await io.readFile(file);
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(data.comments)) return [];
|
||||||
|
return normalizeComments(data.comments);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist comments for a document (creates the sidecar structure on demand).
|
||||||
|
*
|
||||||
|
* @param {string} docPath Document path
|
||||||
|
* @param {Array<object>} comments
|
||||||
|
* @param {object} io - { writeFile, ensureDirectory }
|
||||||
|
* @param {Function} pathUtil injected path module
|
||||||
|
*/
|
||||||
|
async function saveComments(docPath, comments, io, pathUtil) {
|
||||||
|
const file = commentsFilePathFor(docPath, pathUtil);
|
||||||
|
await io.ensureDirectory(pathUtil.dirname(file));
|
||||||
|
await io.writeFile(
|
||||||
|
file,
|
||||||
|
JSON.stringify({ version: 1, doc: pathUtil.basename(docPath), comments }, null, 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coerce/validate raw entries into the canonical comment shape. */
|
||||||
|
function normalizeComments(raw) {
|
||||||
|
return raw
|
||||||
|
.filter((c) => c && typeof c === 'object' && typeof c.line === 'number')
|
||||||
|
.map((c) => ({
|
||||||
|
id: typeof c.id === 'string' ? c.id : newCommentId(),
|
||||||
|
line: Math.max(1, Math.floor(c.line)),
|
||||||
|
anchorText: typeof c.anchorText === 'string' ? c.anchorText : '',
|
||||||
|
author: typeof c.author === 'string' ? c.author : 'anonymous',
|
||||||
|
text: typeof c.text === 'string' ? c.text : '',
|
||||||
|
createdAt: typeof c.createdAt === 'number' ? c.createdAt : Date.now(),
|
||||||
|
resolved: c.resolved === true,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.line - b.line || a.createdAt - b.createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a comment anchored to a document line.
|
||||||
|
*
|
||||||
|
* @param {Array<object>} comments Existing list (mutated copy returned)
|
||||||
|
* @param {{line:number, anchorText?:string, author?:string, text:string}} input
|
||||||
|
* @returns {object} the created comment
|
||||||
|
*/
|
||||||
|
function addComment(comments, input) {
|
||||||
|
const comment = {
|
||||||
|
id: newCommentId(),
|
||||||
|
line: Math.max(1, Math.floor(input.line || 1)),
|
||||||
|
anchorText: String(input.anchorText || '').slice(0, 200),
|
||||||
|
author: String(input.author || 'me'),
|
||||||
|
text: String(input.text || '').slice(0, 4000),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
resolved: false,
|
||||||
|
};
|
||||||
|
comments.push(comment);
|
||||||
|
return comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle a comment's resolved flag by id.
|
||||||
|
* @returns {boolean} new resolved state, or null when the id is unknown
|
||||||
|
*/
|
||||||
|
function toggleResolved(comments, id) {
|
||||||
|
const comment = comments.find((c) => c.id === id);
|
||||||
|
if (!comment) return null;
|
||||||
|
comment.resolved = !comment.resolved;
|
||||||
|
return comment.resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a comment by id. @returns {boolean} whether anything was removed.
|
||||||
|
*/
|
||||||
|
function deleteComment(comments, id) {
|
||||||
|
const index = comments.findIndex((c) => c.id === id);
|
||||||
|
if (index === -1) return false;
|
||||||
|
comments.splice(index, 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the next unresolved comment for F8-style navigation (wraps around).
|
||||||
|
* "Next" is relative to the given line: the first unresolved comment on a
|
||||||
|
* line > fromLine, wrapping to the top when none exist below.
|
||||||
|
*
|
||||||
|
* @returns {object|null} the comment to jump to, or null when none are open
|
||||||
|
*/
|
||||||
|
function nextUnresolved(comments, fromLine) {
|
||||||
|
const open = comments.filter((c) => !c.resolved);
|
||||||
|
if (open.length === 0) return null;
|
||||||
|
return open.find((c) => c.line > fromLine) || open[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drift check for one comment against the live document lines.
|
||||||
|
* @returns {'ok'|'moved'|'changed'|'missing'} anchor status
|
||||||
|
*/
|
||||||
|
function anchorStatus(comment, lines) {
|
||||||
|
if (!Array.isArray(lines) || lines.length === 0) return 'missing';
|
||||||
|
if (comment.line > lines.length) return 'missing';
|
||||||
|
const currentText = lines[comment.line - 1].trim();
|
||||||
|
if (!comment.anchorText) return 'moved';
|
||||||
|
return currentText === comment.anchorText.trim() ? 'ok' : 'changed';
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
COMMENT_FOLDER,
|
||||||
|
commentsFilePathFor,
|
||||||
|
loadComments,
|
||||||
|
saveComments,
|
||||||
|
addComment,
|
||||||
|
toggleResolved,
|
||||||
|
deleteComment,
|
||||||
|
nextUnresolved,
|
||||||
|
anchorStatus,
|
||||||
|
newCommentId,
|
||||||
|
normalizeComments,
|
||||||
|
};
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* Collaboration plugin — Comments sidebar panel.
|
||||||
|
*
|
||||||
|
* Lists the inline comments anchored to the current document, lets the user
|
||||||
|
* add a comment at the cursor line, jump to a comment's anchor, and
|
||||||
|
* resolve/delete entries. Comment persistence flows through the injected IO
|
||||||
|
* (IPC-backed file helpers in the app; fakes in tests).
|
||||||
|
*
|
||||||
|
* @module CommentsPanel
|
||||||
|
*/
|
||||||
|
|
||||||
|
const store = require('./comment-store');
|
||||||
|
|
||||||
|
/** Escape text before it enters any innerHTML template. */
|
||||||
|
function esc(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the comments panel.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} container Sidebar panel content element
|
||||||
|
* @param {object} deps
|
||||||
|
* @param {object} deps.editor Plugin editor API (getCurrentFilePath,
|
||||||
|
* getCurrentLine, jumpToLine, getContent)
|
||||||
|
* @param {object} deps.io { readFile, writeFile, fileExists, ensureDirectory }
|
||||||
|
* @param {Function} deps.pathUtil injected path module
|
||||||
|
* @param {string} [deps.author='me'] Display name for new comments
|
||||||
|
*/
|
||||||
|
async function renderCommentsPanel(container, deps) {
|
||||||
|
container.replaceChildren();
|
||||||
|
const { editor, io, pathUtil } = deps;
|
||||||
|
const docPath = editor.getCurrentFilePath();
|
||||||
|
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.className = 'ws-panel';
|
||||||
|
|
||||||
|
// --- Header ---------------------------------------------------------------
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'ws-section';
|
||||||
|
const heading = document.createElement('h3');
|
||||||
|
heading.className = 'ws-heading';
|
||||||
|
heading.textContent = docPath ? 'Inline Comments' : 'No document open';
|
||||||
|
header.appendChild(heading);
|
||||||
|
|
||||||
|
if (docPath) {
|
||||||
|
const hint = document.createElement('p');
|
||||||
|
hint.className = 'ws-muted';
|
||||||
|
hint.textContent =
|
||||||
|
'Comments are stored in .comments/ next to the file and never exported. F8 jumps to the next open comment.';
|
||||||
|
header.appendChild(hint);
|
||||||
|
}
|
||||||
|
panel.appendChild(header);
|
||||||
|
|
||||||
|
if (!docPath) {
|
||||||
|
const note = document.createElement('p');
|
||||||
|
note.className = 'ws-muted';
|
||||||
|
note.textContent = 'Open a saved document to annotate it.';
|
||||||
|
panel.appendChild(note);
|
||||||
|
container.appendChild(panel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Composer -------------------------------------------------------------
|
||||||
|
const composer = document.createElement('div');
|
||||||
|
composer.className = 'ws-section';
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.rows = 2;
|
||||||
|
textarea.placeholder = 'Comment on the current line…';
|
||||||
|
const addBtn = document.createElement('button');
|
||||||
|
addBtn.className = 'ws-btn ws-btn-primary';
|
||||||
|
addBtn.textContent = 'Comment on line ' + editor.getCurrentLine();
|
||||||
|
addBtn.addEventListener('click', async () => {
|
||||||
|
const text = textarea.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
const line = editor.getCurrentLine();
|
||||||
|
const anchorText = (getLines(editor)[line - 1] || '').trim();
|
||||||
|
const comments = await store.loadComments(docPath, io, pathUtil);
|
||||||
|
store.addComment(comments, { line, anchorText, author: deps.author || 'me', text });
|
||||||
|
await store.saveComments(docPath, comments, io, pathUtil);
|
||||||
|
renderCommentsPanel(container, deps); // re-render with fresh list
|
||||||
|
});
|
||||||
|
composer.appendChild(textarea);
|
||||||
|
composer.appendChild(addBtn);
|
||||||
|
panel.appendChild(composer);
|
||||||
|
|
||||||
|
// --- List -----------------------------------------------------------------
|
||||||
|
const comments = await store.loadComments(docPath, io, pathUtil);
|
||||||
|
const lines = getLines(editor);
|
||||||
|
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'ws-issues-list';
|
||||||
|
if (comments.length === 0) {
|
||||||
|
const empty = document.createElement('p');
|
||||||
|
empty.className = 'ws-muted';
|
||||||
|
empty.textContent = 'No comments yet.';
|
||||||
|
list.appendChild(empty);
|
||||||
|
}
|
||||||
|
for (const comment of comments) {
|
||||||
|
const status = store.anchorStatus(comment, lines);
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'ws-issue-item' + (comment.resolved ? ' comment-resolved' : '');
|
||||||
|
const date = new Date(comment.createdAt).toLocaleString();
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="ws-issue-type">
|
||||||
|
<span data-role="meta">L${comment.line} · ${esc(comment.author)} · ${esc(date)}
|
||||||
|
${status !== 'ok' ? '· ' + (status === 'changed' ? 'text changed' : status === 'moved' ? 'moved' : 'gone') : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="ws-issue-text">${esc(comment.text)}</div>
|
||||||
|
${comment.anchorText ? `<div class="ws-muted">> ${esc(comment.anchorText)}</div>` : ''}
|
||||||
|
<div class="ws-issue-actions">
|
||||||
|
<button class="ws-btn ws-btn-sm" data-action="jump">Go to line</button>
|
||||||
|
<button class="ws-btn ws-btn-sm" data-action="resolve">${comment.resolved ? 'Reopen' : 'Resolve'}</button>
|
||||||
|
<button class="ws-btn ws-btn-sm" data-action="delete">Delete</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
item.querySelector('[data-action="jump"]').addEventListener('click', () => {
|
||||||
|
editor.jumpToLine(comment.line);
|
||||||
|
});
|
||||||
|
item.querySelector('[data-action="resolve"]').addEventListener('click', async () => {
|
||||||
|
const updated = await store.loadComments(docPath, io, pathUtil);
|
||||||
|
store.toggleResolved(updated, comment.id);
|
||||||
|
await store.saveComments(docPath, updated, io, pathUtil);
|
||||||
|
renderCommentsPanel(container, deps);
|
||||||
|
});
|
||||||
|
item.querySelector('[data-action="delete"]').addEventListener('click', async () => {
|
||||||
|
const updated = await store.loadComments(docPath, io, pathUtil);
|
||||||
|
store.deleteComment(updated, comment.id);
|
||||||
|
await store.saveComments(docPath, updated, io, pathUtil);
|
||||||
|
renderCommentsPanel(container, deps);
|
||||||
|
});
|
||||||
|
list.appendChild(item);
|
||||||
|
}
|
||||||
|
panel.appendChild(list);
|
||||||
|
|
||||||
|
container.appendChild(panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split the live document content into lines (1-based indexing helper). */
|
||||||
|
function getLines(editor) {
|
||||||
|
return String(editor.getContent() || '').split('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { renderCommentsPanel };
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Collaboration built-in plugin — anchor-based inline comments.
|
||||||
|
*
|
||||||
|
* Implements the comment half of the v5 collaboration design:
|
||||||
|
* - Comments sidebar panel (add / list / jump / resolve / delete)
|
||||||
|
* - F8 navigates to the next open comment (document-level key handler)
|
||||||
|
* - Ctrl+Alt+M adds a comment at the cursor line
|
||||||
|
*
|
||||||
|
* Comments persist to `<folder>/.comments/<file>.json` via the app's IPC file
|
||||||
|
* helpers; nothing is written into the document itself, so exports and Git
|
||||||
|
* commits of the prose stay clean.
|
||||||
|
*
|
||||||
|
* @module collaboration
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { PluginAPI } = require('../../../plugins/plugin-api');
|
||||||
|
const { renderCommentsPanel } = require('./comments-panel');
|
||||||
|
const store = require('./comment-store');
|
||||||
|
|
||||||
|
class CollaborationPlugin extends PluginAPI {
|
||||||
|
init(context) {
|
||||||
|
this.context = context;
|
||||||
|
|
||||||
|
// IO adapters over the allowlisted IPC file helpers; comments-panel
|
||||||
|
// and comment-store stay renderer-agnostic.
|
||||||
|
this._io = {
|
||||||
|
readFile: (p) => context.ipc.invoke('read-file', p),
|
||||||
|
writeFile: (p, c) => context.ipc.invoke('write-file', { path: p, content: c }),
|
||||||
|
fileExists: (p) => context.ipc.invoke('path-exists', p),
|
||||||
|
ensureDirectory: (p) => context.ipc.invoke('ensure-directory', p),
|
||||||
|
};
|
||||||
|
|
||||||
|
this._registerSidebar(context);
|
||||||
|
this._registerCommands(context);
|
||||||
|
this._installF8Navigation();
|
||||||
|
}
|
||||||
|
|
||||||
|
_registerSidebar(context) {
|
||||||
|
context.sidebar.registerPanel('comments', {
|
||||||
|
title: 'Comments',
|
||||||
|
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||||
|
</svg>`,
|
||||||
|
render: (container) => renderCommentsPanel(container, this._panelDeps()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_panelDeps() {
|
||||||
|
return {
|
||||||
|
editor: this.context.editor,
|
||||||
|
io: this._io,
|
||||||
|
pathUtil: require('path'),
|
||||||
|
author: 'me',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_registerCommands(context) {
|
||||||
|
context.commands.register(
|
||||||
|
'add-comment',
|
||||||
|
'Comment: Add Comment at Cursor',
|
||||||
|
() => {
|
||||||
|
// Opens the Comments panel focused on the composer at the cursor line
|
||||||
|
this._openPanel();
|
||||||
|
},
|
||||||
|
'Ctrl+Alt+M'
|
||||||
|
);
|
||||||
|
context.commands.register('next-comment', 'Comment: Next Open Comment', () => {
|
||||||
|
this._jumpToNextComment();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Document-level F8 handler (uninstalled on deactivate). Inside textareas
|
||||||
|
* and inputs F8 must keep its default behavior.
|
||||||
|
*/
|
||||||
|
_installF8Navigation() {
|
||||||
|
this._f8Handler = (event) => {
|
||||||
|
if (event.key !== 'F8') return;
|
||||||
|
const target = event.target;
|
||||||
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
|
||||||
|
event.preventDefault();
|
||||||
|
this._jumpToNextComment();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', this._f8Handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _jumpToNextComment() {
|
||||||
|
const docPath = this.context.editor.getCurrentFilePath();
|
||||||
|
if (!docPath) return;
|
||||||
|
const comments = await store.loadComments(docPath, this._io, require('path'));
|
||||||
|
const fromLine = this.context.editor.getCurrentLine();
|
||||||
|
const next = store.nextUnresolved(comments, fromLine);
|
||||||
|
if (next) this.context.editor.jumpToLine(next.line);
|
||||||
|
}
|
||||||
|
|
||||||
|
_openPanel() {
|
||||||
|
this.context.events.emit('sidebar:open-panel', { panel: 'collaboration:comments' });
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate() {
|
||||||
|
if (this._f8Handler) {
|
||||||
|
document.removeEventListener('keydown', this._f8Handler);
|
||||||
|
this._f8Handler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { Plugin: CollaborationPlugin };
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"id": "collaboration",
|
||||||
|
"name": "Collaboration",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Anchor-based inline comments stored in .comments/ sidecar files, with an F8 next-comment navigator",
|
||||||
|
"icon": "message-square",
|
||||||
|
"extensionPoints": {
|
||||||
|
"sidebar": [{ "id": "comments", "title": "Comments", "order": 34 }],
|
||||||
|
"commands": [
|
||||||
|
{ "id": "add-comment", "label": "Comment: Add Comment at Cursor", "shortcut": "Ctrl+Alt+M" },
|
||||||
|
{ "id": "next-comment", "label": "Comment: Next Open Comment", "shortcut": "F8" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"settings": []
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
const HISTORY_KEY = 'plugins.writing-studio.history';
|
const HISTORY_KEY = 'plugins.writing-studio.history';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks daily word-count history for goals, streaks, and the heatmap.
|
||||||
|
*
|
||||||
|
* All methods are async: the settings backend in the real app is the
|
||||||
|
* IPC-backed SettingsStore (get/set return Promises), while unit tests inject
|
||||||
|
* a plain synchronous object — `await` transparently handles both.
|
||||||
|
*/
|
||||||
class GoalTracker {
|
class GoalTracker {
|
||||||
/**
|
/**
|
||||||
* @param {object} store - { get(key), set(key, value) } settings backend
|
* @param {object} store - { get(key), set(key, value) } settings backend
|
||||||
@@ -8,41 +15,41 @@ class GoalTracker {
|
|||||||
this.store = store;
|
this.store = store;
|
||||||
}
|
}
|
||||||
|
|
||||||
_getHistory() {
|
async _getHistory() {
|
||||||
const raw = this.store.get(HISTORY_KEY);
|
const raw = await this.store.get(HISTORY_KEY);
|
||||||
return raw ? JSON.parse(raw) : {};
|
return raw ? JSON.parse(raw) : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
_setHistory(history) {
|
async _setHistory(history) {
|
||||||
this.store.set(HISTORY_KEY, JSON.stringify(history));
|
await this.store.set(HISTORY_KEY, JSON.stringify(history));
|
||||||
}
|
}
|
||||||
|
|
||||||
_setHistoryDay(dateStr, data) {
|
async _setHistoryDay(dateStr, data) {
|
||||||
const history = this._getHistory();
|
const history = await this._getHistory();
|
||||||
history[dateStr] = data;
|
history[dateStr] = data;
|
||||||
this._setHistory(history);
|
await this._setHistory(history);
|
||||||
}
|
}
|
||||||
|
|
||||||
addWords(count) {
|
async addWords(count) {
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
const history = this._getHistory();
|
const history = await this._getHistory();
|
||||||
if (!history[today]) {
|
if (!history[today]) {
|
||||||
history[today] = { words: 0, sessions: 0 };
|
history[today] = { words: 0, sessions: 0 };
|
||||||
}
|
}
|
||||||
history[today].words += count;
|
history[today].words += count;
|
||||||
history[today].sessions += 1;
|
history[today].sessions += 1;
|
||||||
this._setHistory(history);
|
await this._setHistory(history);
|
||||||
}
|
}
|
||||||
|
|
||||||
getDailyProgress(goal) {
|
async getDailyProgress(goal) {
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
const history = this._getHistory();
|
const history = await this._getHistory();
|
||||||
const written = history[today]?.words || 0;
|
const written = history[today]?.words || 0;
|
||||||
return { written, goal, pct: goal > 0 ? Math.min(100, Math.round((written / goal) * 100)) : 0 };
|
return { written, goal, pct: goal > 0 ? Math.min(100, Math.round((written / goal) * 100)) : 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
getStreak(goal) {
|
async getStreak(goal) {
|
||||||
const history = this._getHistory();
|
const history = await this._getHistory();
|
||||||
let streak = 0;
|
let streak = 0;
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
for (let i = 0; i < 365; i++) {
|
for (let i = 0; i < 365; i++) {
|
||||||
@@ -58,8 +65,8 @@ class GoalTracker {
|
|||||||
return streak;
|
return streak;
|
||||||
}
|
}
|
||||||
|
|
||||||
getLast30Days() {
|
async getLast30Days() {
|
||||||
const history = this._getHistory();
|
const history = await this._getHistory();
|
||||||
const days = [];
|
const days = [];
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
for (let i = 0; i < 30; i++) {
|
for (let i = 0; i < 30; i++) {
|
||||||
@@ -71,8 +78,8 @@ class GoalTracker {
|
|||||||
return days.reverse();
|
return days.reverse();
|
||||||
}
|
}
|
||||||
|
|
||||||
getWeeklyTotal() {
|
async getWeeklyTotal() {
|
||||||
const history = this._getHistory();
|
const history = await this._getHistory();
|
||||||
let total = 0;
|
let total = 0;
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
for (let i = 0; i < 7; i++) {
|
for (let i = 0; i < 7; i++) {
|
||||||
|
|||||||
@@ -35,11 +35,14 @@ class WritingStudioPlugin extends PluginAPI {
|
|||||||
_registerCommands(context) {
|
_registerCommands(context) {
|
||||||
const { sprintEngine, snapshotManager, goalTracker } = this;
|
const { sprintEngine, snapshotManager, goalTracker } = this;
|
||||||
|
|
||||||
|
// NOTE: settings.get and all engine methods are async (IPC-backed
|
||||||
|
// backend) — every handler below awaits what it reads.
|
||||||
|
|
||||||
context.commands.register(
|
context.commands.register(
|
||||||
'start-sprint',
|
'start-sprint',
|
||||||
'Studio: Start Sprint',
|
'Studio: Start Sprint',
|
||||||
() => {
|
async () => {
|
||||||
const duration = context.settings.get('sprintDuration') || 25;
|
const duration = (await context.settings.get('sprintDuration')) || 25;
|
||||||
const content = context.editor.getContent() || '';
|
const content = context.editor.getContent() || '';
|
||||||
const words = content.split(/\s+/).filter(Boolean).length;
|
const words = content.split(/\s+/).filter(Boolean).length;
|
||||||
sprintEngine.start(duration, words);
|
sprintEngine.start(duration, words);
|
||||||
@@ -50,12 +53,12 @@ class WritingStudioPlugin extends PluginAPI {
|
|||||||
context.commands.register(
|
context.commands.register(
|
||||||
'stop-sprint',
|
'stop-sprint',
|
||||||
'Studio: Stop Sprint',
|
'Studio: Stop Sprint',
|
||||||
() => {
|
async () => {
|
||||||
if (!sprintEngine.isActive()) return;
|
if (!sprintEngine.isActive()) return;
|
||||||
const content = context.editor.getContent() || '';
|
const content = context.editor.getContent() || '';
|
||||||
const words = content.split(/\s+/).filter(Boolean).length;
|
const words = content.split(/\s+/).filter(Boolean).length;
|
||||||
const result = sprintEngine.stop(words);
|
const result = sprintEngine.stop(words);
|
||||||
goalTracker.addWords(result.wordDelta);
|
await goalTracker.addWords(result.wordDelta);
|
||||||
context.events.emit('sprint:stopped', result);
|
context.events.emit('sprint:stopped', result);
|
||||||
},
|
},
|
||||||
'Ctrl+Alt+Shift+S'
|
'Ctrl+Alt+Shift+S'
|
||||||
@@ -64,9 +67,9 @@ class WritingStudioPlugin extends PluginAPI {
|
|||||||
context.commands.register(
|
context.commands.register(
|
||||||
'take-snapshot',
|
'take-snapshot',
|
||||||
'Studio: Take Snapshot',
|
'Studio: Take Snapshot',
|
||||||
() => {
|
async () => {
|
||||||
const content = context.editor.getContent() || '';
|
const content = context.editor.getContent() || '';
|
||||||
snapshotManager.create(content, 'manual');
|
await snapshotManager.create(content, 'manual');
|
||||||
context.events.emit('snapshot:created', {});
|
context.events.emit('snapshot:created', {});
|
||||||
},
|
},
|
||||||
'Ctrl+Alt+N'
|
'Ctrl+Alt+N'
|
||||||
@@ -75,10 +78,10 @@ class WritingStudioPlugin extends PluginAPI {
|
|||||||
context.commands.register(
|
context.commands.register(
|
||||||
'restore-last-snapshot',
|
'restore-last-snapshot',
|
||||||
'Studio: Restore Last Snapshot',
|
'Studio: Restore Last Snapshot',
|
||||||
() => {
|
async () => {
|
||||||
const snaps = snapshotManager.list();
|
const snaps = await snapshotManager.list();
|
||||||
if (snaps.length === 0) return;
|
if (snaps.length === 0) return;
|
||||||
const content = snapshotManager.restore(snaps[0].id);
|
const content = await snapshotManager.restore(snaps[0].id);
|
||||||
context.editor.insertAtCursor(content);
|
context.editor.insertAtCursor(content);
|
||||||
},
|
},
|
||||||
'Ctrl+Alt+Z'
|
'Ctrl+Alt+Z'
|
||||||
@@ -134,9 +137,9 @@ class WritingStudioPlugin extends PluginAPI {
|
|||||||
extension: 'txt',
|
extension: 'txt',
|
||||||
handler: async (markdownContent, outputPath) => {
|
handler: async (markdownContent, outputPath) => {
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const goal = context.settings.get('dailyGoal') || 1000;
|
const goal = (await context.settings.get('dailyGoal')) || 1000;
|
||||||
const progress = goalTracker.getDailyProgress(goal);
|
const progress = await goalTracker.getDailyProgress(goal);
|
||||||
const streak = goalTracker.getStreak(goal);
|
const streak = await goalTracker.getStreak(goal);
|
||||||
const wordCount = (markdownContent || '').split(/\s+/).filter(Boolean).length;
|
const wordCount = (markdownContent || '').split(/\s+/).filter(Boolean).length;
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
function renderGoalsPanel(container, { engines, settings }) {
|
async function renderGoalsPanel(container, { engines, settings }) {
|
||||||
const dailyGoal = settings.get('dailyGoal') || 1000;
|
// settings.get and the GoalTracker are async (IPC-backed store) — the whole
|
||||||
const progress = engines.goals.getDailyProgress(dailyGoal);
|
// panel renders after its data resolves.
|
||||||
const streak = engines.goals.getStreak(dailyGoal);
|
const dailyGoal = (await settings.get('dailyGoal')) || 1000;
|
||||||
const weekly = engines.goals.getWeeklyTotal();
|
const progress = await engines.goals.getDailyProgress(dailyGoal);
|
||||||
const last30 = engines.goals.getLast30Days();
|
const streak = await engines.goals.getStreak(dailyGoal);
|
||||||
|
const weekly = await engines.goals.getWeeklyTotal();
|
||||||
|
const last30 = await engines.goals.getLast30Days();
|
||||||
|
|
||||||
container.replaceChildren();
|
container.replaceChildren();
|
||||||
|
|
||||||
@@ -94,7 +96,44 @@ function renderGoalsPanel(container, { engines, settings }) {
|
|||||||
section3.appendChild(chart);
|
section3.appendChild(chart);
|
||||||
panel.appendChild(section3);
|
panel.appendChild(section3);
|
||||||
|
|
||||||
|
// GitHub-style activity heatmap over the same 30-day window: intensity is
|
||||||
|
// relative to the daily goal (empty / <33% / <66% / <100% / goal met).
|
||||||
|
const section4 = document.createElement('div');
|
||||||
|
section4.className = 'ws-section';
|
||||||
|
const heading4 = document.createElement('h3');
|
||||||
|
heading4.className = 'ws-heading';
|
||||||
|
heading4.textContent = 'Writing Heatmap';
|
||||||
|
section4.appendChild(heading4);
|
||||||
|
|
||||||
|
const heatmap = document.createElement('div');
|
||||||
|
heatmap.className = 'ws-heatmap';
|
||||||
|
heatmap.setAttribute('role', 'img');
|
||||||
|
heatmap.setAttribute('aria-label', 'Daily writing activity over the last 30 days');
|
||||||
|
for (const day of last30) {
|
||||||
|
const cell = document.createElement('span');
|
||||||
|
cell.className = 'ws-heatmap-cell ' + heatLevel(day.words, dailyGoal);
|
||||||
|
cell.title = `${day.date}: ${day.words} words${day.words >= dailyGoal ? ' — goal met' : ''}`;
|
||||||
|
heatmap.appendChild(cell);
|
||||||
|
}
|
||||||
|
section4.appendChild(heatmap);
|
||||||
|
panel.appendChild(section4);
|
||||||
|
|
||||||
container.appendChild(panel);
|
container.appendChild(panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a day's word count to a heatmap intensity class (l0 implicit…l4).
|
||||||
|
* @param {number} words words written that day
|
||||||
|
* @param {number} goal daily goal
|
||||||
|
* @returns {string} class suffix ('' for empty days)
|
||||||
|
*/
|
||||||
|
function heatLevel(words, goal) {
|
||||||
|
if (!words || words <= 0) return '';
|
||||||
|
const ratio = words / Math.max(1, goal);
|
||||||
|
if (ratio >= 1) return 'l4';
|
||||||
|
if (ratio >= 0.66) return 'l3';
|
||||||
|
if (ratio >= 0.33) return 'l2';
|
||||||
|
return 'l1';
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = { renderGoalsPanel };
|
module.exports = { renderGoalsPanel };
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
function renderManuscriptPanel(container, { engines, editor, settings }) {
|
/**
|
||||||
const projectDir = settings.get('projectDir');
|
* Manuscript sidebar panel (writing-studio).
|
||||||
|
*
|
||||||
|
* All project/settings access is async (IPC-backed store), and the
|
||||||
|
* "New Project" flow uses an inline dialog — Electron renderers have no
|
||||||
|
* window.prompt, so the old prompt() call threw on click.
|
||||||
|
*/
|
||||||
|
async function renderManuscriptPanel(container, { engines, editor, settings }) {
|
||||||
|
const projectDir = await settings.get('projectDir');
|
||||||
|
|
||||||
container.replaceChildren();
|
container.replaceChildren();
|
||||||
const panel = document.createElement('div');
|
const panel = document.createElement('div');
|
||||||
@@ -16,10 +23,14 @@ function renderManuscriptPanel(container, { engines, editor, settings }) {
|
|||||||
btn.id = 'ws-new-project';
|
btn.id = 'ws-new-project';
|
||||||
btn.textContent = 'New Project';
|
btn.textContent = 'New Project';
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const name = prompt('Project name:');
|
// Inline name input (window.prompt is unavailable in Electron)
|
||||||
if (!name) return;
|
const name = askForProjectName();
|
||||||
settings.set('projectDir', name);
|
name.then((result) => {
|
||||||
renderManuscriptPanel(container, { engines, editor, settings });
|
if (!result) return;
|
||||||
|
settings.set('projectDir', result).then(() => {
|
||||||
|
renderManuscriptPanel(container, { engines, editor, settings });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
empty.appendChild(btn);
|
empty.appendChild(btn);
|
||||||
panel.appendChild(empty);
|
panel.appendChild(empty);
|
||||||
@@ -27,7 +38,7 @@ function renderManuscriptPanel(container, { engines, editor, settings }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = engines.projects.loadProject(projectDir);
|
const project = await engines.projects.loadProject(projectDir);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
empty.className = 'ws-empty';
|
empty.className = 'ws-empty';
|
||||||
@@ -39,8 +50,9 @@ function renderManuscriptPanel(container, { engines, editor, settings }) {
|
|||||||
btn.id = 'ws-clear-project';
|
btn.id = 'ws-clear-project';
|
||||||
btn.textContent = 'Clear Project';
|
btn.textContent = 'Clear Project';
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
settings.set('projectDir', null);
|
settings.set('projectDir', null).then(() => {
|
||||||
renderManuscriptPanel(container, { engines, editor, settings });
|
renderManuscriptPanel(container, { engines, editor, settings });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
empty.appendChild(btn);
|
empty.appendChild(btn);
|
||||||
panel.appendChild(empty);
|
panel.appendChild(empty);
|
||||||
@@ -48,7 +60,7 @@ function renderManuscriptPanel(container, { engines, editor, settings }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = engines.projects.getStats(projectDir);
|
const stats = await engines.projects.getStats(projectDir);
|
||||||
|
|
||||||
// Project title + progress
|
// Project title + progress
|
||||||
const section1 = document.createElement('div');
|
const section1 = document.createElement('div');
|
||||||
@@ -112,8 +124,8 @@ function renderManuscriptPanel(container, { engines, editor, settings }) {
|
|||||||
compileBtn.className = 'ws-btn ws-btn-primary';
|
compileBtn.className = 'ws-btn ws-btn-primary';
|
||||||
compileBtn.id = 'ws-compile';
|
compileBtn.id = 'ws-compile';
|
||||||
compileBtn.textContent = 'Compile Manuscript';
|
compileBtn.textContent = 'Compile Manuscript';
|
||||||
compileBtn.addEventListener('click', () => {
|
compileBtn.addEventListener('click', async () => {
|
||||||
const compiled = engines.projects.compileManuscript(projectDir);
|
const compiled = await engines.projects.compileManuscript(projectDir);
|
||||||
editor.insertAtCursor(compiled);
|
editor.insertAtCursor(compiled);
|
||||||
});
|
});
|
||||||
section3.appendChild(compileBtn);
|
section3.appendChild(compileBtn);
|
||||||
@@ -122,4 +134,42 @@ function renderManuscriptPanel(container, { engines, editor, settings }) {
|
|||||||
container.appendChild(panel);
|
container.appendChild(panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal promise-based name input (Electron has no window.prompt).
|
||||||
|
* @returns {Promise<string|null>} project name, or null when cancelled
|
||||||
|
*/
|
||||||
|
function askForProjectName() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'ai-settings-overlay';
|
||||||
|
overlay.setAttribute('role', 'dialog');
|
||||||
|
overlay.setAttribute('aria-modal', 'true');
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="ai-settings-dialog">
|
||||||
|
<h3>New manuscript project</h3>
|
||||||
|
<input type="text" data-role="name" placeholder="Project name" aria-label="Project name" />
|
||||||
|
<div class="ai-settings-actions">
|
||||||
|
<button type="button" data-role="ok">Create</button>
|
||||||
|
<button type="button" data-role="cancel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
const input = overlay.querySelector('[data-role="name"]');
|
||||||
|
const done = (value) => {
|
||||||
|
overlay.remove();
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
overlay.querySelector('[data-role="ok"]').addEventListener('click', () => done(input.value.trim() || null));
|
||||||
|
overlay.querySelector('[data-role="cancel"]').addEventListener('click', () => done(null));
|
||||||
|
overlay.addEventListener('click', (event) => {
|
||||||
|
if (event.target === overlay) done(null);
|
||||||
|
});
|
||||||
|
input.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter') done(input.value.trim() || null);
|
||||||
|
if (event.key === 'Escape') done(null);
|
||||||
|
});
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = { renderManuscriptPanel };
|
module.exports = { renderManuscriptPanel };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
function renderSnapshotsPanel(container, { engines, editor }) {
|
async function renderSnapshotsPanel(container, { engines, editor }) {
|
||||||
const snapshots = engines.snapshots.list();
|
const snapshots = await engines.snapshots.list();
|
||||||
|
|
||||||
container.replaceChildren();
|
container.replaceChildren();
|
||||||
|
|
||||||
@@ -53,16 +53,16 @@ function renderSnapshotsPanel(container, { engines, editor }) {
|
|||||||
const actionBtn = document.createElement('button');
|
const actionBtn = document.createElement('button');
|
||||||
actionBtn.className = 'ws-btn ws-btn-sm' + (cls ? ' ' + cls : '');
|
actionBtn.className = 'ws-btn ws-btn-sm' + (cls ? ' ' + cls : '');
|
||||||
actionBtn.textContent = text;
|
actionBtn.textContent = text;
|
||||||
actionBtn.addEventListener('click', () => {
|
actionBtn.addEventListener('click', async () => {
|
||||||
if (action === 'restore') {
|
if (action === 'restore') {
|
||||||
const content = engines.snapshots.restore(s.id);
|
const content = await engines.snapshots.restore(s.id);
|
||||||
editor.insertAtCursor(content);
|
editor.insertAtCursor(content);
|
||||||
} else if (action === 'delete') {
|
} else if (action === 'delete') {
|
||||||
engines.snapshots.delete(s.id);
|
await engines.snapshots.delete(s.id);
|
||||||
renderSnapshotsPanel(container, { engines, editor });
|
renderSnapshotsPanel(container, { engines, editor });
|
||||||
} else if (action === 'diff') {
|
} else if (action === 'diff') {
|
||||||
const current = editor.getContent() || '';
|
const current = editor.getContent() || '';
|
||||||
const result = engines.snapshots.diff(s.id, current);
|
const result = await engines.snapshots.diff(s.id, current);
|
||||||
alert('+' + result.added + ' lines added, -' + result.removed + ' lines removed');
|
alert('+' + result.added + ' lines added, -' + result.removed + ' lines removed');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -75,9 +75,9 @@ function renderSnapshotsPanel(container, { engines, editor }) {
|
|||||||
container.appendChild(panel);
|
container.appendChild(panel);
|
||||||
|
|
||||||
// Take snapshot button handler
|
// Take snapshot button handler
|
||||||
container.querySelector('#ws-take-snapshot').addEventListener('click', () => {
|
container.querySelector('#ws-take-snapshot').addEventListener('click', async () => {
|
||||||
const content = editor.getContent() || '';
|
const content = editor.getContent() || '';
|
||||||
engines.snapshots.create(content, 'manual');
|
await engines.snapshots.create(content, 'manual');
|
||||||
renderSnapshotsPanel(container, { engines, editor });
|
renderSnapshotsPanel(container, { engines, editor });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Manuscript project management (.project.json + chapter files).
|
||||||
|
*
|
||||||
|
* All methods are async: the fs backend injected by the plugin is the
|
||||||
|
* IPC-backed file API (readFile/writeFile/fileExists/listDir return
|
||||||
|
* Promises); `await` also works against the synchronous fakes used in unit
|
||||||
|
* tests.
|
||||||
|
*/
|
||||||
class ProjectManager {
|
class ProjectManager {
|
||||||
/**
|
/**
|
||||||
* @param {object} fs - { readFile(path), writeFile(path, content), fileExists(path), listDir(path) }
|
* @param {object} fs - { readFile(path), writeFile(path, content), fileExists(path), listDir(path) }
|
||||||
@@ -6,7 +14,7 @@ class ProjectManager {
|
|||||||
this.fs = fs;
|
this.fs = fs;
|
||||||
}
|
}
|
||||||
|
|
||||||
createProject(dir, opts) {
|
async createProject(dir, opts) {
|
||||||
const project = {
|
const project = {
|
||||||
title: opts.title,
|
title: opts.title,
|
||||||
type: opts.type || 'manuscript',
|
type: opts.type || 'manuscript',
|
||||||
@@ -14,51 +22,53 @@ class ProjectManager {
|
|||||||
chapters: [],
|
chapters: [],
|
||||||
metadata: opts.metadata || {},
|
metadata: opts.metadata || {},
|
||||||
};
|
};
|
||||||
this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
|
await this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
|
||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadProject(dir) {
|
async loadProject(dir) {
|
||||||
const raw = this.fs.readFile(dir + '/.project.json');
|
const exists = await this.fs.fileExists(dir + '/.project.json');
|
||||||
|
if (!exists) return null;
|
||||||
|
const raw = await this.fs.readFile(dir + '/.project.json');
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
return JSON.parse(raw);
|
return JSON.parse(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
_saveProject(dir, project) {
|
async _saveProject(dir, project) {
|
||||||
this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
|
await this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
addChapter(dir, chapter) {
|
async addChapter(dir, chapter) {
|
||||||
const project = this.loadProject(dir);
|
const project = await this.loadProject(dir);
|
||||||
if (!project) throw new Error('Project not found');
|
if (!project) throw new Error('Project not found');
|
||||||
project.chapters.push(chapter);
|
project.chapters.push(chapter);
|
||||||
this._saveProject(dir, project);
|
await this._saveProject(dir, project);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateChapter(dir, index, updates) {
|
async updateChapter(dir, index, updates) {
|
||||||
const project = this.loadProject(dir);
|
const project = await this.loadProject(dir);
|
||||||
if (!project) throw new Error('Project not found');
|
if (!project) throw new Error('Project not found');
|
||||||
Object.assign(project.chapters[index], updates);
|
Object.assign(project.chapters[index], updates);
|
||||||
this._saveProject(dir, project);
|
await this._saveProject(dir, project);
|
||||||
}
|
}
|
||||||
|
|
||||||
compileManuscript(dir) {
|
async compileManuscript(dir) {
|
||||||
const project = this.loadProject(dir);
|
const project = await this.loadProject(dir);
|
||||||
if (!project) throw new Error('Project not found');
|
if (!project) throw new Error('Project not found');
|
||||||
const parts = [];
|
const parts = [];
|
||||||
for (const ch of project.chapters) {
|
for (const ch of project.chapters) {
|
||||||
const content = this.fs.readFile(dir + '/' + ch.file);
|
const content = await this.fs.readFile(dir + '/' + ch.file);
|
||||||
if (content) parts.push(content);
|
if (content) parts.push(content);
|
||||||
}
|
}
|
||||||
return parts.join('\n\n---\n\n');
|
return parts.join('\n\n---\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
getStats(dir) {
|
async getStats(dir) {
|
||||||
const project = this.loadProject(dir);
|
const project = await this.loadProject(dir);
|
||||||
if (!project) throw new Error('Project not found');
|
if (!project) throw new Error('Project not found');
|
||||||
let totalWords = 0;
|
let totalWords = 0;
|
||||||
for (const ch of project.chapters) {
|
for (const ch of project.chapters) {
|
||||||
const content = this.fs.readFile(dir + '/' + ch.file);
|
const content = await this.fs.readFile(dir + '/' + ch.file);
|
||||||
if (content) totalWords += content.split(/\s+/).filter(Boolean).length;
|
if (content) totalWords += content.split(/\s+/).filter(Boolean).length;
|
||||||
}
|
}
|
||||||
const target = project.target.words || 0;
|
const target = project.target.words || 0;
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Content snapshots with a rolling window, stored via the plugin settings
|
||||||
|
* backend. All methods are async because the real backend is the IPC-backed
|
||||||
|
* SettingsStore (get/set return Promises); `await` also works against the
|
||||||
|
* synchronous fakes used in unit tests.
|
||||||
|
*/
|
||||||
class SnapshotManager {
|
class SnapshotManager {
|
||||||
/**
|
/**
|
||||||
* @param {object} store - { get(key), set(key, value) }
|
* @param {object} store - { get(key), set(key, value) }
|
||||||
@@ -8,17 +14,17 @@ class SnapshotManager {
|
|||||||
this.storeKey = storeKey;
|
this.storeKey = storeKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
_getAll() {
|
async _getAll() {
|
||||||
const raw = this.store.get(this.storeKey);
|
const raw = await this.store.get(this.storeKey);
|
||||||
return raw ? JSON.parse(raw) : [];
|
return raw ? JSON.parse(raw) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
_saveAll(snaps) {
|
async _saveAll(snaps) {
|
||||||
this.store.set(this.storeKey, JSON.stringify(snaps));
|
await this.store.set(this.storeKey, JSON.stringify(snaps));
|
||||||
}
|
}
|
||||||
|
|
||||||
create(content, label = 'manual') {
|
async create(content, label = 'manual') {
|
||||||
const snaps = this._getAll();
|
const snaps = await this._getAll();
|
||||||
const snap = {
|
const snap = {
|
||||||
id: 'snap-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8),
|
id: 'snap-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8),
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -27,31 +33,31 @@ class SnapshotManager {
|
|||||||
label,
|
label,
|
||||||
};
|
};
|
||||||
snaps.unshift(snap);
|
snaps.unshift(snap);
|
||||||
this._saveAll(snaps);
|
await this._saveAll(snaps);
|
||||||
return snap;
|
return snap;
|
||||||
}
|
}
|
||||||
|
|
||||||
list() {
|
async list() {
|
||||||
return this._getAll();
|
return this._getAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
getById(id) {
|
async getById(id) {
|
||||||
return this._getAll().find((s) => s.id === id) || null;
|
return (await this._getAll()).find((s) => s.id === id) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
restore(id) {
|
async restore(id) {
|
||||||
const snap = this.getById(id);
|
const snap = await this.getById(id);
|
||||||
if (!snap) throw new Error('Snapshot not found');
|
if (!snap) throw new Error('Snapshot not found');
|
||||||
return snap.content;
|
return snap.content;
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(id) {
|
async delete(id) {
|
||||||
const snaps = this._getAll().filter((s) => s.id !== id);
|
const snaps = (await this._getAll()).filter((s) => s.id !== id);
|
||||||
this._saveAll(snaps);
|
await this._saveAll(snaps);
|
||||||
}
|
}
|
||||||
|
|
||||||
diff(id, currentContent) {
|
async diff(id, currentContent) {
|
||||||
const snap = this.getById(id);
|
const snap = await this.getById(id);
|
||||||
if (!snap) throw new Error('Snapshot not found');
|
if (!snap) throw new Error('Snapshot not found');
|
||||||
const oldLines = snap.content.split('\n');
|
const oldLines = snap.content.split('\n');
|
||||||
const newLines = currentContent.split('\n');
|
const newLines = currentContent.split('\n');
|
||||||
@@ -68,9 +74,9 @@ class SnapshotManager {
|
|||||||
return { added, removed };
|
return { added, removed };
|
||||||
}
|
}
|
||||||
|
|
||||||
prune(keepCount) {
|
async prune(keepCount) {
|
||||||
const snaps = this._getAll();
|
const snaps = await this._getAll();
|
||||||
this._saveAll(snaps.slice(0, keepCount));
|
await this._saveAll(snaps.slice(0, keepCount));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,15 @@ class PluginContext {
|
|||||||
getSelection: () => editor.getSelection(),
|
getSelection: () => editor.getSelection(),
|
||||||
insertAtCursor: (text) => editor.insertAtCursor(text),
|
insertAtCursor: (text) => editor.insertAtCursor(text),
|
||||||
onContentChanged: (cb) => editor.onContentChanged(cb),
|
onContentChanged: (cb) => editor.onContentChanged(cb),
|
||||||
|
// Current tab's file path (null for untitled tabs) — plugins that store
|
||||||
|
// sidecar data (comments, snapshots, …) key off this.
|
||||||
|
getCurrentFilePath: () => (editor.getCurrentFilePath ? editor.getCurrentFilePath() : null),
|
||||||
|
// Scroll the active editor to a 1-based line and place the cursor there
|
||||||
|
jumpToLine: (line) => {
|
||||||
|
if (editor.jumpToLine) editor.jumpToLine(line);
|
||||||
|
},
|
||||||
|
// 1-based line of the primary cursor
|
||||||
|
getCurrentLine: () => (editor.getCurrentLine ? editor.getCurrentLine() : 1),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.events = {
|
this.events = {
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
'plugin-export-formats-registered',
|
'plugin-export-formats-registered',
|
||||||
'plugin-export-format-result',
|
'plugin-export-format-result',
|
||||||
|
|
||||||
|
// AI Assistant plugin (completions proxied through main; keys stay there)
|
||||||
|
'ai-assistant:complete',
|
||||||
|
'ai-assistant:status',
|
||||||
|
|
||||||
// Batch conversion
|
// Batch conversion
|
||||||
'batch-convert',
|
'batch-convert',
|
||||||
'select-folder',
|
'select-folder',
|
||||||
@@ -155,6 +159,27 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
// Monospace font settings
|
// Monospace font settings
|
||||||
'get-monospace-settings',
|
'get-monospace-settings',
|
||||||
'set-monospace-settings',
|
'set-monospace-settings',
|
||||||
|
|
||||||
|
// Vim keybinding mode
|
||||||
|
'get-vim-mode',
|
||||||
|
'set-vim-mode',
|
||||||
|
|
||||||
|
// PlantUML local rendering (optional CLI)
|
||||||
|
'plantuml:available',
|
||||||
|
'plantuml:render',
|
||||||
|
|
||||||
|
// MarkItDown import (optional Python CLI)
|
||||||
|
'markitdown:available',
|
||||||
|
'markitdown:convert',
|
||||||
|
|
||||||
|
// Quick Note scratchpad
|
||||||
|
'quick-note:save',
|
||||||
|
|
||||||
|
// Document version history
|
||||||
|
'version-history:list',
|
||||||
|
'version-history:read',
|
||||||
|
'version-history:save',
|
||||||
|
'version-history:delete',
|
||||||
];
|
];
|
||||||
|
|
||||||
const ALLOWED_RECEIVE_CHANNELS = [
|
const ALLOWED_RECEIVE_CHANNELS = [
|
||||||
|
|||||||
+551
-54
@@ -17,6 +17,46 @@ const { showDocumentCompareDialog } = require('./renderer/document-compare-dialo
|
|||||||
const { initExportPresets, refreshExportPresets } = require('./renderer/export-presets');
|
const { initExportPresets, refreshExportPresets } = require('./renderer/export-presets');
|
||||||
const { csvToMarkdownTable } = require('./utils/csv-to-markdown-table');
|
const { csvToMarkdownTable } = require('./utils/csv-to-markdown-table');
|
||||||
const { getFilePath } = require('./utils/file-path');
|
const { getFilePath } = require('./utils/file-path');
|
||||||
|
const SessionStore = require('./utils/session-store');
|
||||||
|
const { renderWikiLinksInHtml, resolveTargetPath } = require('./utils/wiki-links');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// Snippet Tab-expansion registry
|
||||||
|
// ================================
|
||||||
|
// A lowercase-name → content map rebuilt whenever snippets change; queried
|
||||||
|
// synchronously by every editor's Tab keymap (see snippetTabKeymap).
|
||||||
|
const snippetExpansions = new Map();
|
||||||
|
async function refreshSnippetExpansions() {
|
||||||
|
try {
|
||||||
|
const snippets = await ipcRenderer.invoke('get-snippets');
|
||||||
|
snippetExpansions.clear();
|
||||||
|
for (const snippet of Array.isArray(snippets) ? snippets : []) {
|
||||||
|
if (snippet?.name && typeof snippet.content === 'string') {
|
||||||
|
snippetExpansions.set(snippet.name.toLowerCase(), snippet.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* snippets unavailable — Tab keeps its default indent behavior */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshSnippetExpansions();
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// Vim keybinding mode
|
||||||
|
// ================================
|
||||||
|
// Mirrors the monospace-setting flow: main menu checkbox → persisted setting
|
||||||
|
// → renderer applies setVimMode to every live editor. window.__vimModeEnabled
|
||||||
|
// seeds newly created editors with the right mode.
|
||||||
|
function applyVimMode(enabled) {
|
||||||
|
window.__vimModeEnabled = enabled === true;
|
||||||
|
const { setVimMode } = require('./editor/codemirror-setup');
|
||||||
|
if (tabManager) {
|
||||||
|
for (const tab of tabManager.tabs.values()) {
|
||||||
|
if (tab.editorView) setVimMode(tab.editorView, window.__vimModeEnabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle body classes that drive the monospace font + ligatures CSS tokens.
|
* Toggle body classes that drive the monospace font + ligatures CSS tokens.
|
||||||
@@ -278,6 +318,10 @@ marked.use({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Session-scoped set of languages the user has approved for REPL execution
|
||||||
|
// (populated by the confirm dialog on first Run click per language).
|
||||||
|
const replExecutionConfirmed = new Set();
|
||||||
|
|
||||||
// PlantUML hex encoding for server rendering
|
// PlantUML hex encoding for server rendering
|
||||||
function plantumlEncode(text) {
|
function plantumlEncode(text) {
|
||||||
const hex = Array.from(new TextEncoder().encode(text))
|
const hex = Array.from(new TextEncoder().encode(text))
|
||||||
@@ -286,6 +330,67 @@ function plantumlEncode(text) {
|
|||||||
return '~h' + hex;
|
return '~h' + hex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// PlantUML rendering (local-first, server fallback)
|
||||||
|
// ================================
|
||||||
|
// A local `plantuml` CLI (if installed) renders via the main process, so
|
||||||
|
// diagram text never leaves the machine; otherwise the public plantuml.com
|
||||||
|
// server is used exactly as before. The availability probe is cached per
|
||||||
|
// session so each preview render costs at most one invoke.
|
||||||
|
let plantumlLocalAvailability = undefined; // undefined = not probed yet
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace PlantUML code blocks with rendered diagrams.
|
||||||
|
* @param {NodeList} blocks `pre code.language-plantuml` elements
|
||||||
|
*/
|
||||||
|
async function renderPlantUmlBlocks(blocks) {
|
||||||
|
if (plantumlLocalAvailability === undefined) {
|
||||||
|
try {
|
||||||
|
plantumlLocalAvailability = (await ipcRenderer.invoke('plantuml:available')) === true;
|
||||||
|
} catch {
|
||||||
|
plantumlLocalAvailability = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
const code = block.textContent;
|
||||||
|
const pre = block.parentElement;
|
||||||
|
|
||||||
|
if (plantumlLocalAvailability) {
|
||||||
|
try {
|
||||||
|
const result = await ipcRenderer.invoke('plantuml:render', { text: code });
|
||||||
|
if (result?.dataUrl) {
|
||||||
|
replacePreWithImage(pre, result.dataUrl);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to server rendering
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = `https://www.plantuml.com/plantuml/svg/${plantumlEncode(code)}`;
|
||||||
|
img.alt = 'PlantUML diagram';
|
||||||
|
img.style.maxWidth = '100%';
|
||||||
|
img.onerror = () => {
|
||||||
|
img.replaceWith(pre); // Fallback to code block on error
|
||||||
|
};
|
||||||
|
if (pre.parentElement) pre.parentElement.replaceChild(img, pre);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Swap a <pre> for a rendered <img>, keeping a fallback on error. */
|
||||||
|
function replacePreWithImage(pre, dataUrl) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = dataUrl;
|
||||||
|
img.alt = 'PlantUML diagram';
|
||||||
|
img.style.maxWidth = '100%';
|
||||||
|
img.onerror = () => {
|
||||||
|
img.replaceWith(pre);
|
||||||
|
};
|
||||||
|
if (pre.parentElement) pre.parentElement.replaceChild(img, pre);
|
||||||
|
}
|
||||||
|
|
||||||
// Scopes user custom CSS by prefixing all selectors to protect application UI
|
// Scopes user custom CSS by prefixing all selectors to protect application UI
|
||||||
function scopeCSS(cssText, scopeSelector) {
|
function scopeCSS(cssText, scopeSelector) {
|
||||||
if (!cssText) return '';
|
if (!cssText) return '';
|
||||||
@@ -618,6 +723,10 @@ class TabManager {
|
|||||||
const isDark = document.body.className.includes('dark');
|
const isDark = document.body.className.includes('dark');
|
||||||
tab.editorView = createEditor(editorContainer, {
|
tab.editorView = createEditor(editorContainer, {
|
||||||
content: tab.content,
|
content: tab.content,
|
||||||
|
// Vim mode follows the persisted app setting for every new editor
|
||||||
|
vimMode: window.__vimModeEnabled === true,
|
||||||
|
// Tab expands a snippet when the word before the cursor matches one
|
||||||
|
getTabExpansion: (prefix) => snippetExpansions.get(prefix) || null,
|
||||||
onChange: (newContent) => {
|
onChange: (newContent) => {
|
||||||
tab.content = newContent;
|
tab.content = newContent;
|
||||||
tab.isDirty = true;
|
tab.isDirty = true;
|
||||||
@@ -638,6 +747,7 @@ class TabManager {
|
|||||||
this.updatePreview(tab.id);
|
this.updatePreview(tab.id);
|
||||||
this.updateWordCount();
|
this.updateWordCount();
|
||||||
this.updateTabBar();
|
this.updateTabBar();
|
||||||
|
this.scheduleSessionPersist(); // crash-recovery snapshot (debounced)
|
||||||
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
|
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
|
||||||
},
|
},
|
||||||
onUpdate: (view) => {
|
onUpdate: (view) => {
|
||||||
@@ -678,6 +788,7 @@ class TabManager {
|
|||||||
|
|
||||||
// Refresh outline panel for new tab content
|
// Refresh outline panel for new tab content
|
||||||
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
|
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
|
||||||
|
this.scheduleSessionPersist(); // crash-recovery snapshot
|
||||||
}
|
}
|
||||||
switchToNextTab() {
|
switchToNextTab() {
|
||||||
const tabIds = Array.from(this.tabs.keys());
|
const tabIds = Array.from(this.tabs.keys());
|
||||||
@@ -729,6 +840,7 @@ class TabManager {
|
|||||||
this.switchToTab(remainingTabs[0]);
|
this.switchToTab(remainingTabs[0]);
|
||||||
}
|
}
|
||||||
this.updateTabBar();
|
this.updateTabBar();
|
||||||
|
this.scheduleSessionPersist(); // crash-recovery snapshot
|
||||||
}
|
}
|
||||||
updateTabBar() {
|
updateTabBar() {
|
||||||
const tabBar = document.getElementById('tab-bar');
|
const tabBar = document.getElementById('tab-bar');
|
||||||
@@ -919,6 +1031,13 @@ class TabManager {
|
|||||||
}
|
}
|
||||||
preview.innerHTML = sanitizedHtml;
|
preview.innerHTML = sanitizedHtml;
|
||||||
|
|
||||||
|
// Turn [[wiki links]] into clickable links (knowledge-base mode).
|
||||||
|
// Applied after sanitization; renderWikiLinksInHtml itself escapes the
|
||||||
|
// target/label text, so no new HTML injection surface is opened.
|
||||||
|
if (sanitizedHtml.indexOf('[[') !== -1) {
|
||||||
|
preview.innerHTML = renderWikiLinksInHtml(sanitizedHtml);
|
||||||
|
}
|
||||||
|
|
||||||
// Render math expressions if KaTeX is available
|
// Render math expressions if KaTeX is available
|
||||||
if (window.katex && window.renderMathInElement) {
|
if (window.katex && window.renderMathInElement) {
|
||||||
try {
|
try {
|
||||||
@@ -989,23 +1108,13 @@ class TabManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render PlantUML diagrams
|
// Render PlantUML diagrams — locally when a plantuml CLI is installed
|
||||||
|
// (keeps diagram sources on the machine), falling back to the public
|
||||||
|
// server otherwise. Local availability is checked once per session.
|
||||||
const plantumlBlocks = preview.querySelectorAll('pre code.language-plantuml');
|
const plantumlBlocks = preview.querySelectorAll('pre code.language-plantuml');
|
||||||
plantumlBlocks.forEach((block) => {
|
if (plantumlBlocks.length > 0) {
|
||||||
const code = block.textContent;
|
renderPlantUmlBlocks(plantumlBlocks);
|
||||||
const pre = block.parentElement;
|
}
|
||||||
|
|
||||||
// Encode for PlantUML server using hex encoding
|
|
||||||
const encoded = plantumlEncode(code);
|
|
||||||
const img = document.createElement('img');
|
|
||||||
img.src = `https://www.plantuml.com/plantuml/svg/${encoded}`;
|
|
||||||
img.alt = 'PlantUML diagram';
|
|
||||||
img.style.maxWidth = '100%';
|
|
||||||
img.onerror = () => {
|
|
||||||
img.replaceWith(pre); // Fallback to code block on error
|
|
||||||
};
|
|
||||||
pre.parentElement.replaceChild(img, pre);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add Run buttons to executable code blocks (REPL)
|
// Add Run buttons to executable code blocks (REPL)
|
||||||
const codeBlocks = preview.querySelectorAll('pre code[class*="language-"]');
|
const codeBlocks = preview.querySelectorAll('pre code[class*="language-"]');
|
||||||
@@ -1017,6 +1126,18 @@ class TabManager {
|
|||||||
runBtn.textContent = '\u25B6 Run';
|
runBtn.textContent = '\u25B6 Run';
|
||||||
runBtn.addEventListener('click', async () => {
|
runBtn.addEventListener('click', async () => {
|
||||||
if (replPanel) {
|
if (replPanel) {
|
||||||
|
// Code execution runs UNSANDBOXED on the user's machine
|
||||||
|
// (node/python/shell child processes). Confirm once per session
|
||||||
|
// per language so a crafted document can't silently run code
|
||||||
|
// the first time its Run button is clicked.
|
||||||
|
if (!replExecutionConfirmed.has(lang)) {
|
||||||
|
const ok = confirm(
|
||||||
|
`Run ${lang} code blocks on this machine?\n\n` +
|
||||||
|
'Code from the document will execute as a child process with your user permissions (10s timeout).'
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
replExecutionConfirmed.add(lang);
|
||||||
|
}
|
||||||
replPanel.show();
|
replPanel.show();
|
||||||
const result = await ipcRenderer.invoke('execute-code', {
|
const result = await ipcRenderer.invoke('execute-code', {
|
||||||
code: block.textContent,
|
code: block.textContent,
|
||||||
@@ -1156,6 +1277,115 @@ class TabManager {
|
|||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// Session persistence (crash recovery)
|
||||||
|
// ================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a session snapshot (open tabs + unsaved content) to localStorage so
|
||||||
|
* the next launch can offer to restore after a crash or force-quit.
|
||||||
|
*/
|
||||||
|
persistSession() {
|
||||||
|
const session = SessionStore.captureSession(this.tabs, this.activeTabId, (tabId) =>
|
||||||
|
this.getEditorContent(tabId)
|
||||||
|
);
|
||||||
|
SessionStore.saveSession(localStorage, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounced persistSession — called on every content keystroke, so writing
|
||||||
|
* to localStorage synchronously each time would be wasteful.
|
||||||
|
*/
|
||||||
|
scheduleSessionPersist() {
|
||||||
|
if (this._sessionPersistTimer) clearTimeout(this._sessionPersistTimer);
|
||||||
|
this._sessionPersistTimer = setTimeout(() => {
|
||||||
|
this._sessionPersistTimer = null;
|
||||||
|
this.persistSession();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore tabs from a saved session snapshot. Returns the number of tabs
|
||||||
|
* actually restored (missing files and empty tabs are skipped).
|
||||||
|
*
|
||||||
|
* @param {object} session Snapshot from SessionStore.loadSession()
|
||||||
|
* @returns {number} restored tab count
|
||||||
|
*/
|
||||||
|
restoreSession(session) {
|
||||||
|
if (!session || !Array.isArray(session.tabs)) return 0;
|
||||||
|
let restored = 0;
|
||||||
|
|
||||||
|
for (const entry of session.tabs) {
|
||||||
|
try {
|
||||||
|
if (entry.type === 'pdf') {
|
||||||
|
// PDF tabs are view-only; reopen from the original path when it
|
||||||
|
// still exists, otherwise skip silently.
|
||||||
|
if (entry.filePath && fs.existsSync(entry.filePath)) {
|
||||||
|
this.createPdfTab(entry.filePath);
|
||||||
|
restored++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.filePath && fs.existsSync(entry.filePath)) {
|
||||||
|
// File-backed tab: reload the file from disk, then reapply the
|
||||||
|
// unsaved buffer on top when the snapshot captured one.
|
||||||
|
const onDisk = fs.readFileSync(entry.filePath, 'utf-8');
|
||||||
|
this.openFile(entry.filePath, onDisk);
|
||||||
|
if (entry.isDirty && typeof entry.content === 'string') {
|
||||||
|
const tab = this.tabs.get(this.activeTabId);
|
||||||
|
if (tab) {
|
||||||
|
this.setEditorContent(tab.id, entry.content);
|
||||||
|
tab.isDirty = true;
|
||||||
|
this.updateTabBar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
restored++;
|
||||||
|
} else if (typeof entry.content === 'string' && entry.content.length > 0) {
|
||||||
|
// Untitled dirty buffer — the only copy is the snapshot itself.
|
||||||
|
this.createNewTab();
|
||||||
|
const tab = this.tabs.get(this.activeTabId);
|
||||||
|
if (tab) {
|
||||||
|
this.setEditorContent(tab.id, entry.content);
|
||||||
|
tab.title = entry.title || 'Untitled';
|
||||||
|
tab.isDirty = true;
|
||||||
|
this.updatePreview(tab.id);
|
||||||
|
this.updateTabBar();
|
||||||
|
}
|
||||||
|
restored++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to restore tab from session:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// End on the tab that was active when the session was saved, if possible
|
||||||
|
const lastEntry = [...session.tabs].reverse().find((t) => this._findTabIdForEntry(t) !== null);
|
||||||
|
const targetId = lastEntry ? this._findTabIdForEntry(lastEntry) : null;
|
||||||
|
if (targetId !== null && this.tabs.has(targetId)) {
|
||||||
|
this.switchToTab(targetId);
|
||||||
|
}
|
||||||
|
if (restored > 0) this.scheduleSessionPersist();
|
||||||
|
return restored;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort mapping of a session entry back to a live tab id after a
|
||||||
|
* restore pass (matches by filePath, falling back to title for untitled).
|
||||||
|
*/
|
||||||
|
_findTabIdForEntry(entry) {
|
||||||
|
if (!entry) return null;
|
||||||
|
for (const tab of this.tabs.values()) {
|
||||||
|
if (entry.filePath && tab.filePath === entry.filePath) return tab.id;
|
||||||
|
}
|
||||||
|
for (const tab of this.tabs.values()) {
|
||||||
|
if (!entry.filePath && !tab.filePath && tab.title === (entry.title || 'Untitled')) {
|
||||||
|
return tab.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Recent files functionality
|
// Recent files functionality
|
||||||
addToRecentFiles(filePath) {
|
addToRecentFiles(filePath) {
|
||||||
if (!filePath) return;
|
if (!filePath) return;
|
||||||
@@ -1608,6 +1838,7 @@ class TabManager {
|
|||||||
}
|
}
|
||||||
this.startAutoSave();
|
this.startAutoSave();
|
||||||
this.addToRecentFiles(filePath);
|
this.addToRecentFiles(filePath);
|
||||||
|
this.scheduleSessionPersist(); // crash-recovery snapshot
|
||||||
this.updateTabBar();
|
this.updateTabBar();
|
||||||
this.updateFilePath();
|
this.updateFilePath();
|
||||||
this.updateBreadcrumb();
|
this.updateBreadcrumb();
|
||||||
@@ -1708,10 +1939,83 @@ async function loadTemplateIntoNewTab(file) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crash recovery: build and show a lightweight "restore session?" prompt.
|
||||||
|
* Rendered as a fixed overlay (not a ModalManager dialog) because modals are
|
||||||
|
* initialized inside DOMContentLoaded and this must appear above everything on
|
||||||
|
* the very first paint. Disappears on either choice; no opinion is recorded
|
||||||
|
* for future launches (the prompt appears whenever a snapshot exists).
|
||||||
|
*
|
||||||
|
* @param {object} session Snapshot from SessionStore.loadSession()
|
||||||
|
*/
|
||||||
|
function showRestoreSessionPrompt(session) {
|
||||||
|
const unsaved = session.tabs.filter((t) => t.isDirty && typeof t.content === 'string');
|
||||||
|
const summary =
|
||||||
|
`${session.tabs.length} tab${session.tabs.length === 1 ? '' : 's'}` +
|
||||||
|
(unsaved.length > 0 ? `, ${unsaved.length} with unsaved changes` : '');
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'session-restore-prompt';
|
||||||
|
overlay.setAttribute('role', 'dialog');
|
||||||
|
overlay.setAttribute('aria-modal', 'true');
|
||||||
|
overlay.setAttribute('aria-labelledby', 'session-restore-title');
|
||||||
|
overlay.style.cssText =
|
||||||
|
'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);' +
|
||||||
|
'display:flex;align-items:center;justify-content:center;z-index:10000;';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div style="background:var(--bg-primary,#fff);color:var(--text-primary,#222);border-radius:8px;
|
||||||
|
padding:24px;max-width:420px;box-shadow:0 8px 32px rgba(0,0,0,0.3);text-align:center;">
|
||||||
|
<h3 id="session-restore-title" style="margin:0 0 8px;">Restore previous session?</h3>
|
||||||
|
<p style="margin:0 0 20px;color:var(--text-secondary,#666);">
|
||||||
|
The app closed with ${summary}. Restore them now?
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;gap:12px;justify-content:center;">
|
||||||
|
<button id="session-restore-yes" type="button"
|
||||||
|
style="padding:8px 20px;border-radius:4px;border:none;cursor:pointer;
|
||||||
|
background:#4a90d9;color:#fff;">Restore session</button>
|
||||||
|
<button id="session-restore-no" type="button"
|
||||||
|
style="padding:8px 20px;border-radius:4px;border:1px solid #ccc;cursor:pointer;
|
||||||
|
background:transparent;color:inherit;">Start fresh</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const dismiss = () => overlay.remove();
|
||||||
|
overlay.querySelector('#session-restore-yes').addEventListener('click', () => {
|
||||||
|
dismiss();
|
||||||
|
const restored = tabManager ? tabManager.restoreSession(session) : 0;
|
||||||
|
notifyUser(restored > 0 ? `Restored ${restored} tab(s)` : 'Nothing to restore', 'success');
|
||||||
|
});
|
||||||
|
overlay.querySelector('#session-restore-no').addEventListener('click', () => {
|
||||||
|
dismiss();
|
||||||
|
SessionStore.clearSession(localStorage);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const ModalManager = _ModalManager;
|
const ModalManager = _ModalManager;
|
||||||
tabManager = new TabManager();
|
tabManager = new TabManager();
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// Crash recovery: offer to restore the previous session
|
||||||
|
// ================================
|
||||||
|
// A snapshot is written on edits/tab changes (debounced), on unload, and
|
||||||
|
// once a minute. It is kept only when it contains something restorable;
|
||||||
|
// a clean single-empty-tab state clears it.
|
||||||
|
const previousSession = SessionStore.loadSession(localStorage);
|
||||||
|
if (previousSession) {
|
||||||
|
showRestoreSessionPrompt(previousSession);
|
||||||
|
}
|
||||||
|
// Persist synchronously on unload — the debounced timer may not have fired
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
if (tabManager) tabManager.persistSession();
|
||||||
|
});
|
||||||
|
// Periodic safety net: guarantees a snapshot at most 60s old even when the
|
||||||
|
// user steps away mid-edit (laptop sleep, SIGKILL, power loss, etc.)
|
||||||
|
setInterval(() => {
|
||||||
|
if (tabManager) tabManager.persistSession();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
// Load saved Custom Preview CSS if present
|
// Load saved Custom Preview CSS if present
|
||||||
const savedCSSContent = localStorage.getItem('customPreviewCSSContent');
|
const savedCSSContent = localStorage.getItem('customPreviewCSSContent');
|
||||||
if (savedCSSContent) {
|
if (savedCSSContent) {
|
||||||
@@ -1805,8 +2109,17 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
render: (container) =>
|
render: (container) =>
|
||||||
getRenderSnippetsPanel()(container, {
|
getRenderSnippetsPanel()(container, {
|
||||||
getSnippets: () => ipcRenderer.invoke('get-snippets'),
|
getSnippets: () => ipcRenderer.invoke('get-snippets'),
|
||||||
saveSnippet: (s) => ipcRenderer.invoke('save-snippet', s),
|
// Wrap save/delete so the Tab-expansion map stays in sync
|
||||||
deleteSnippet: (id) => ipcRenderer.invoke('delete-snippet', id),
|
saveSnippet: async (s) => {
|
||||||
|
const result = await ipcRenderer.invoke('save-snippet', s);
|
||||||
|
refreshSnippetExpansions();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
deleteSnippet: async (id) => {
|
||||||
|
const result = await ipcRenderer.invoke('delete-snippet', id);
|
||||||
|
refreshSnippetExpansions();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
onInsert: (code) => tabManager.insertAtCursor(code),
|
onInsert: (code) => tabManager.insertAtCursor(code),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -1846,6 +2159,79 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Backlinks panel — "what links here?" for [[wiki links]] (knowledge-base mode)
|
||||||
|
const { renderBacklinksPanel } = require('./sidebar/backlinks-panel');
|
||||||
|
sidebarManager.registerPanel('backlinks', {
|
||||||
|
title: 'Backlinks',
|
||||||
|
render: (container) =>
|
||||||
|
renderBacklinksPanel(container, {
|
||||||
|
getCurrentFilePath: () => {
|
||||||
|
const tab = tabManager.tabs.get(tabManager.activeTabId);
|
||||||
|
return tab?.filePath || null;
|
||||||
|
},
|
||||||
|
// Bounded folder scan through the same IPC helper the Explorer uses
|
||||||
|
listDir: (dir) => ipcRenderer.invoke('list-directory', dir),
|
||||||
|
readFile: (p) => ipcRenderer.invoke('read-file', p),
|
||||||
|
onFileOpen: (p) => ipcRenderer.send('open-file-path', p),
|
||||||
|
pathUtil: require('path'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// History panel — local version history (snapshots captured before saves)
|
||||||
|
const { renderHistoryPanel } = require('./sidebar/history-panel');
|
||||||
|
sidebarManager.registerPanel('history', {
|
||||||
|
title: 'History',
|
||||||
|
render: (container) =>
|
||||||
|
renderHistoryPanel(container, {
|
||||||
|
getCurrentFilePath: () => {
|
||||||
|
const tab = tabManager.tabs.get(tabManager.activeTabId);
|
||||||
|
return tab?.filePath || null;
|
||||||
|
},
|
||||||
|
getCurrentContent: () => tabManager.getEditorContent(),
|
||||||
|
replaceContent: (content) => {
|
||||||
|
const tab = tabManager.tabs.get(tabManager.activeTabId);
|
||||||
|
if (!tab) return;
|
||||||
|
tabManager.setEditorContent(tab.id, content);
|
||||||
|
tab.isDirty = true;
|
||||||
|
tabManager.updatePreview(tab.id);
|
||||||
|
tabManager.updateTabBar();
|
||||||
|
},
|
||||||
|
history: {
|
||||||
|
list: (docPath) => ipcRenderer.invoke('version-history:list', docPath),
|
||||||
|
read: (docPath, id) => ipcRenderer.invoke('version-history:read', { docPath, id }),
|
||||||
|
save: (docPath, content, label) =>
|
||||||
|
ipcRenderer.invoke('version-history:save', { docPath, content, label }),
|
||||||
|
delete: (docPath, id) => ipcRenderer.invoke('version-history:delete', { docPath, id }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wiki-link navigation: open the target note (creating it on demand after
|
||||||
|
// confirmation). Delegated on document so it works in every tab's preview.
|
||||||
|
document.addEventListener('click', (event) => {
|
||||||
|
const link = event.target.closest?.('.wiki-link');
|
||||||
|
if (!link) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const target = link.getAttribute('data-wiki-target');
|
||||||
|
const tab = tabManager.tabs.get(tabManager.activeTabId);
|
||||||
|
if (!target || !tab?.filePath) return;
|
||||||
|
|
||||||
|
const pathUtil = require('path');
|
||||||
|
const targetPath = resolveTargetPath(target, pathUtil.dirname(tab.filePath), pathUtil);
|
||||||
|
if (!targetPath) return;
|
||||||
|
|
||||||
|
if (fs.existsSync(targetPath)) {
|
||||||
|
ipcRenderer.send('open-file-path', targetPath);
|
||||||
|
} else if (confirm(`"${target}" doesn't exist yet. Create it?`)) {
|
||||||
|
// Create then open — the note materializes with a title heading
|
||||||
|
ipcRenderer
|
||||||
|
.invoke('write-file', { path: targetPath, content: `# ${target}\n\n` })
|
||||||
|
.then(() => {
|
||||||
|
ipcRenderer.send('open-file-path', targetPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// View menu: toggle a sidebar panel by id (explorer/git/snippets/templates/outline)
|
// View menu: toggle a sidebar panel by id (explorer/git/snippets/templates/outline)
|
||||||
ipcRenderer.on('toggle-sidebar-panel', (event, panelId) => {
|
ipcRenderer.on('toggle-sidebar-panel', (event, panelId) => {
|
||||||
sidebarManager.togglePanel(panelId);
|
sidebarManager.togglePanel(panelId);
|
||||||
@@ -1899,6 +2285,28 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
getSelection: () => tabManager.getSelection(),
|
getSelection: () => tabManager.getSelection(),
|
||||||
insertAtCursor: (text) => tabManager.insertAtCursor(text),
|
insertAtCursor: (text) => tabManager.insertAtCursor(text),
|
||||||
onContentChanged: (cb) => pluginEventBus.on('document:changed', cb),
|
onContentChanged: (cb) => pluginEventBus.on('document:changed', cb),
|
||||||
|
// Current file path for plugins that store per-file sidecar data
|
||||||
|
getCurrentFilePath: () => {
|
||||||
|
const tab = tabManager.tabs.get(tabManager.activeTabId);
|
||||||
|
return tab?.filePath || null;
|
||||||
|
},
|
||||||
|
// Jump the active editor to a 1-based line (used by comment navigation)
|
||||||
|
jumpToLine: (line) => {
|
||||||
|
const tab = tabManager.tabs.get(tabManager.activeTabId);
|
||||||
|
if (!tab?.editorView || !Number.isFinite(line) || line < 1) return;
|
||||||
|
const maxLine = tab.editorView.state.doc.lines;
|
||||||
|
const target = Math.min(Math.floor(line), maxLine);
|
||||||
|
tab.editorView.dispatch({
|
||||||
|
selection: { anchor: tab.editorView.state.doc.line(target).from },
|
||||||
|
scrollIntoView: true,
|
||||||
|
});
|
||||||
|
tab.editorView.focus();
|
||||||
|
},
|
||||||
|
getCurrentLine: () => {
|
||||||
|
const tab = tabManager.tabs.get(tabManager.activeTabId);
|
||||||
|
if (!tab?.editorView) return 1;
|
||||||
|
return tab.editorView.state.doc.lineAt(tab.editorView.state.selection.main.head).number;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
ipc: {
|
ipc: {
|
||||||
invoke: (ch, data) => window.electronAPI.invoke(ch, data),
|
invoke: (ch, data) => window.electronAPI.invoke(ch, data),
|
||||||
@@ -1911,6 +2319,66 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
const discovered = loader.discoverPlugins();
|
const discovered = loader.discoverPlugins();
|
||||||
discovered.forEach((p) => pluginRegistry.register(p));
|
discovered.forEach((p) => pluginRegistry.register(p));
|
||||||
|
|
||||||
|
// Plugin panels request programmatic opening via the shared event bus
|
||||||
|
// (e.g. the AI Chat command). Expand the named sidebar panel in response.
|
||||||
|
pluginEventBus.on('sidebar:open-panel', ({ panel } = {}) => {
|
||||||
|
if (panel && typeof panel === 'string') sidebarManager.expand(panel);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wire the writing-studio plugin's sidebar panels (declared in its manifest
|
||||||
|
// but rendered by these panel modules): registering through the registry's
|
||||||
|
// context namespaces the ids to "writing-studio:<panel>" and adds rail icons.
|
||||||
|
const studioPlugin = pluginRegistry.getPlugin('writing-studio');
|
||||||
|
if (studioPlugin?.context) {
|
||||||
|
const ctx = studioPlugin.context;
|
||||||
|
const {
|
||||||
|
renderManuscriptPanel,
|
||||||
|
} = require('./plugins/built-in/writing-studio/panels/manuscript-panel');
|
||||||
|
const { renderGoalsPanel } = require('./plugins/built-in/writing-studio/panels/goals-panel');
|
||||||
|
const {
|
||||||
|
renderSnapshotsPanel,
|
||||||
|
} = require('./plugins/built-in/writing-studio/panels/snapshots-panel');
|
||||||
|
const {
|
||||||
|
renderProofreadPanel,
|
||||||
|
} = require('./plugins/built-in/writing-studio/panels/proofread-panel');
|
||||||
|
const studioIcon = (paths) =>
|
||||||
|
`<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
|
||||||
|
// Panels take {engines, settings, editor, events} — engines is the
|
||||||
|
// writing-studio plugin's engine map (sprint/goals/snapshots/projects).
|
||||||
|
const engines = studioPlugin.instance.getEngines ? studioPlugin.instance.getEngines() : {};
|
||||||
|
const panelDeps = { engines, settings: ctx.settings, editor: ctx.editor, events: ctx.events };
|
||||||
|
|
||||||
|
ctx.sidebar.registerPanel('manuscript', {
|
||||||
|
title: 'Manuscript',
|
||||||
|
icon: studioIcon(
|
||||||
|
'<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>'
|
||||||
|
),
|
||||||
|
render: (container) => renderManuscriptPanel(container, panelDeps),
|
||||||
|
});
|
||||||
|
ctx.sidebar.registerPanel('goals', {
|
||||||
|
title: 'Goals',
|
||||||
|
icon: studioIcon(
|
||||||
|
'<circle cx="12" cy="12" r="10"></circle><circle cx="12" cy="12" r="6"></circle><circle cx="12" cy="12" r="2"></circle>'
|
||||||
|
),
|
||||||
|
render: (container) => renderGoalsPanel(container, panelDeps),
|
||||||
|
});
|
||||||
|
ctx.sidebar.registerPanel('snapshots', {
|
||||||
|
title: 'Snapshots',
|
||||||
|
icon: studioIcon(
|
||||||
|
'<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"></path>'
|
||||||
|
),
|
||||||
|
render: (container) => renderSnapshotsPanel(container, panelDeps),
|
||||||
|
});
|
||||||
|
ctx.sidebar.registerPanel('proofread', {
|
||||||
|
title: 'Proofread',
|
||||||
|
icon: studioIcon(
|
||||||
|
'<path d="M9 11l3 3L22 4"></path><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>'
|
||||||
|
),
|
||||||
|
render: (container) => renderProofreadPanel(container, panelDeps),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Tell the main process which export formats plugins registered, so the
|
// Tell the main process which export formats plugins registered, so the
|
||||||
// Export menu (built in main.js, a separate process from this one) can
|
// Export menu (built in main.js, a separate process from this one) can
|
||||||
// grow entries for them. Only serializable metadata crosses the IPC
|
// grow entries for them. Only serializable metadata crosses the IPC
|
||||||
@@ -1975,6 +2443,18 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Vim mode: initial value from settings, then live toggles from the View menu
|
||||||
|
ipcRenderer
|
||||||
|
.invoke('get-vim-mode')
|
||||||
|
.then(applyVimMode)
|
||||||
|
.catch(() => {});
|
||||||
|
ipcRenderer.on('vim-setting-change', (event, enabled) => {
|
||||||
|
ipcRenderer
|
||||||
|
.invoke('set-vim-mode', enabled === true)
|
||||||
|
.then(() => applyVimMode(enabled === true))
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
// Welcome tab on startup
|
// Welcome tab on startup
|
||||||
const hasLaunched = localStorage.getItem('hasLaunchedBefore');
|
const hasLaunched = localStorage.getItem('hasLaunchedBefore');
|
||||||
const showWelcome = localStorage.getItem('showWelcomeOnStartup') !== 'false';
|
const showWelcome = localStorage.getItem('showWelcomeOnStartup') !== 'false';
|
||||||
@@ -3704,7 +4184,12 @@ ipcRenderer.on('show-table-generator', () => {
|
|||||||
|
|
||||||
// Show PDF Editor Dialog
|
// Show PDF Editor Dialog
|
||||||
ipcRenderer.on('show-pdf-editor-dialog', (event, operation, openedFilePath) => {
|
ipcRenderer.on('show-pdf-editor-dialog', (event, operation, openedFilePath) => {
|
||||||
currentPDFOperation = operation;
|
if (!operation) {
|
||||||
|
// File > Open PDF sends null — legit, but log unexpected variants so
|
||||||
|
// stray senders are traceable in the console.
|
||||||
|
console.info('[pdf-editor] opening with default section; operation =', operation);
|
||||||
|
}
|
||||||
|
currentPDFOperation = operation || 'merge';
|
||||||
showPDFEditorDialog(operation, openedFilePath);
|
showPDFEditorDialog(operation, openedFilePath);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3893,8 +4378,13 @@ function showPDFEditorDialog(operation, openedFilePath = null) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
title.textContent = titleText;
|
|
||||||
document.getElementById(sectionId).classList.remove('hidden');
|
// Unknown/null operations (e.g. File > Open PDF sends null meaning "open
|
||||||
|
// the editor with this file") fall back to the merge section instead of
|
||||||
|
// crashing on a missing element.
|
||||||
|
const section = document.getElementById(sectionId || 'pdf-merge-section');
|
||||||
|
title.textContent = titleText || 'PDF Editor';
|
||||||
|
if (section) section.classList.remove('hidden');
|
||||||
window.modals.pdfEditorModal.open();
|
window.modals.pdfEditorModal.open();
|
||||||
}
|
}
|
||||||
function hidePDFEditorDialog() {
|
function hidePDFEditorDialog() {
|
||||||
@@ -3922,12 +4412,13 @@ function updateMergeFilesList() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Task 27: pdf-lib 1.17.1 cannot encrypt, so encrypt/decrypt/permissions fail
|
// Password ops (encrypt/decrypt/permissions) are backed by @cantoo/pdf-lib,
|
||||||
// honestly in the main process. Ask main once whether password protection is
|
// which supports real encryption. Main reports its capability probe once; if a
|
||||||
// available and, when it is not, disable the three sections' controls with an
|
// build ever ships a library without encryption support, disable the three
|
||||||
// explanatory hint instead of letting the user fill the form only to see the
|
// sections' controls with an explanatory hint instead of letting the user fill
|
||||||
// operation fail. If the capability query itself fails, leave the controls
|
// the form only to see the operation fail. If the capability query itself
|
||||||
// enabled — main still fails the operation honestly on submit.
|
// fails, leave the controls enabled — main still fails the operation honestly
|
||||||
|
// on submit.
|
||||||
async function applyPDFPasswordProtectionAvailability() {
|
async function applyPDFPasswordProtectionAvailability() {
|
||||||
let capabilities;
|
let capabilities;
|
||||||
try {
|
try {
|
||||||
@@ -3947,14 +4438,14 @@ async function applyPDFPasswordProtectionAvailability() {
|
|||||||
const hint = document.createElement('p');
|
const hint = document.createElement('p');
|
||||||
hint.className = 'warning-message pdf-unavailable-hint';
|
hint.className = 'warning-message pdf-unavailable-hint';
|
||||||
hint.textContent =
|
hint.textContent =
|
||||||
'Password protection is not available in this build (pdf-lib lacks encryption support).';
|
'Password protection is not available in this build (the PDF library lacks encryption support).';
|
||||||
section.prepend(hint);
|
section.prepend(hint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PDF Editor Event Listeners
|
// PDF Editor Event Listeners
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Disable password-protection controls when pdf-lib lacks encryption support
|
// Disable password-protection controls if the PDF library lacks encryption support
|
||||||
applyPDFPasswordProtectionAvailability();
|
applyPDFPasswordProtectionAvailability();
|
||||||
|
|
||||||
// Close PDF Editor Dialog
|
// Close PDF Editor Dialog
|
||||||
@@ -4742,33 +5233,39 @@ ipcRenderer.on('pdf-operation-progress', (event, { message, progress }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add math rendering support using KaTeX for enhanced preview
|
// Add math rendering support using KaTeX for enhanced preview.
|
||||||
|
// KaTeX is bundled locally (assets/katex CSS + the katex npm package) so math
|
||||||
|
// renders offline and nothing is fetched from a CDN at runtime (the old
|
||||||
|
// jsdelivr links broke math rendering whenever the machine was offline and
|
||||||
|
// leaked document content requests to a third party).
|
||||||
function initMathSupport() {
|
function initMathSupport() {
|
||||||
// Add KaTeX CSS
|
// KaTeX CSS ships in index.html via <link href="assets/katex/katex.min.css">,
|
||||||
const katexCSS = document.createElement('link');
|
// including its font files. Only the JS needs wiring up here.
|
||||||
katexCSS.rel = 'stylesheet';
|
try {
|
||||||
katexCSS.href = 'https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.css';
|
// The renderer runs with nodeIntegration, so the locally installed npm
|
||||||
katexCSS.crossOrigin = 'anonymous';
|
// package can be required directly — no script-tag loading needed.
|
||||||
document.head.appendChild(katexCSS);
|
// katex exports the katex object; the auto-render contrib exports the
|
||||||
|
// renderMathInElement function itself (webpack default interop), so
|
||||||
|
// resolve both shapes defensively.
|
||||||
|
const katex = require('katex');
|
||||||
|
const autoRender = require('katex/contrib/auto-render');
|
||||||
|
window.katex = katex;
|
||||||
|
const renderFn =
|
||||||
|
typeof autoRender === 'function'
|
||||||
|
? autoRender
|
||||||
|
: autoRender.renderMathInElement || autoRender.default;
|
||||||
|
if (typeof renderFn !== 'function') throw new Error('auto-render export not found');
|
||||||
|
window.renderMathInElement = renderFn;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Local KaTeX unavailable, math rendering disabled:', error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Add KaTeX JS
|
// Math is now available synchronously; re-render the current preview so an
|
||||||
const katexJS = document.createElement('script');
|
// already-rendered tab picks up math without waiting for the next edit.
|
||||||
katexJS.src = 'https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.js';
|
if (tabManager) {
|
||||||
katexJS.crossOrigin = 'anonymous';
|
tabManager.updatePreview();
|
||||||
katexJS.onload = () => {
|
}
|
||||||
// Add auto-render extension
|
|
||||||
const autoRenderJS = document.createElement('script');
|
|
||||||
autoRenderJS.src = 'https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/contrib/auto-render.min.js';
|
|
||||||
autoRenderJS.crossOrigin = 'anonymous';
|
|
||||||
autoRenderJS.onload = () => {
|
|
||||||
// Re-render current preview to include math
|
|
||||||
if (tabManager) {
|
|
||||||
tabManager.updatePreview();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.head.appendChild(autoRenderJS);
|
|
||||||
};
|
|
||||||
document.head.appendChild(katexJS);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize math support on load
|
// Initialize math support on load
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Backlinks sidebar panel — the "what links here?" view for the local
|
||||||
|
* knowledge-base. Lists documents in the current folder that wiki-link to the
|
||||||
|
* active document, with jump-to-source. Re-scans on every render (panel opens
|
||||||
|
* / refresh) because scans are bounded and folders are small in practice.
|
||||||
|
*
|
||||||
|
* @module BacklinksPanel
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { collectMarkdownFiles, findBacklinks } = require('../utils/backlinks');
|
||||||
|
const { docNameFor } = require('../utils/wiki-links');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the backlinks panel.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} container Sidebar panel content element
|
||||||
|
* @param {object} deps
|
||||||
|
* @param {() => string|null} deps.getCurrentFilePath Active tab's file path
|
||||||
|
* @param {(dir: string) => Promise<{entries:Array}|null>} deps.listDir
|
||||||
|
* @param {(path: string) => Promise<string|null>} deps.readFile
|
||||||
|
* @param {(path: string) => void} deps.onFileOpen Open a file in a new tab
|
||||||
|
* @param {Function} deps.pathUtil injected path module
|
||||||
|
*/
|
||||||
|
async function renderBacklinksPanel(container, deps) {
|
||||||
|
container.replaceChildren();
|
||||||
|
const { getCurrentFilePath, listDir, readFile, onFileOpen, pathUtil } = deps;
|
||||||
|
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.className = 'ws-panel';
|
||||||
|
|
||||||
|
const heading = document.createElement('h3');
|
||||||
|
heading.className = 'ws-heading';
|
||||||
|
heading.textContent = 'Backlinks';
|
||||||
|
panel.appendChild(heading);
|
||||||
|
|
||||||
|
const docPath = getCurrentFilePath();
|
||||||
|
if (!docPath) {
|
||||||
|
appendNote(panel, 'Open a saved document to see what links to it.');
|
||||||
|
container.appendChild(panel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const docName = docNameFor(docPath, pathUtil);
|
||||||
|
const dir = pathUtil.dirname(docPath);
|
||||||
|
const status = document.createElement('p');
|
||||||
|
status.className = 'ws-muted';
|
||||||
|
status.textContent = `Scanning for links to "${docName}"…`;
|
||||||
|
panel.appendChild(status);
|
||||||
|
container.appendChild(panel);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await collectMarkdownFiles(dir, listDir);
|
||||||
|
const links = await findBacklinks({ docName, docPath, files, readFile });
|
||||||
|
|
||||||
|
status.textContent =
|
||||||
|
files.length === 0
|
||||||
|
? 'No markdown files found in this folder.'
|
||||||
|
: `${links.length} document${links.length === 1 ? '' : 's'} link here ` +
|
||||||
|
`(scanned ${files.length} files)`;
|
||||||
|
|
||||||
|
if (links.length > 0) {
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'ws-issues-list';
|
||||||
|
for (const link of links) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'ws-issue-item';
|
||||||
|
const name = pathUtil.basename(link.path);
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="ws-issue-type"><span>${escapeText(name)} : line ${link.line}</span></div>
|
||||||
|
<div class="ws-issue-text">${escapeText(link.context)}</div>`;
|
||||||
|
// Whole item is clickable — jump straight to the linking document
|
||||||
|
item.setAttribute('role', 'link');
|
||||||
|
item.setAttribute('tabindex', '0');
|
||||||
|
item.style.cursor = 'pointer';
|
||||||
|
item.addEventListener('click', () => onFileOpen(link.path));
|
||||||
|
item.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter') onFileOpen(link.path);
|
||||||
|
});
|
||||||
|
list.appendChild(item);
|
||||||
|
}
|
||||||
|
panel.appendChild(list);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
status.textContent = 'Scan failed: ' + (error.message || error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendNote(parent, text) {
|
||||||
|
const note = document.createElement('p');
|
||||||
|
note.className = 'ws-muted';
|
||||||
|
note.textContent = text;
|
||||||
|
parent.appendChild(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeText(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { renderBacklinksPanel };
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user